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:
320
frontend/src/views/employees/EmployeeDetail.vue
Normal file
320
frontend/src/views/employees/EmployeeDetail.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<template v-else-if="employee">
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="employee.Picture">
|
||||
<img :src="employee.Picture" :alt="fullName" />
|
||||
</div>
|
||||
<div class="hero-image placeholder" v-else>
|
||||
<span class="initials">{{ initials }}</span>
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">{{ fullName }}</h1>
|
||||
<div class="hero-meta">
|
||||
<span class="badge">SSO: {{ employee.SSO }}</span>
|
||||
<span v-if="employee.Team" class="badge badge-primary">{{ employee.Team }}</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<p v-if="employee.Role"><strong>Role:</strong> {{ employee.Role }}</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<router-link to="/" class="btn">Back to Dashboard</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recognitions -->
|
||||
<div class="section-card">
|
||||
<h2 class="section-title">
|
||||
Recognitions
|
||||
<span v-if="recognitions.length > 0" class="count-badge">{{ recognitions.length }}</span>
|
||||
</h2>
|
||||
<div v-if="recognitionsLoading" class="loading">Loading...</div>
|
||||
<div v-else-if="recognitions.length === 0" class="empty">
|
||||
No recognitions yet.
|
||||
</div>
|
||||
<div v-else class="recognitions-list">
|
||||
<div v-for="rec in displayedRecognitions" :key="rec.notificationid" class="recognition-card">
|
||||
<div class="recognition-header">
|
||||
<span class="badge" :style="{ backgroundColor: rec.typecolor || '#14abef' }">
|
||||
{{ rec.typename || 'Recognition' }}
|
||||
</span>
|
||||
<span class="recognition-date">{{ formatDate(rec.startdate) }}</span>
|
||||
</div>
|
||||
<div class="recognition-message">{{ rec.notification }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="recognitions.length > recognitionsLimit && !showAllRecognitions"
|
||||
class="btn btn-secondary show-more-btn"
|
||||
@click="showAllRecognitions = true"
|
||||
>
|
||||
Show {{ recognitions.length - recognitionsLimit }} more
|
||||
</button>
|
||||
<button
|
||||
v-if="showAllRecognitions && recognitions.length > recognitionsLimit"
|
||||
class="btn btn-secondary show-more-btn"
|
||||
@click="showAllRecognitions = false"
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Currently Checked Out USB Devices -->
|
||||
<div class="section-card">
|
||||
<h2 class="section-title">Checked Out USB Devices</h2>
|
||||
<div v-if="usbLoading" class="loading">Loading...</div>
|
||||
<div v-else-if="usbDevices.length === 0" class="empty">
|
||||
No USB devices currently checked out.
|
||||
</div>
|
||||
<div v-else class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Checked Out</th>
|
||||
<th>Purpose</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="device in usbDevices" :key="device.usbdeviceid">
|
||||
<td>
|
||||
<router-link :to="`/usb/${device.usbdeviceid}`">
|
||||
{{ device.displayname }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ device.serialnumber }}</td>
|
||||
<td>{{ formatDate(device.checkoutdate) }}</td>
|
||||
<td>{{ device.checkoutpurpose || '-' }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-small btn-success" @click="checkinDevice(device)">
|
||||
Check In
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USB Checkout History -->
|
||||
<div class="section-card">
|
||||
<h2 class="section-title">USB Checkout History</h2>
|
||||
<div v-if="historyLoading" class="loading">Loading...</div>
|
||||
<div v-else-if="checkoutHistory.length === 0" class="empty">
|
||||
No checkout history.
|
||||
</div>
|
||||
<div v-else class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Checked Out</th>
|
||||
<th>Checked In</th>
|
||||
<th>Purpose</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in checkoutHistory" :key="record.usbcheckoutid">
|
||||
<td>
|
||||
<router-link :to="`/usb/${record.usbdeviceid}`">
|
||||
{{ record.devicename || `Device #${record.usbdeviceid}` }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ formatDate(record.checkoutdate) }}</td>
|
||||
<td>{{ record.checkindate ? formatDate(record.checkindate) : 'Still out' }}</td>
|
||||
<td>{{ record.purpose || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { employeesApi, usbApi, notificationsApi } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const employee = ref(null)
|
||||
const recognitions = ref([])
|
||||
const usbDevices = ref([])
|
||||
const checkoutHistory = ref([])
|
||||
const loading = ref(true)
|
||||
const recognitionsLoading = ref(true)
|
||||
const usbLoading = ref(true)
|
||||
const historyLoading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const recognitionsLimit = 5
|
||||
const showAllRecognitions = ref(false)
|
||||
|
||||
const displayedRecognitions = computed(() => {
|
||||
if (showAllRecognitions.value) {
|
||||
return recognitions.value
|
||||
}
|
||||
return recognitions.value.slice(0, recognitionsLimit)
|
||||
})
|
||||
|
||||
const fullName = computed(() => {
|
||||
if (!employee.value) return ''
|
||||
return `${employee.value.First_Name?.trim() || ''} ${employee.value.Last_Name?.trim() || ''}`.trim()
|
||||
})
|
||||
|
||||
const initials = computed(() => {
|
||||
if (!employee.value) return '?'
|
||||
const first = employee.value.First_Name?.trim()?.[0] || ''
|
||||
const last = employee.value.Last_Name?.trim()?.[0] || ''
|
||||
return (first + last).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadEmployee()
|
||||
await Promise.all([loadRecognitions(), loadUSBDevices(), loadCheckoutHistory()])
|
||||
})
|
||||
|
||||
async function loadEmployee() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await employeesApi.lookup(route.params.sso)
|
||||
employee.value = response.data.data
|
||||
} catch (err) {
|
||||
console.error('Error loading employee:', err)
|
||||
error.value = 'Employee not found'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecognitions() {
|
||||
recognitionsLoading.value = true
|
||||
try {
|
||||
const response = await notificationsApi.getEmployeeRecognitions(route.params.sso)
|
||||
recognitions.value = response.data.data?.recognitions || []
|
||||
} catch (err) {
|
||||
console.error('Error loading recognitions:', err)
|
||||
recognitions.value = []
|
||||
} finally {
|
||||
recognitionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUSBDevices() {
|
||||
usbLoading.value = true
|
||||
try {
|
||||
const response = await usbApi.getUserCheckouts(route.params.sso)
|
||||
usbDevices.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading USB devices:', err)
|
||||
} finally {
|
||||
usbLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCheckoutHistory() {
|
||||
historyLoading.value = true
|
||||
try {
|
||||
// Get user's checkout history (all past checkouts)
|
||||
const response = await usbApi.list({ user_id: route.params.sso, include_history: true })
|
||||
// Filter to only show returned items (not currently checked out)
|
||||
checkoutHistory.value = (response.data.data || []).filter(d => d.checkindate)
|
||||
} catch (err) {
|
||||
console.error('Error loading checkout history:', err)
|
||||
checkoutHistory.value = []
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkinDevice(device) {
|
||||
if (!confirm(`Check in ${device.displayname}?`)) return
|
||||
|
||||
try {
|
||||
await usbApi.checkin(device.usbdeviceid)
|
||||
await loadUSBDevices()
|
||||
await loadCheckoutHistory()
|
||||
} catch (err) {
|
||||
console.error('Error checking in device:', err)
|
||||
alert('Failed to check in device')
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero-image.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.initials {
|
||||
font-size: 3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recognitions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.recognition-card {
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--primary);
|
||||
}
|
||||
|
||||
.recognition-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.recognition-date {
|
||||
color: var(--text-light);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.recognition-message {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0 0.5rem;
|
||||
margin-left: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.show-more-btn {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user