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>
This commit is contained in:
cproudlock
2026-01-21 16:37:49 -05:00
parent 7e729c8fdc
commit c8b325d2e7
108 changed files with 17693 additions and 600 deletions

View File

@@ -0,0 +1,601 @@
<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Notification' : (isDetail ? 'Notification Details' : 'New Notification') }}</h2>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="saveNotification">
<div class="form-group">
<label for="notificationtypeid">Type *</label>
<select
id="notificationtypeid"
v-model="form.notificationtypeid"
class="form-control"
required
:disabled="isDetail"
@change="onTypeChange"
>
<option value="">-- Select Type --</option>
<option
v-for="type in types"
:key="type.notificationtypeid"
:value="type.notificationtypeid"
>
{{ type.typename }}
</option>
</select>
<small class="form-hint">Classification type for this notification</small>
</div>
<div class="form-group">
<label for="notification">{{ isRecognition ? 'Recognition Message' : 'Notification' }} *</label>
<textarea
id="notification"
v-model="form.notification"
class="form-control"
rows="4"
required
:disabled="isDetail"
:placeholder="isRecognition ? 'Enter the recognition message...' : 'Enter the notification message...'"
></textarea>
</div>
<!-- Employee Search - Only for Recognition -->
<div v-if="isRecognition" class="form-group">
<label>Employee(s) *</label>
<div class="employee-search-container">
<input
v-model="employeeSearch"
type="text"
class="form-control"
placeholder="Search by name..."
:disabled="isDetail"
@input="searchEmployees"
@keydown.enter.prevent="addCustomEmployee"
/>
<div v-if="employeeResults.length" class="employee-dropdown">
<div
v-for="emp in employeeResults"
:key="emp.SSO"
class="employee-option"
@click="selectEmployee(emp)"
>
<span class="emp-name">{{ emp.First_Name }} {{ emp.Last_Name }}</span>
<span class="emp-sso">{{ emp.SSO }}</span>
</div>
</div>
</div>
<small class="form-hint">Search for employees or press Enter to add a custom name</small>
<!-- Selected Employees -->
<div v-if="selectedEmployees.length" class="selected-employees">
<div
v-for="(emp, idx) in selectedEmployees"
:key="idx"
class="selected-employee"
>
<span>{{ emp.name }}</span>
<button v-if="!isDetail" type="button" class="btn-remove" @click="removeEmployee(idx)">&times;</button>
</div>
</div>
</div>
<div class="form-group">
<label for="businessunitid">Business Unit</label>
<select
id="businessunitid"
v-model="form.businessunitid"
class="form-control"
:disabled="isDetail"
>
<option value="">-- All Business Units --</option>
<option
v-for="bu in businessUnits"
:key="bu.businessunitid"
:value="bu.businessunitid"
>
{{ bu.businessunit }}
</option>
</select>
<small class="form-hint">Leave blank to apply to all</small>
</div>
<div class="form-group">
<label for="appid">Related Application</label>
<select
id="appid"
v-model="form.appid"
class="form-control"
:disabled="isDetail"
>
<option value="">-- No Application --</option>
<option
v-for="app in applications"
:key="app.appid"
:value="app.appid"
>
{{ app.appname }}
</option>
</select>
<small class="form-hint">Link to a specific application (e.g., for software updates)</small>
</div>
<div v-if="!isRecognition" class="form-group">
<label for="ticketnumber">Ticket Number</label>
<input
id="ticketnumber"
v-model="form.ticketnumber"
type="text"
class="form-control"
maxlength="50"
:disabled="isDetail"
placeholder="GEINC123456 or GECHG123456"
/>
<small class="form-hint">Optional ServiceNow ticket number</small>
</div>
<div class="form-group">
<label for="link">More Info URL</label>
<input
id="link"
v-model="form.link"
type="url"
class="form-control"
maxlength="500"
:disabled="isDetail"
placeholder="https://..."
/>
</div>
<!-- Time fields - Hidden for Recognition (auto-set) -->
<div v-if="!isRecognition" class="form-row">
<div class="form-group">
<label for="starttime">Start Time *</label>
<div class="input-group">
<input
id="starttime"
v-model="form.starttime"
type="datetime-local"
class="form-control"
required
:disabled="isDetail"
/>
<button v-if="!isDetail" type="button" class="btn btn-secondary" @click="setNow('starttime')">
Now
</button>
</div>
<small class="form-hint">When notification becomes visible</small>
</div>
<div class="form-group">
<label for="endtime">End Time</label>
<div class="input-group">
<input
id="endtime"
v-model="form.endtime"
type="datetime-local"
class="form-control"
:disabled="isDetail"
/>
<button v-if="!isDetail" type="button" class="btn btn-secondary" @click="setNow('endtime')">
Now
</button>
<button v-if="!isDetail" type="button" class="btn btn-secondary" @click="form.endtime = ''">
Clear
</button>
</div>
<small class="form-hint">Leave blank for indefinite</small>
</div>
</div>
<div class="form-row checkbox-row">
<div class="form-group">
<label>
<input
type="checkbox"
v-model="form.isactive"
:disabled="isDetail"
/>
Active
</label>
<small class="form-hint">Uncheck to save as draft</small>
</div>
<div class="form-group">
<label>
<input
type="checkbox"
v-model="form.isshopfloor"
:disabled="isDetail"
/>
Show on Shopfloor Dashboard
</label>
<small class="form-hint">Display on TV dashboard</small>
</div>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="form-actions">
<template v-if="isDetail">
<router-link :to="`/notifications/${route.params.id}/edit`" class="btn btn-primary">
Edit
</router-link>
<router-link to="/notifications" class="btn btn-secondary">Back</router-link>
</template>
<template v-else>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : (isEdit ? 'Update' : 'Create') }}
</button>
<router-link to="/notifications" class="btn btn-secondary">Cancel</router-link>
</template>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { notificationsApi, applicationsApi, businessUnitsApi, employeesApi } from '@/api'
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => route.name === 'notification-edit')
const isDetail = computed(() => route.name === 'notification-detail')
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const types = ref([])
const businessUnits = ref([])
const applications = ref([])
// Employee search
const employeeSearch = ref('')
const employeeResults = ref([])
const selectedEmployees = ref([])
const form = ref({
notification: '',
notificationtypeid: '',
businessunitid: '',
appid: '',
ticketnumber: '',
link: '',
starttime: '',
endtime: '',
isactive: true,
isshopfloor: false,
employeesso: ''
})
// Check if selected type is Recognition
const isRecognition = computed(() => {
const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid))
return selectedType?.typename?.toLowerCase() === 'recognition'
})
onMounted(async () => {
try {
// Load dropdown data in parallel
const [typesRes, buRes, appsRes] = await Promise.all([
notificationsApi.types.list(),
businessUnitsApi.list().catch(() => ({ data: { data: [] } })),
applicationsApi.list({ per_page: 500 }).catch(() => ({ data: { data: [] } }))
])
types.value = typesRes.data.data || []
businessUnits.value = buRes.data?.data || []
applications.value = appsRes.data?.data || []
// Load notification if editing or viewing
if (route.params.id) {
const response = await notificationsApi.get(route.params.id)
const n = response.data.data
form.value = {
notification: n.notification || '',
notificationtypeid: n.notificationtypeid || '',
businessunitid: n.businessunitid || '',
appid: n.appid || '',
ticketnumber: n.ticketnumber || '',
link: n.link || '',
starttime: formatDateForInput(n.starttime),
endtime: formatDateForInput(n.endtime),
isactive: n.isactive !== false,
isshopfloor: n.isshopfloor || false,
employeesso: n.employeesso || ''
}
// Parse existing employee data
if (n.employeesso) {
const ssos = n.employeesso.split(',')
for (const sso of ssos) {
if (sso.trim()) {
// Try to look up the employee name
const name = n.employeename || sso.trim()
selectedEmployees.value.push({ sso: sso.trim(), name })
}
}
}
} else {
// Set default start date to now
form.value.starttime = formatDateForInput(new Date().toISOString())
}
} catch (err) {
console.error('Error loading data:', err)
error.value = 'Failed to load data'
} finally {
loading.value = false
}
})
function formatDateForInput(dateStr) {
if (!dateStr) return ''
const date = new Date(dateStr)
return date.toISOString().slice(0, 16)
}
function setNow(field) {
form.value[field] = formatDateForInput(new Date().toISOString())
}
function onTypeChange() {
// If switching to Recognition, auto-set times
if (isRecognition.value) {
const now = new Date()
form.value.starttime = formatDateForInput(now.toISOString())
// End time = now + 30 days
const endDate = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
form.value.endtime = formatDateForInput(endDate.toISOString())
}
}
// Employee search
let searchTimeout = null
async function searchEmployees() {
if (searchTimeout) clearTimeout(searchTimeout)
const query = employeeSearch.value.trim()
if (query.length < 2) {
employeeResults.value = []
return
}
searchTimeout = setTimeout(async () => {
try {
const res = await employeesApi.search(query)
employeeResults.value = res.data.data || []
} catch (err) {
console.error('Employee search error:', err)
employeeResults.value = []
}
}, 300)
}
function selectEmployee(emp) {
// Check if already selected
if (selectedEmployees.value.some(e => e.sso === String(emp.SSO))) {
return
}
selectedEmployees.value.push({
sso: String(emp.SSO),
name: `${emp.First_Name} ${emp.Last_Name}`.trim()
})
employeeSearch.value = ''
employeeResults.value = []
updateEmployeeSso()
}
function addCustomEmployee() {
const name = employeeSearch.value.trim()
if (!name) return
// Add as custom name (no SSO)
selectedEmployees.value.push({
sso: `NAME:${name}`,
name: name
})
employeeSearch.value = ''
employeeResults.value = []
updateEmployeeSso()
}
function removeEmployee(idx) {
selectedEmployees.value.splice(idx, 1)
updateEmployeeSso()
}
function updateEmployeeSso() {
form.value.employeesso = selectedEmployees.value.map(e => e.sso).join(',')
}
async function saveNotification() {
error.value = ''
// Validation for Recognition type
if (isRecognition.value && selectedEmployees.value.length === 0) {
error.value = 'Please select at least one employee for recognition'
return
}
saving.value = true
try {
const data = {
notification: form.value.notification,
notificationtypeid: parseInt(form.value.notificationtypeid) || null,
businessunitid: parseInt(form.value.businessunitid) || null,
appid: parseInt(form.value.appid) || null,
ticketnumber: form.value.ticketnumber || null,
link: form.value.link || null,
starttime: form.value.starttime ? new Date(form.value.starttime).toISOString() : null,
endtime: form.value.endtime ? new Date(form.value.endtime).toISOString() : null,
isactive: form.value.isactive,
isshopfloor: form.value.isshopfloor,
employeesso: form.value.employeesso || null
}
// For recognition, also send employeename
if (isRecognition.value && selectedEmployees.value.length > 0) {
data.employeename = selectedEmployees.value.map(e => e.name).join(', ')
}
if (isEdit.value) {
await notificationsApi.update(route.params.id, data)
} else {
await notificationsApi.create(data)
}
router.push('/notifications')
} catch (err) {
console.error('Error saving notification:', err)
error.value = err.response?.data?.message || 'Failed to save notification'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.input-group {
display: flex;
gap: 0.25rem;
}
.input-group .form-control {
flex: 1;
}
.input-group .btn {
flex-shrink: 0;
}
.checkbox-row {
display: flex;
gap: 2rem;
margin-top: 1rem;
}
.checkbox-row .form-group {
flex: none;
}
.checkbox-row label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
.form-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
}
/* Employee search styles */
.employee-search-container {
position: relative;
}
.employee-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-card-solid, #1a1a1a);
border: 1px solid var(--border);
border-radius: 0.25rem;
max-height: 200px;
overflow-y: auto;
z-index: 100;
}
.employee-option {
padding: 0.5rem 0.75rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.employee-option:hover {
background: rgba(255, 255, 255, 0.1);
}
.emp-name {
font-weight: 500;
}
.emp-sso {
font-size: 0.85rem;
color: var(--text-light);
}
.selected-employees {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.selected-employee {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--primary);
color: white;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.9rem;
}
.btn-remove {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 0;
font-size: 1.2rem;
line-height: 1;
opacity: 0.7;
}
.btn-remove:hover {
opacity: 1;
}
@media (max-width: 600px) {
.form-row {
grid-template-columns: 1fr;
}
.checkbox-row {
flex-direction: column;
gap: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<div class="page-header">
<h1>Notifications</h1>
<router-link to="/notifications/new" class="btn btn-primary">New Notification</router-link>
</div>
<div class="filters">
<input
v-model="searchQuery"
type="text"
class="form-control"
placeholder="Search notifications..."
@input="debouncedSearch"
/>
<select v-model="selectedType" class="form-control" @change="loadNotifications">
<option value="">All Types</option>
<option v-for="type in types" :key="type.notificationtypeid" :value="type.notificationtypeid">
{{ type.typename }}
</option>
</select>
<select v-model="currentFilter" class="form-control" @change="loadNotifications">
<option value="">All</option>
<option value="current">Current Only</option>
<option value="pinned">Pinned Only</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="notifications.length === 0" class="empty">
No notifications found.
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Title</th>
<th>Type</th>
<th>Start Date</th>
<th>End Date</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="notification in notifications" :key="notification.notificationid">
<td>
<router-link :to="`/notifications/${notification.notificationid}`">
{{ notification.title }}
</router-link>
<span v-if="notification.ispinned" class="badge badge-primary" title="Pinned">Pinned</span>
</td>
<td>
<span class="badge" :style="{ backgroundColor: notification.typecolor }">
{{ notification.typename }}
</span>
</td>
<td>{{ formatDate(notification.startdate) }}</td>
<td>{{ notification.enddate ? formatDate(notification.enddate) : 'No end' }}</td>
<td>
<span :class="['badge', notification.iscurrent ? 'badge-success' : 'badge-secondary']">
{{ notification.iscurrent ? 'Active' : 'Inactive' }}
</span>
</td>
<td class="actions">
<router-link :to="`/notifications/${notification.notificationid}/edit`" class="btn btn-small">
Edit
</router-link>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination" v-if="totalPages > 1">
<button
v-for="p in totalPages"
:key="p"
:class="{ active: p === page }"
@click="goToPage(p)"
>
{{ p }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { notificationsApi } from '@/api'
const notifications = ref([])
const types = ref([])
const loading = ref(true)
const searchQuery = ref('')
const selectedType = ref('')
const currentFilter = ref('')
const page = ref(1)
const perPage = ref(20)
const total = ref(0)
const totalPages = ref(1)
let searchTimeout = null
onMounted(async () => {
await loadTypes()
await loadNotifications()
})
async function loadTypes() {
try {
const response = await notificationsApi.types.list()
types.value = response.data.data
} catch (error) {
console.error('Error loading types:', error)
}
}
async function loadNotifications() {
loading.value = true
try {
const params = {
page: page.value,
per_page: perPage.value
}
if (searchQuery.value) {
params.search = searchQuery.value
}
if (selectedType.value) {
params.type_id = selectedType.value
}
if (currentFilter.value === 'current') {
params.current = 'true'
} else if (currentFilter.value === 'pinned') {
params.pinned = 'true'
}
const response = await notificationsApi.list(params)
notifications.value = response.data.data
total.value = response.data.meta?.pagination?.total || notifications.value.length
totalPages.value = Math.ceil(total.value / perPage.value)
} catch (error) {
console.error('Error loading notifications:', error)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
page.value = 1
loadNotifications()
}, 300)
}
function goToPage(p) {
page.value = p
loadNotifications()
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString()
}
</script>