Add email sending (service + 3 flows) and a general asset label generator
Email: a stdlib SMTP mailer (settings-first config, graceful no-op when unconfigured), a test-email endpoint wired to the Email settings page, forced first-login password change (users.mustchangepassword, migration 7d23, /change-password flow), new-user welcome mail, and on-demand report/alert delivery (POST /api/reports/email + Email Report buttons) with an external-cron-with-a-scoped-PAT path documented for automation. All tests patch smtplib - no network. Labels: a shared /print/asset-label/<type>/<id> view any asset detail page opens - card or plain style, QR or barcode, configurable encoding. Per-type qr_target_* templates plus label_default_style/codetype/encodes settings on the Printing page. Measuring-tool labels default to encoding their inspection-operation code (derived from the location name, e.g. 0615), so every tool in an area shares the area code - verified by decoding the rendered QR. Machine labels default to the machine number; blank-serial handled gracefully. 808 tests pass; both features verified live. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,9 @@ export const authApi = {
|
||||
return api.post('/auth/refresh', {}, {
|
||||
headers: { Authorization: `Bearer ${refreshToken}` }
|
||||
})
|
||||
},
|
||||
changePassword(payload) {
|
||||
return api.post('/auth/change-password', payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -714,6 +717,11 @@ export const reportsApi = {
|
||||
},
|
||||
pcRelationships(params = {}) {
|
||||
return api.get('/reports/pc-relationships', { params })
|
||||
},
|
||||
// On-demand report delivery: email the given rows as an HTML table.
|
||||
// Recipients default to the site Alert Recipients when `to` is omitted.
|
||||
email(payload) {
|
||||
return api.post('/reports/email', payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -822,6 +830,9 @@ export const settingsApi = {
|
||||
update(key, value) {
|
||||
return api.put(`/settings/${key}`, { value })
|
||||
},
|
||||
testEmail(to) {
|
||||
return api.post('/settings/test-email', { to })
|
||||
},
|
||||
create(data) {
|
||||
return api.post('/settings', data)
|
||||
},
|
||||
|
||||
53
frontend/src/components/EmailReportButton.vue
Normal file
53
frontend/src/components/EmailReportButton.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<button class="btn btn-secondary" :disabled="sending" @click="emailReport">
|
||||
{{ sending ? 'Sending...' : 'Email report' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { reportsApi } from '../api'
|
||||
import { useToast } from '../composables/toast'
|
||||
import { apiError } from '../utils/apiError'
|
||||
|
||||
// On-demand report delivery. Emails the given rows as an HTML table to the
|
||||
// site's Alert Recipients (or an explicit `to`). Automatic/scheduled sending is
|
||||
// out of scope for this app; point an external cron at POST /api/reports/email
|
||||
// with an API token to automate.
|
||||
const props = defineProps({
|
||||
subject: { type: String, required: true },
|
||||
columns: { type: Array, required: true },
|
||||
rows: { type: Array, required: true },
|
||||
intro: { type: String, default: '' },
|
||||
to: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const sending = ref(false)
|
||||
|
||||
async function emailReport() {
|
||||
sending.value = true
|
||||
try {
|
||||
const payload = {
|
||||
subject: props.subject,
|
||||
columns: props.columns,
|
||||
rows: props.rows,
|
||||
intro: props.intro,
|
||||
}
|
||||
if (props.to) payload.to = props.to
|
||||
const response = await reportsApi.email(payload)
|
||||
const result = response.data?.data || {}
|
||||
if (result.sent) {
|
||||
toast.success('Report emailed.')
|
||||
} else if (result.error) {
|
||||
toast.error(`Report email failed: ${result.error}`)
|
||||
} else {
|
||||
toast.info(response.data?.message || 'Email is not configured.')
|
||||
}
|
||||
} catch (event) {
|
||||
toast.error(apiError(event, 'Failed to email report'))
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -87,6 +87,17 @@ export function useSystemSettings() {
|
||||
qr_target_printer: '',
|
||||
qr_target_usb: '',
|
||||
usb_label_style: 'barcode',
|
||||
qr_target_machine: '',
|
||||
qr_target_computer: '',
|
||||
qr_target_network_device: '',
|
||||
qr_target_measuring_tool: '',
|
||||
label_default_style: 'card',
|
||||
label_default_codetype: 'qr',
|
||||
label_default_encodes_machine: 'assetnumber',
|
||||
label_default_encodes_computer: 'assetpage',
|
||||
label_default_encodes_printer: 'assetpage',
|
||||
label_default_encodes_network_device: 'assetpage',
|
||||
label_default_encodes_measuring_tool: 'location',
|
||||
// Email
|
||||
smtp_enabled: false,
|
||||
smtp_host: '',
|
||||
|
||||
@@ -45,6 +45,13 @@ const routes = [
|
||||
component: () => import('../views/Login.vue'),
|
||||
meta: { guest: true }
|
||||
},
|
||||
// Forced/self-service password change (standalone, authenticated)
|
||||
{
|
||||
path: '/change-password',
|
||||
name: 'change-password',
|
||||
component: () => import('../views/ChangePassword.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
// First-run setup wizard (standalone, admin-only)
|
||||
{
|
||||
path: '/setup',
|
||||
@@ -70,6 +77,14 @@ const routes = [
|
||||
name: 'print-machine-badge',
|
||||
component: () => import('../views/print/MachineBadge.vue')
|
||||
},
|
||||
// Shared asset label/code generator for any asset type (public, like the
|
||||
// other /print/* routes). assettype = machine|computer|printer|
|
||||
// network_device|measuring_tool; id = the asset's plugin id.
|
||||
{
|
||||
path: '/print/asset-label/:assettype/:id',
|
||||
name: 'print-asset-label',
|
||||
component: () => import('../views/print/AssetLabel.vue')
|
||||
},
|
||||
{
|
||||
path: '/print/printer-qr',
|
||||
name: 'print-printer-qr-batch',
|
||||
@@ -115,6 +130,13 @@ router.beforeEach(async (to, from, next) => {
|
||||
return next('/')
|
||||
}
|
||||
|
||||
// Forced password change: an admin-set temporary password must be replaced
|
||||
// before the user reaches the rest of the app. Let them log out.
|
||||
if (authStore.isAuthenticated && authStore.mustChangePassword
|
||||
&& to.path !== '/change-password' && to.path !== '/login') {
|
||||
return next('/change-password')
|
||||
}
|
||||
|
||||
// Plugin gating: a disabled backend plugin's frontend routes are dead ends.
|
||||
// The enabled list is fetched once and cached; fail-open on error so a blip
|
||||
// cannot brick navigation. Works unauthenticated (endpoint is jwt-optional).
|
||||
|
||||
@@ -13,6 +13,8 @@ export const useAuthStore = defineStore('auth', {
|
||||
roles: (state) => state.user?.roles || [],
|
||||
hasRole: (state) => (role) => state.user?.roles?.includes(role) || false,
|
||||
isAdmin: (state) => state.user?.roles?.includes('admin') || false,
|
||||
// True when an admin-set temporary password must be changed before use.
|
||||
mustChangePassword: (state) => !!state.user?.mustchangepassword,
|
||||
// Full name from the employee directory (falls back to username/SSO).
|
||||
displayName: (state) => state.user?.directoryname || state.user?.username || '',
|
||||
// Employee photo URL if the directory has one for this SSO. The directory
|
||||
@@ -57,6 +59,15 @@ export const useAuthStore = defineStore('auth', {
|
||||
localStorage.removeItem('user')
|
||||
},
|
||||
|
||||
// Clear the forced-password-change flag after a successful change so the
|
||||
// router guard stops steering the user to the change-password view.
|
||||
clearMustChangePassword() {
|
||||
if (this.user) {
|
||||
this.user.mustchangepassword = false
|
||||
localStorage.setItem('user', JSON.stringify(this.user))
|
||||
}
|
||||
},
|
||||
|
||||
async fetchUser() {
|
||||
try {
|
||||
const response = await authApi.me()
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
<div class="username">{{ authStore.displayName }}</div>
|
||||
<div v-if="authStore.displayName !== authStore.username" class="user-sso">{{ authStore.username }}</div>
|
||||
</div>
|
||||
<router-link to="/change-password" class="btn btn-secondary">Change password</router-link>
|
||||
<button class="btn btn-secondary" @click="handleLogout">Logout</button>
|
||||
</template>
|
||||
<router-link v-else to="/login" class="btn btn-primary">Login</router-link>
|
||||
|
||||
89
frontend/src/views/ChangePassword.vue
Normal file
89
frontend/src/views/ChangePassword.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<img :src="siteLogo" alt="Site logo" class="login-logo" />
|
||||
<h1>Change Password</h1>
|
||||
|
||||
<p v-if="forced" class="first-run-note">
|
||||
Your account uses a temporary password. Set a new one to continue.
|
||||
</p>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div v-if="!forced" class="form-group">
|
||||
<label for="currentpassword">Current Password</label>
|
||||
<input id="currentpassword" v-model="currentPassword" type="password"
|
||||
class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newpassword">New Password</label>
|
||||
<input id="newpassword" v-model="newPassword" type="password"
|
||||
class="form-control" required minlength="8" autofocus />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirmpassword">Confirm New Password</label>
|
||||
<input id="confirmpassword" v-model="confirmPassword" type="password"
|
||||
class="form-control" required minlength="8" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
{{ loading ? 'Saving...' : 'Change password' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { authApi } from '../api'
|
||||
import { getSiteLogo } from '../utils/siteSettings'
|
||||
import { apiError } from '../utils/apiError'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const forced = computed(() => authStore.mustChangePassword)
|
||||
|
||||
const siteLogo = ref('/ge-aerospace-logo.svg')
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
getSiteLogo().then(logo => { siteLogo.value = logo })
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
error.value = 'New passwords do not match'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const payload = { new_password: newPassword.value }
|
||||
if (!forced.value) payload.current_password = currentPassword.value
|
||||
await authApi.changePassword(payload)
|
||||
authStore.clearMustChangePassword()
|
||||
success.value = 'Password changed.'
|
||||
// Land in the app now that the forced-change flag is cleared.
|
||||
setTimeout(() => router.push('/'), 600)
|
||||
} catch (event) {
|
||||
error.value = apiError(event, 'Failed to change password')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.first-run-note { color: var(--text-light); font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
</style>
|
||||
@@ -6,6 +6,9 @@
|
||||
<router-link :to="`/print/machine-badge/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
|
||||
Print Badge
|
||||
</router-link>
|
||||
<router-link :to="`/print/asset-label/machine/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
|
||||
Print Label
|
||||
</router-link>
|
||||
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
|
||||
Edit
|
||||
</router-link>
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<div class="page-header">
|
||||
<h2>Measuring Tool Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/print/asset-label/measuring_tool/${tool?.measuringtool?.measuringtoolid}`" class="btn btn-secondary" v-if="tool" target="_blank">
|
||||
Print Label
|
||||
</router-link>
|
||||
<router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool">
|
||||
Edit
|
||||
</router-link>
|
||||
|
||||
@@ -174,6 +174,9 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="action-bar" v-if="authStore.isAuthenticated">
|
||||
<router-link :to="`/print/asset-label/network_device/${deviceId}`" class="btn btn-secondary" target="_blank">
|
||||
Print Label
|
||||
</router-link>
|
||||
<router-link :to="`/network/${deviceId}/edit`" class="btn btn-primary">
|
||||
Edit Device
|
||||
</router-link>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<div class="page-header">
|
||||
<h2>Computer Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
|
||||
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/pcs" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
|
||||
276
frontend/src/views/print/AssetLabel.vue
Normal file
276
frontend/src/views/print/AssetLabel.vue
Normal file
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Controls (never printed) -->
|
||||
<div class="no-print">
|
||||
<div class="controls">
|
||||
<h3>Print Asset Label</h3>
|
||||
<div v-if="loading" class="loading-msg">Loading...</div>
|
||||
<div v-else-if="!asset" class="error-msg">Asset not found</div>
|
||||
<template v-else>
|
||||
<div class="control-row">
|
||||
<label>Style
|
||||
<select v-model="style">
|
||||
<option value="card">Card (badge)</option>
|
||||
<option value="plain">Plain (code only)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Code type
|
||||
<select v-model="codetype">
|
||||
<option value="qr">QR code</option>
|
||||
<option value="barcode">Barcode (CODE128)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Encodes
|
||||
<select v-model="encodes">
|
||||
<option value="assetpage">Asset page (link)</option>
|
||||
<option value="assetnumber">Asset number</option>
|
||||
<option value="serialnumber">Serial number</option>
|
||||
<option v-if="hasLocation" value="location">Inspection location code</option>
|
||||
<option value="custom">Custom target (settings template)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="encodes === 'location' && !asset.locationcode" class="control-note">
|
||||
This tool has no location; the label falls back to the asset page.
|
||||
</p>
|
||||
<button class="print-btn" @click="print">Print</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printable area -->
|
||||
<div v-if="asset" class="label-sheet">
|
||||
<div class="asset-label" :class="style">
|
||||
<template v-if="style === 'card'">
|
||||
<div class="label-title">{{ cardTitle }}</div>
|
||||
<img v-if="imageUrl" class="label-image" :src="imageUrl" :alt="cardTitle" />
|
||||
<div class="label-fields">
|
||||
<div v-for="field in identityFields" :key="field.label" class="label-field">
|
||||
<span class="field-label">{{ field.label }}</span>
|
||||
<span class="field-value">{{ field.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="code-area">
|
||||
<template v-if="codeText">
|
||||
<img v-if="codetype === 'qr' && qrImage" class="code-qr" :src="qrImage" alt="QR" />
|
||||
<svg v-show="codetype === 'barcode'" ref="barcodeEl" class="code-barcode"></svg>
|
||||
<div class="code-caption">{{ caption }}</div>
|
||||
</template>
|
||||
<div v-else class="code-missing">No {{ encodeLabel }} recorded for this asset.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
import { renderQrDataUrl } from './qrLogo'
|
||||
import { getSetting } from '@/utils/siteSettings'
|
||||
import {
|
||||
TYPE_CONFIG, hasLocationType, resolveDefaultEncodes,
|
||||
effectiveEncodes as effEncodes, resolveCodeText as resolveText, captionFor,
|
||||
} from './assetLabel'
|
||||
|
||||
const route = useRoute()
|
||||
const assettype = route.params.assettype
|
||||
const config = TYPE_CONFIG[assettype] || null
|
||||
|
||||
const loading = ref(true)
|
||||
const asset = ref(null)
|
||||
const barcodeEl = ref(null)
|
||||
const qrImage = ref('')
|
||||
// The resolved string the code encodes; empty when the chosen field has no
|
||||
// value (e.g. serial number on an asset with none) so the UI can explain it.
|
||||
const codeText = ref('')
|
||||
|
||||
const style = ref('card')
|
||||
const codetype = ref('qr')
|
||||
const encodes = ref('assetpage')
|
||||
|
||||
const hasLocation = computed(() => hasLocationType(assettype))
|
||||
|
||||
const extension = computed(() => (asset.value && config) ? asset.value[config.extkey] : null)
|
||||
|
||||
const cardTitle = computed(() => {
|
||||
if (!asset.value) return ''
|
||||
const ext = extension.value || {}
|
||||
return ext.modelname
|
||||
|| ext.measuringtooltypename
|
||||
|| asset.value.assettypename
|
||||
|| (config ? config.label : 'Asset')
|
||||
})
|
||||
|
||||
const imageUrl = computed(() => {
|
||||
const ext = extension.value
|
||||
return (ext && ext.imageurl) ? ext.imageurl : null
|
||||
})
|
||||
|
||||
// Small identity table shown on the card.
|
||||
const identityFields = computed(() => {
|
||||
if (!asset.value) return []
|
||||
const rows = []
|
||||
if (asset.value.assetnumber) rows.push({ label: 'Asset #', value: asset.value.assetnumber })
|
||||
if (asset.value.serialnumber) rows.push({ label: 'Serial', value: asset.value.serialnumber })
|
||||
if (asset.value.name) rows.push({ label: 'Name', value: asset.value.name })
|
||||
if (asset.value.locationname) rows.push({ label: 'Location', value: asset.value.locationname })
|
||||
return rows
|
||||
})
|
||||
|
||||
// Human caption printed under the code.
|
||||
const caption = computed(() => captionFor(asset.value, encodes.value))
|
||||
|
||||
// Human phrase for the current encode mode, used in the "nothing to encode"
|
||||
// message.
|
||||
const ENCODE_LABELS = {
|
||||
assetnumber: 'asset number', serialnumber: 'serial number',
|
||||
location: 'inspection location', assetpage: 'page link', custom: 'target',
|
||||
}
|
||||
const encodeLabel = computed(() =>
|
||||
ENCODE_LABELS[effEncodes(encodes.value, asset.value)] || 'value')
|
||||
|
||||
async function renderCode() {
|
||||
const text = await resolveText(assettype, asset.value, encodes.value)
|
||||
codeText.value = text
|
||||
if (!text) { qrImage.value = ''; return }
|
||||
if (codetype.value === 'qr') {
|
||||
qrImage.value = await renderQrDataUrl(text)
|
||||
} else {
|
||||
await nextTick()
|
||||
if (!barcodeEl.value) return
|
||||
try {
|
||||
JsBarcode(barcodeEl.value, text, {
|
||||
format: 'CODE128', displayValue: false, width: 2, height: 70, margin: 0,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Barcode error:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!config) { loading.value = false; return }
|
||||
style.value = (await getSetting('label_default_style', 'card')) === 'plain' ? 'plain' : 'card'
|
||||
codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr'
|
||||
encodes.value = await resolveDefaultEncodes(assettype)
|
||||
try {
|
||||
const response = await config.api.get(route.params.id)
|
||||
asset.value = response.data.data
|
||||
} catch (err) {
|
||||
console.error('Error loading asset:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
renderCode()
|
||||
}
|
||||
})
|
||||
|
||||
watch([style, codetype, encodes], renderCode)
|
||||
|
||||
function print() {
|
||||
window.print()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@page { size: 2.13in 3.38in; margin: 0; }
|
||||
|
||||
.no-print { padding: 20px; }
|
||||
.controls {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-width: 40rem;
|
||||
}
|
||||
.controls h3 { margin-top: 0; }
|
||||
.control-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 12px; }
|
||||
.control-row label { display: flex; flex-direction: column; font-size: 0.875rem; gap: 4px; }
|
||||
.control-row select { padding: 6px; font-size: 0.875rem; }
|
||||
.control-note { color: var(--warning); font-size: 0.8125rem; margin: 0 0 12px; }
|
||||
|
||||
.print-btn {
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.print-btn:hover { background: var(--primary-dark); }
|
||||
|
||||
.loading-msg, .error-msg { padding: 1rem 0; color: var(--text-light); }
|
||||
|
||||
.label-sheet { display: flex; justify-content: center; padding: 20px 0; }
|
||||
|
||||
.asset-label {
|
||||
width: 2.13in;
|
||||
min-height: 3.38in;
|
||||
background: white;
|
||||
color: #000;
|
||||
border: 1px solid #ccc;
|
||||
box-sizing: border-box;
|
||||
padding: 0.15in;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.asset-label.plain { justify-content: center; min-height: 2in; }
|
||||
|
||||
.label-title {
|
||||
font-size: 12pt;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 0.08in;
|
||||
}
|
||||
.label-image {
|
||||
max-width: 1.6in;
|
||||
max-height: 1.2in;
|
||||
object-fit: contain;
|
||||
margin-bottom: 0.08in;
|
||||
}
|
||||
.label-fields { width: 100%; margin-bottom: 0.08in; }
|
||||
.label-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
font-size: 8pt;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.field-label { color: #555; }
|
||||
.field-value { font-weight: bold; text-align: right; word-break: break-all; }
|
||||
|
||||
.code-area {
|
||||
margin-top: auto;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
.code-qr { width: 1.5in; height: 1.5in; }
|
||||
.code-barcode { width: 1.8in; height: 0.9in; }
|
||||
.code-caption {
|
||||
font-size: 12pt;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
margin-top: 0.02in;
|
||||
}
|
||||
.code-missing {
|
||||
font-size: 9pt;
|
||||
color: #999;
|
||||
padding: 0.3in 0.1in;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.label-sheet { padding: 0; }
|
||||
.asset-label { border: none; }
|
||||
body, .asset-label, .code-qr, .code-barcode {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
99
frontend/src/views/print/assetLabel.js
Normal file
99
frontend/src/views/print/assetLabel.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// Shared wiring for the asset label pages (single AssetLabel.vue and batch
|
||||
// AssetLabelBatch.vue): per-type api/route config plus the "what does the code
|
||||
// encode" resolution so both surfaces behave identically.
|
||||
import {
|
||||
machinesApi, computersApi, printersApi, networkApi, measuringtoolsApi,
|
||||
} from '../../api'
|
||||
import { fillUrlTemplate } from '@/utils/qrTarget'
|
||||
import { getSetting, getSiteBaseUrl } from '@/utils/siteSettings'
|
||||
|
||||
// Per asset-type: which api loads it, its detail/list routes, the extension key
|
||||
// nested in the merged payload, and its qr_target_<type> settings key.
|
||||
export const TYPE_CONFIG = {
|
||||
machine: {
|
||||
api: machinesApi, extkey: 'machine', targetKey: 'qr_target_machine',
|
||||
label: 'Machine', detailPath: id => `/machines/${id}`, listPath: '/machines',
|
||||
},
|
||||
computer: {
|
||||
api: computersApi, extkey: 'computer', targetKey: 'qr_target_computer',
|
||||
label: 'Computer', detailPath: id => `/pcs/${id}`, listPath: '/pcs',
|
||||
},
|
||||
printer: {
|
||||
api: printersApi, extkey: 'printer', targetKey: 'qr_target_printer',
|
||||
label: 'Printer', detailPath: id => `/printers/${id}`, listPath: '/printers',
|
||||
},
|
||||
network_device: {
|
||||
api: networkApi, extkey: 'network_device', targetKey: 'qr_target_network_device',
|
||||
label: 'Network Device', detailPath: id => `/network/${id}`, listPath: '/network',
|
||||
},
|
||||
measuring_tool: {
|
||||
api: measuringtoolsApi, extkey: 'measuringtool', targetKey: 'qr_target_measuring_tool',
|
||||
label: 'Measuring Tool', detailPath: id => `/measuringtools/${id}`, listPath: '/measuringtools',
|
||||
},
|
||||
}
|
||||
|
||||
// Hardcoded fallback default per type when the site setting is unset. Machines
|
||||
// encode their machine number, measuring tools their inspection location.
|
||||
const DEFAULT_ENCODES = { machine: 'assetnumber', measuring_tool: 'location' }
|
||||
|
||||
export function hasLocationType(assettype) {
|
||||
return assettype === 'measuring_tool'
|
||||
}
|
||||
|
||||
// The default encode mode for a type: the label_default_encodes_<type> site
|
||||
// setting when valid, else the hardcoded fallback.
|
||||
export async function resolveDefaultEncodes(assettype) {
|
||||
const valid = ['assetpage', 'assetnumber', 'serialnumber', 'custom']
|
||||
if (hasLocationType(assettype)) valid.push('location')
|
||||
const seeded = await getSetting(`label_default_encodes_${assettype}`, '')
|
||||
return valid.includes(seeded) ? seeded : (DEFAULT_ENCODES[assettype] || 'assetpage')
|
||||
}
|
||||
|
||||
// 'location' degrades to the asset page when the asset has no location code.
|
||||
export function effectiveEncodes(encodes, asset) {
|
||||
if (encodes === 'location' && !asset?.locationcode) return 'assetpage'
|
||||
return encodes
|
||||
}
|
||||
|
||||
function tokensFor(asset) {
|
||||
return {
|
||||
assetid: asset.assetid || '',
|
||||
assetnumber: asset.assetnumber || '',
|
||||
serialnumber: asset.serialnumber || '',
|
||||
name: asset.name || '',
|
||||
pluginid: asset.pluginid || '',
|
||||
locationcode: asset.locationcode || '',
|
||||
locationname: asset.locationname || '',
|
||||
}
|
||||
}
|
||||
|
||||
// The string a label's code encodes for one asset. Empty when the chosen field
|
||||
// has no value (e.g. serial number on an asset with none).
|
||||
export async function resolveCodeText(assettype, asset, encodes) {
|
||||
const config = TYPE_CONFIG[assettype]
|
||||
if (!config || !asset) return ''
|
||||
const detailId = asset.pluginid
|
||||
const assetPageUrl = `${await getSiteBaseUrl()}${config.detailPath(detailId)}`
|
||||
switch (effectiveEncodes(encodes, asset)) {
|
||||
case 'assetnumber': return asset.assetnumber || ''
|
||||
case 'serialnumber': return asset.serialnumber || ''
|
||||
case 'location': return asset.locationcode || ''
|
||||
case 'custom': {
|
||||
const template = (await getSetting(config.targetKey, '')).trim()
|
||||
return template ? fillUrlTemplate(template, tokensFor(asset)) : assetPageUrl
|
||||
}
|
||||
case 'assetpage':
|
||||
default: return assetPageUrl
|
||||
}
|
||||
}
|
||||
|
||||
// Human caption printed under the code.
|
||||
export function captionFor(asset, encodes) {
|
||||
if (!asset) return ''
|
||||
switch (effectiveEncodes(encodes, asset)) {
|
||||
case 'location': return asset.locationcode || ''
|
||||
case 'serialnumber': return asset.serialnumber || ''
|
||||
case 'assetnumber':
|
||||
default: return asset.assetnumber || ''
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@
|
||||
<router-link :to="`/print/printer-qr/${$route.params.id}`" class="btn btn-secondary" target="_blank">
|
||||
Print QR
|
||||
</router-link>
|
||||
<router-link :to="`/print/asset-label/printer/${$route.params.id}`" class="btn btn-secondary" target="_blank">
|
||||
Print Label
|
||||
</router-link>
|
||||
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<h1>Toner Report</h1>
|
||||
<div class="header-actions">
|
||||
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
|
||||
:columns="emailColumns" :rows="emailRows" />
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,6 +101,17 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
import EmailReportButton from '../../components/EmailReportButton.vue'
|
||||
|
||||
const emailColumns = [
|
||||
{ key: 'printer', label: 'Printer' },
|
||||
{ key: 'assetnumber', label: 'Asset #' },
|
||||
{ key: 'location', label: 'Location' },
|
||||
{ key: 'ipaddress', label: 'IP Address' },
|
||||
{ key: 'supply', label: 'Supply' },
|
||||
{ key: 'level', label: 'Level' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
]
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
@@ -119,6 +132,25 @@ const filteredPrinters = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// One row per supply, honoring the active filter, for the emailed table.
|
||||
const emailRows = computed(() => {
|
||||
const rows = []
|
||||
for (const printer of filteredPrinters.value) {
|
||||
for (const supply of printer.supplies || []) {
|
||||
rows.push({
|
||||
printer: printer.printername || '',
|
||||
assetnumber: printer.assetnumber || '',
|
||||
location: printer.location || '',
|
||||
ipaddress: printer.ipaddress || '',
|
||||
supply: supply.name || '',
|
||||
level: supply.level + '%',
|
||||
status: supply.status || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
function exportCSV() {
|
||||
// one row per supply, honoring the active filter
|
||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<h1>Warranty Report</h1>
|
||||
<div class="header-actions">
|
||||
<button v-if="!loading" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<EmailReportButton v-if="!loading" subject="Warranty Report"
|
||||
:columns="emailColumns" :rows="emailRows" />
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,13 +55,39 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { warrantyApi } from '../../api'
|
||||
import EmailReportButton from '../../components/EmailReportButton.vue'
|
||||
|
||||
const loading = ref(true)
|
||||
const counts = ref({})
|
||||
const buckets = ref({})
|
||||
|
||||
const emailColumns = [
|
||||
{ key: 'bucket', label: 'Status' },
|
||||
{ key: 'vendor', label: 'Vendor' },
|
||||
{ key: 'servicelevel', label: 'Service Level' },
|
||||
{ key: 'enddate', label: 'Ends' },
|
||||
{ key: 'assets', label: 'Covers' },
|
||||
]
|
||||
|
||||
// Flatten the buckets into one row per warranty for the emailed table.
|
||||
const emailRows = computed(() => {
|
||||
const rows = []
|
||||
for (const b of bucketOrder) {
|
||||
for (const w of buckets.value[b.key] || []) {
|
||||
rows.push({
|
||||
bucket: b.label,
|
||||
vendor: w.vendor || '',
|
||||
servicelevel: w.servicelevel || '',
|
||||
enddate: w.enddate || '',
|
||||
assets: (w.assets || []).map(a => a.assetnumber).join(', '),
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
const bucketOrder = [
|
||||
{ key: 'expired', label: 'Expired', color: '#F44336' },
|
||||
{ key: 'expiring', label: 'Expiring Soon', color: '#FF9800' },
|
||||
|
||||
@@ -162,6 +162,7 @@
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { settingsApi } from '../../api'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
@@ -189,18 +190,25 @@ const canTestEmail = computed(() => {
|
||||
})
|
||||
|
||||
async function testEmail() {
|
||||
// TODO: Implement test email endpoint
|
||||
testingEmail.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
// await settingsApi.testEmail()
|
||||
success.value = 'Test email feature coming soon'
|
||||
// Send to the configured Alert Recipients (the endpoint splits the list).
|
||||
const response = await settingsApi.testEmail(settings.alert_recipients)
|
||||
const result = response.data?.data || {}
|
||||
if (result.sent) {
|
||||
success.value = `Test email sent to ${settings.alert_recipients}`
|
||||
} else {
|
||||
error.value = result.error
|
||||
? `Test email failed: ${result.error}`
|
||||
: (response.data?.message || 'Email is not configured')
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'Failed to send test email')
|
||||
} finally {
|
||||
testingEmail.value = false
|
||||
setTimeout(() => { success.value = '' }, 3000)
|
||||
setTimeout(() => { success.value = '' }, 4000)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,114 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Machine label target</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.qr_target_machine"
|
||||
placeholder="(blank = machine page)"
|
||||
@blur="saveSetting('qr_target_machine', settings.qr_target_machine)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Computer label target</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.qr_target_computer"
|
||||
placeholder="(blank = computer page)"
|
||||
@blur="saveSetting('qr_target_computer', settings.qr_target_computer)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Network device label target</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.qr_target_network_device"
|
||||
placeholder="(blank = network device page)"
|
||||
@blur="saveSetting('qr_target_network_device', settings.qr_target_network_device)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Measuring tool label target</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="settings.qr_target_measuring_tool"
|
||||
placeholder="(blank = measuring tool page)"
|
||||
@blur="saveSetting('qr_target_measuring_tool', settings.qr_target_measuring_tool)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<small class="input-hint">Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}, {locationcode}, {locationname}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Default label style</span>
|
||||
<select
|
||||
v-model="settings.label_default_style"
|
||||
@change="saveSetting('label_default_style', settings.label_default_style)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option value="card">Card (badge with image and identity)</option>
|
||||
<option value="plain">Plain (just the code and a caption)</option>
|
||||
</select>
|
||||
<small class="input-hint">House style used when an asset label first opens.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>Default label code type</span>
|
||||
<select
|
||||
v-model="settings.label_default_codetype"
|
||||
@change="saveSetting('label_default_codetype', settings.label_default_codetype)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option value="qr">QR code</option>
|
||||
<option value="barcode">Barcode (CODE128)</option>
|
||||
</select>
|
||||
<small class="input-hint">Code type used when an asset label first opens.</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="setting-description">
|
||||
What each asset type's label encodes by default (still overridable on
|
||||
the label page itself).
|
||||
</p>
|
||||
|
||||
<div class="setting-row" v-for="row in encodesRows" :key="row.key">
|
||||
<label>
|
||||
<span>{{ row.label }} label content</span>
|
||||
<select
|
||||
v-model="settings[row.key]"
|
||||
@change="saveSetting(row.key, settings[row.key])"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option value="assetpage">Link to the asset page</option>
|
||||
<option value="assetnumber">Asset / machine number</option>
|
||||
<option value="serialnumber">Serial number</option>
|
||||
<option v-if="row.hasLocation" value="location">Inspection location code</option>
|
||||
<option value="custom">Custom target template</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
<span>USB label style</span>
|
||||
@@ -71,5 +179,14 @@ const {
|
||||
loadSettings, saveSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
// Per-asset-type "what does the code encode" defaults.
|
||||
const encodesRows = [
|
||||
{ key: 'label_default_encodes_machine', label: 'Machine' },
|
||||
{ key: 'label_default_encodes_computer', label: 'Computer' },
|
||||
{ key: 'label_default_encodes_printer', label: 'Printer' },
|
||||
{ key: 'label_default_encodes_network_device', label: 'Network device' },
|
||||
{ key: 'label_default_encodes_measuring_tool', label: 'Measuring tool', hasLocation: true },
|
||||
]
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user