Files
shopdb-flask/frontend/src/views/printers/PrinterForm.vue
cproudlock c8b325d2e7 Add USB, Notifications, Network plugins and reusable EmployeeSearch component
New Plugins:
- USB plugin: Device checkout/checkin with employee lookup, checkout history
- Notifications plugin: Announcements with types, scheduling, shopfloor display
- Network plugin: Network device management with subnets and VLANs
- Equipment and Computers plugins: Asset type separation

Frontend:
- EmployeeSearch component: Reusable employee lookup with autocomplete
- USB views: List, detail, checkout/checkin modals
- Notifications views: List, form with recognition mode
- Network views: Device list, detail, form
- Calendar view with FullCalendar integration
- Shopfloor and TV dashboard views
- Reports index page
- Map editor for asset positioning
- Light/dark mode fixes for map tooltips

Backend:
- Employee search API with external lookup service
- Collector API for PowerShell data collection
- Reports API endpoints
- Slides API for TV dashboard
- Fixed AppVersion model (removed BaseModel inheritance)
- Added checkout_name column to usbcheckouts table

Styling:
- Unified detail page styles
- Improved pagination (page numbers instead of prev/next)
- Dark/light mode theme improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:37:49 -05:00

602 lines
18 KiB
Vue

<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Printer' : 'New Printer' }}</h2>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="savePrinter">
<div class="form-row">
<div class="form-group">
<label for="machinenumber">Windows Name *</label>
<input
id="machinenumber"
v-model="form.machinenumber"
type="text"
class="form-control"
required
@input="onWindowsNameInput"
:class="{ 'auto-generated': !manualWindowsName && form.machinenumber }"
/>
<small class="form-hint">Auto-generated from CSF Name, Alias, Vendor & Model</small>
</div>
<div class="form-group">
<label for="alias">Alias / Location</label>
<input
id="alias"
v-model="form.alias"
type="text"
class="form-control"
placeholder="e.g., SpoolsInspection"
/>
<small class="form-hint">Used in Windows name generation</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="hostname">Hostname (FQDN)</label>
<input
id="hostname"
v-model="form.hostname"
type="text"
class="form-control"
@input="onHostnameInput"
:class="{ 'auto-generated': !manualHostname && form.hostname }"
/>
<small class="form-hint">Auto-generated from IP address</small>
</div>
<div class="form-group">
<label for="serialnumber">Serial Number</label>
<input
id="serialnumber"
v-model="form.serialnumber"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="machinetypeid">Printer Type *</label>
<select
id="machinetypeid"
v-model="form.machinetypeid"
class="form-control"
required
@change="form.modelnumberid = ''"
>
<option value="">Select type...</option>
<option
v-for="mt in printerTypes"
:key="mt.machinetypeid"
:value="mt.machinetypeid"
>
{{ mt.machinetype }}
</option>
</select>
</div>
<div class="form-group">
<label for="statusid">Status</label>
<select
id="statusid"
v-model="form.statusid"
class="form-control"
>
<option value="">Select status...</option>
<option
v-for="s in statuses"
:key="s.statusid"
:value="s.statusid"
>
{{ s.status }}
</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="vendorid">Vendor</label>
<select
id="vendorid"
v-model="form.vendorid"
class="form-control"
@change="form.modelnumberid = ''"
>
<option value="">Select vendor...</option>
<option
v-for="v in vendors"
:key="v.vendorid"
:value="v.vendorid"
>
{{ v.vendor }}
</option>
</select>
</div>
<div class="form-group">
<label for="modelnumberid">Model</label>
<select
id="modelnumberid"
v-model="form.modelnumberid"
class="form-control"
>
<option value="">Select model...</option>
<option
v-for="m in filteredModels"
:key="m.modelnumberid"
:value="m.modelnumberid"
>
{{ m.modelnumber }}
</option>
</select>
<small v-if="!form.vendorid && !form.machinetypeid" class="form-hint">
Select vendor or printer type to filter models
</small>
</div>
</div>
<div class="form-group">
<label for="locationid">Location</label>
<select
id="locationid"
v-model="form.locationid"
class="form-control"
>
<option value="">Select location...</option>
<option
v-for="l in locations"
:key="l.locationid"
:value="l.locationid"
>
{{ l.location }}
</option>
</select>
</div>
<!-- Printer-specific fields -->
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Printer Settings</h4>
<div class="form-row">
<div class="form-group">
<label for="ipaddress">IP Address</label>
<input
id="ipaddress"
v-model="form.ipaddress"
type="text"
class="form-control"
placeholder="e.g., 192.168.1.100"
/>
</div>
<div class="form-group">
<label for="csfname">CSF Name</label>
<input
id="csfname"
v-model="form.csfname"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="pin">PIN</label>
<input
id="pin"
v-model="form.pin"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-group">
<label for="installpath">Driver Install Path</label>
<input
id="installpath"
v-model="form.installpath"
type="text"
class="form-control"
placeholder="Leave empty for universal driver"
/>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea
id="notes"
v-model="form.notes"
class="form-control"
rows="3"
></textarea>
</div>
<!-- Map Location Picker -->
<div class="form-group">
<label>Map Location</label>
<div class="map-location-control">
<div v-if="form.mapleft !== null && form.maptop !== null" class="current-position">
Position: {{ form.mapleft }}, {{ form.maptop }}
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
</div>
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
Set Location on Map
</button>
</div>
</div>
<!-- Map Picker Modal -->
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
<div class="map-modal-content">
<ShopFloorMap
:pickerMode="true"
:initialPosition="form.mapleft !== null ? { left: form.mapleft, top: form.maptop } : null"
:theme="currentTheme"
@positionPicked="handlePositionPicked"
/>
</div>
<template #footer>
<button class="btn btn-secondary" @click="showMapPicker = false">Cancel</button>
<button class="btn btn-primary" @click="confirmMapPosition">Confirm Location</button>
</template>
</Modal>
<div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save Printer' }}
</button>
<router-link to="/printers" class="btn btn-secondary">Cancel</router-link>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, printersApi, modelsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
const manualHostname = ref(false)
const manualWindowsName = ref(false)
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const showMapPicker = ref(false)
const tempMapPosition = ref(null)
const form = ref({
machinenumber: '',
alias: '',
hostname: '',
serialnumber: '',
machinetypeid: '',
statusid: '',
vendorid: '',
modelnumberid: '',
locationid: '',
notes: '',
mapleft: null,
maptop: null,
// Printer-specific
ipaddress: '',
csfname: '',
installpath: '',
pin: ''
})
const printerTypes = ref([])
const statuses = ref([])
const vendors = ref([])
const models = ref([])
const locations = ref([])
// Filter models by selected vendor and printer type
const filteredModels = computed(() => {
return models.value.filter(m => {
// Filter by vendor if selected
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
return false
}
// Filter by printer type if selected
if (form.value.machinetypeid && m.machinetypeid !== form.value.machinetypeid) {
return false
}
return true
})
})
// Get short description from model number for naming
function getModelShortDesc(modelNumber) {
if (!modelNumber) return ''
const mn = modelNumber.toLowerCase()
if (mn.includes('colorlaserjet')) return 'ColorLaserJet'
if (mn.includes('laserjetpro') || mn.includes('laserjet pro')) return 'LaserJetPro'
if (mn.includes('laserjet')) return 'LaserJet'
if (mn.includes('altalink')) return 'Altalink'
if (mn.includes('versalink')) return 'Versalink'
if (mn.includes('designjet')) return 'DesignJet'
if (mn.includes('dtc')) return 'DTC'
if (mn.includes('officejet')) return 'OfficeJet'
if (mn.includes('pagewide')) return 'PageWide'
// Fallback: get letters before first digit
const match = modelNumber.match(/^([A-Za-z]+)/)
return match ? match[1] : modelNumber.substring(0, 5)
}
// Auto-generate hostname from IP address
function generateHostname(ip) {
if (!ip) return ''
const ipDashed = ip.replace(/\./g, '-')
return `Printer-${ipDashed}.printer.geaerospace.net`
}
// Auto-generate Windows name (machinenumber)
function generateWindowsName() {
const parts = []
// 1. CSF Name (if set and not "NONE")
const csfName = form.value.csfname?.trim()
if (csfName && csfName.toUpperCase() !== 'NONE') {
parts.push(csfName.replace(/\s+/g, ''))
}
// 2. Location (from alias, removing spaces and "Machine")
const alias = form.value.alias?.trim()
if (alias) {
const location = alias.replace(/\s+/g, '').replace(/Machine/gi, '')
// Skip if same as CSF name
if (location.toLowerCase() !== csfName?.toLowerCase()) {
parts.push(location)
}
}
// 3. Vendor + Model short description
const selectedModel = models.value.find(m => m.modelnumberid === form.value.modelnumberid)
const selectedVendor = vendors.value.find(v => v.vendorid === form.value.vendorid)
let vendorModel = ''
if (selectedVendor) {
vendorModel = selectedVendor.vendor.replace(/\s+/g, '')
}
if (selectedModel) {
vendorModel += getModelShortDesc(selectedModel.modelnumber)
}
if (vendorModel) {
parts.push(vendorModel)
}
return parts.join('-')
}
// Watch IP address and auto-generate hostname
watch(() => form.value.ipaddress, (newIp) => {
if (!manualHostname.value && newIp) {
form.value.hostname = generateHostname(newIp)
}
})
// Watch fields that affect Windows name generation
watch(
() => [form.value.csfname, form.value.alias, form.value.vendorid, form.value.modelnumberid],
() => {
if (!manualWindowsName.value && !isEdit.value) {
const generated = generateWindowsName()
if (generated) {
form.value.machinenumber = generated
}
}
}
)
// Track manual edits to hostname
function onHostnameInput() {
manualHostname.value = true
}
// Track manual edits to Windows name
function onWindowsNameInput() {
manualWindowsName.value = true
}
onMounted(async () => {
try {
// Load reference data
const [mtRes, statusRes, vendorRes, modelsRes, locRes] = await Promise.all([
machinetypesApi.list({ category: 'Printer' }),
statusesApi.list(),
vendorsApi.list(),
modelsApi.list(),
locationsApi.list()
])
printerTypes.value = mtRes.data.data || []
statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || []
models.value = modelsRes.data.data || []
locations.value = locRes.data.data || []
// Load printer if editing
if (isEdit.value) {
const response = await printersApi.get(route.params.id)
const printer = response.data.data
// Get IP from communications
const primaryComm = printer.communications?.find(c => c.isprimary) || printer.communications?.[0]
form.value = {
machinenumber: printer.machinenumber || '',
alias: printer.alias || '',
hostname: printer.hostname || '',
serialnumber: printer.serialnumber || '',
machinetypeid: printer.machinetype?.machinetypeid || '',
statusid: printer.status?.statusid || '',
vendorid: printer.vendor?.vendorid || '',
modelnumberid: printer.model?.modelnumberid || '',
locationid: printer.location?.locationid || '',
notes: printer.notes || '',
mapleft: printer.mapleft ?? null,
maptop: printer.maptop ?? null,
// Printer-specific
ipaddress: primaryComm?.ipaddress || '',
csfname: printer.printerdata?.sharename || '',
installpath: printer.printerdata?.installpath || '',
pin: printer.printerdata?.pin || ''
}
// Don't auto-generate for existing printers
manualWindowsName.value = true
manualHostname.value = true
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
} finally {
loading.value = false
}
})
function handlePositionPicked(position) {
tempMapPosition.value = position
}
function confirmMapPosition() {
if (tempMapPosition.value) {
form.value.mapleft = tempMapPosition.value.left
form.value.maptop = tempMapPosition.value.top
}
showMapPicker.value = false
}
function clearMapPosition() {
form.value.mapleft = null
form.value.maptop = null
tempMapPosition.value = null
}
async function savePrinter() {
error.value = ''
saving.value = true
try {
const machineData = {
machinenumber: form.value.machinenumber,
alias: form.value.alias,
hostname: form.value.hostname,
serialnumber: form.value.serialnumber,
machinetypeid: form.value.machinetypeid || null,
statusid: form.value.statusid || null,
vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null,
locationid: form.value.locationid || null,
notes: form.value.notes,
mapleft: form.value.mapleft,
maptop: form.value.maptop
}
const printerData = {
windowsname: form.value.machinenumber, // Windows name is the machinenumber
sharename: form.value.csfname,
installpath: form.value.installpath,
pin: form.value.pin,
iscsf: !!form.value.csfname // Auto-set based on whether CSF name is filled
}
// Handle IP address - need to update/create communication record
const communicationData = form.value.ipaddress ? {
ipaddress: form.value.ipaddress,
isprimary: true
} : null
if (isEdit.value) {
await machinesApi.update(route.params.id, machineData)
await printersApi.updateExtension(route.params.id, printerData)
if (communicationData) {
await printersApi.updateCommunication(route.params.id, communicationData)
}
} else {
const response = await machinesApi.create(machineData)
const newId = response.data.data.machineid
await printersApi.updateExtension(newId, printerData)
if (communicationData) {
await printersApi.updateCommunication(newId, communicationData)
}
}
router.push('/printers')
} catch (err) {
console.error('Error saving printer:', err)
error.value = err.response?.data?.message || 'Failed to save printer'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.map-location-control {
display: flex;
align-items: center;
gap: 1rem;
}
.current-position {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #e3f2fd;
border-radius: 4px;
font-family: monospace;
}
.map-modal-content {
height: calc(90vh - 140px);
}
.map-modal-content :deep(.shopfloor-map) {
height: 100%;
}
.map-modal-content :deep(.map-container) {
height: calc(100% - 50px);
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light, #666);
}
.auto-generated {
background-color: #f0f7ff;
border-color: #90caf9;
}
.auto-generated:focus {
background-color: white;
}
</style>