Updated A LOT

This commit is contained in:
2026-06-05 04:15:24 -05:00
parent 8335f1af1b
commit 4072695432
92 changed files with 16139 additions and 570 deletions

View File

@@ -1,5 +1,7 @@
<template>
<section class="content-grid">
<!-- USERS & TEAMS -->
<template v-if="adminSection === 'admin-users'">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<div>
@@ -9,70 +11,154 @@
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-account-plus" @click="userDialog = true">Add user</v-btn>
</div>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Team</th>
<th>Role</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="account in users" :key="account.id">
<td class="font-weight-bold">{{ account.name }}</td>
<td>{{ account.email }}</td>
<td>{{ account.team_name || 'No team' }}</td>
<td><v-chip size="small" variant="tonal">{{ account.role }}</v-chip></td>
<td><span :class="['status-chip', account.status === 'active' ? 'status-in_service' : 'status-disposed']">{{ account.status }}</span></td>
<td class="text-right">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="editUser(account)" />
</td>
</tr>
</tbody>
</table>
</div>
<DataTable
:columns="userColumns"
:rows="users"
:page-size="10"
row-key="id"
has-actions
searchable
search-placeholder="Filter users"
empty-text="No users."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-team_name="{ row }">{{ row.team_name || 'No team' }}</template>
<template #cell-role="{ row }">
<v-chip size="small" variant="tonal">{{ roleName(row.role) }}</v-chip>
</template>
<template #cell-status="{ row }">
<span :class="['status-chip', row.status === 'active' ? 'status-in_service' : 'status-disposed']">{{ row.status }}</span>
</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="editUser(row)" />
</template>
</DataTable>
</v-card>
<v-card class="span-5 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Teams</h2>
<v-spacer />
<v-btn variant="tonal" size="small" prepend-icon="mdi-plus" @click="teamDialog = true">Add team</v-btn>
<v-btn variant="tonal" size="small" prepend-icon="mdi-plus" @click="openNewTeam">Add team</v-btn>
</div>
<v-list lines="two">
<v-list-item v-for="team in teams" :key="team.id" prepend-icon="mdi-account-group">
<v-list-item-title>{{ team.name }}</v-list-item-title>
<v-list-item-subtitle>{{ team.description || 'No description' }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<v-alert v-if="teamNotice" type="error" density="compact" class="mb-3">{{ teamNotice }}</v-alert>
<DataTable
:columns="teamColumns"
:rows="teams"
row-key="id"
:page-size="10"
has-actions
searchable
search-placeholder="Filter teams"
empty-text="No teams."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-description="{ row }">{{ row.description || 'No description' }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="editTeam(row)" />
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="removeTeam(row)" />
</template>
</DataTable>
</v-card>
<v-card class="span-7 panel-pad">
<h2 class="section-title mb-4">Role permissions</h2>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th>Capability</th>
<th v-for="role in roles" :key="role">{{ role }}</th>
</tr>
</thead>
<tbody>
<tr v-for="capability in roleCapabilities" :key="capability.key">
<td>{{ capability.label }}</td>
<td v-for="role in roles" :key="role">
<v-icon :color="capability.roles.includes(role) ? 'success' : 'disabled'" :icon="capability.roles.includes(role) ? 'mdi-check-circle' : 'mdi-minus-circle'" size="18" />
</td>
</tr>
</tbody>
</table>
<div class="toolbar-row mb-4">
<div>
<h2 class="section-title">Roles &amp; permissions</h2>
<div class="quiet text-body-2">Built-in and custom roles, each granting a set of capabilities.</div>
</div>
<v-spacer />
<v-btn variant="tonal" size="small" prepend-icon="mdi-shield-plus-outline" @click="openNewRole">New role</v-btn>
</div>
<DataTable
:columns="roleColumns"
:rows="roles"
row-key="key"
:page-size="10"
has-actions
searchable
search-placeholder="Filter roles"
empty-text="No roles."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
<v-chip v-if="row.is_system" size="x-small" variant="tonal" class="ml-2">Built-in</v-chip>
</template>
<template #cell-capabilities="{ row }">{{ capabilitySummary(row) }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEditRole(row)" />
<v-btn v-if="!row.is_system" icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDeleteRole(row)" />
</template>
</DataTable>
</v-card>
<!-- Role editor -->
<v-dialog v-model="roleDialog" max-width="640" scrollable>
<v-card>
<v-card-title>{{ roleForm.exists ? `Edit role · ${roleForm.name}` : 'New role' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<v-text-field v-model="roleForm.name" label="Role name" :readonly="roleForm.locked" />
<v-text-field v-if="!roleForm.exists" v-model="roleForm.key" label="Key (optional)" placeholder="auto from name" />
</div>
<v-textarea v-model="roleForm.description" label="Description" rows="2" class="mt-2" />
<v-alert v-if="roleForm.locked" type="info" density="compact" variant="tonal" class="mt-2">
The Administrator role always has every capability and cant be restricted.
</v-alert>
<template v-else>
<v-divider class="my-3" />
<div class="toolbar-row mb-2">
<h3 class="section-title">Capabilities</h3>
<v-spacer />
<span class="quiet text-caption">{{ roleForm.capabilities.length }} selected</span>
</div>
<div v-for="group in capabilityGroups" :key="group.name" class="cap-group">
<div class="cap-group-title">{{ group.name }}</div>
<v-checkbox
v-for="cap in group.items"
:key="cap.key"
v-model="roleForm.capabilities"
:value="cap.key"
:label="cap.label"
density="compact"
hide-details
/>
</div>
</template>
<v-alert v-if="roleError" type="error" density="compact" class="mt-3">{{ roleError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="roleDialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :disabled="!roleForm.name.trim()" @click="saveRole">Save role</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="roleDeleteDialog" max-width="460">
<v-card>
<v-card-title class="text-error">Delete role</v-card-title>
<v-card-text>
<p>Delete the role <strong>{{ roleDeleteTarget?.name }}</strong>?</p>
<v-alert v-if="roleDeleteTarget && roleDeleteTarget.user_count" type="warning" variant="tonal" density="comfortable" class="mt-2">
{{ roleDeleteTarget.user_count }} user(s) currently have this role and must be reassigned first.
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="roleDeleteDialog = false">Cancel</v-btn>
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDeleteRole">Delete role</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<!-- APPLICATION CONFIGURATION -->
<template v-if="adminSection === 'admin-config'">
<v-card class="span-6 panel-pad">
<h2 class="section-title mb-4">Application settings</h2>
<v-text-field v-model="localSettings.server_fqdn" label="Server FQDN" />
@@ -81,6 +167,20 @@
<v-text-field v-model="localSettings.database_path" label="Database path" readonly />
<v-btn color="primary" prepend-icon="mdi-content-save" @click="$emit('save-settings', localSettings)">Save settings</v-btn>
</v-card>
<AssetClassSettings :token="token" />
<DepreciationZoneSettings :token="token" />
<AssetIdTemplateSettings :token="token" />
<CategorySettings :token="token" @updated="$emit('categories-updated')" />
<DepartmentSettings :token="token" @updated="$emit('categories-updated')" />
<PmCategorySettings :token="token" />
<PartCategorySettings :token="token" />
<v-card class="span-6 panel-pad">
<h2 class="section-title mb-4">Tax rule sets</h2>
<v-list lines="two">
@@ -90,6 +190,18 @@
</v-list-item>
</v-list>
</v-card>
<PmSettings :token="token" />
</template>
<!-- INTEGRATIONS -->
<template v-if="adminSection === 'admin-integrations'">
<NotificationSettings :token="token" />
<WebhookSettings :token="token" />
<ServiceNowSettings :token="token" />
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<div>
@@ -108,6 +220,8 @@
<v-text-field v-model="localWorkday.client_secret" type="password" label="Client secret" />
<v-text-field v-model="localWorkday.workers_path" label="Workers path" />
<v-switch v-model="localWorkday.enabled" label="Enabled" color="primary" density="compact" hide-details />
<v-switch v-model="localWorkday.sync_enabled" label="Automatic scheduled sync" color="primary" density="compact" hide-details />
<v-text-field v-model.number="localWorkday.sync_interval_hours" type="number" label="Sync interval (hours)" />
</div>
<div class="toolbar-row mt-3">
<div class="quiet text-body-2">Last sync: {{ workdayConnection.last_sync_at ? shortDate(workdayConnection.last_sync_at) : 'Never' }}</div>
@@ -116,6 +230,7 @@
</div>
<v-alert v-if="workdayConnection.last_sync_status" class="mt-3" density="compact" type="info">{{ workdayConnection.last_sync_status }}</v-alert>
</v-card>
</template>
<v-dialog v-model="userDialog" max-width="560">
<v-card>
@@ -125,7 +240,7 @@
<v-text-field v-model="userForm.name" label="Name" />
<v-text-field v-model="userForm.email" label="Email" />
<v-select v-model="userForm.team_id" :items="teamOptions" item-title="title" item-value="value" clearable label="Team" />
<v-select v-model="userForm.role" :items="roles" label="Role" />
<v-select v-model="userForm.role" :items="roleSelectItems" item-title="title" item-value="value" label="Role" />
<v-select v-model="userForm.status" :items="['active', 'inactive']" label="Status" />
<v-text-field v-model="userForm.password" type="password" :label="userForm.id ? 'New password' : 'Password'" />
</div>
@@ -143,7 +258,7 @@
<v-dialog v-model="teamDialog" max-width="460">
<v-card>
<v-card-title>Add team</v-card-title>
<v-card-title>{{ teamForm.id ? 'Edit team' : 'Add team' }}</v-card-title>
<v-card-text>
<v-text-field v-model="teamForm.name" label="Team name" />
<v-textarea v-model="teamForm.description" label="Description" rows="2" />
@@ -159,36 +274,88 @@
</template>
<script>
import AssetClassSettings from '../components/AssetClassSettings.vue';
import AssetIdTemplateSettings from '../components/AssetIdTemplateSettings.vue';
import CategorySettings from '../components/CategorySettings.vue';
import DataTable from '../components/DataTable.vue';
import DepartmentSettings from '../components/DepartmentSettings.vue';
import DepreciationZoneSettings from '../components/DepreciationZoneSettings.vue';
import NotificationSettings from '../components/NotificationSettings.vue';
import PartCategorySettings from '../components/PartCategorySettings.vue';
import PmCategorySettings from '../components/PmCategorySettings.vue';
import PmSettings from '../components/PmSettings.vue';
import ServiceNowSettings from '../components/ServiceNowSettings.vue';
import WebhookSettings from '../components/WebhookSettings.vue';
export default {
components: { AssetClassSettings, AssetIdTemplateSettings, CategorySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PmCategorySettings, PmSettings, ServiceNowSettings, WebhookSettings },
props: {
roleCapabilities: { type: Array, default: () => [] },
rolePermissions: { type: Object, default: () => ({}) },
token: { type: String, default: '' },
section: { type: String, default: 'admin-users' },
capabilityCatalog: { type: Array, default: () => [] },
roles: { type: Array, default: () => [] },
settings: { type: Object, required: true },
shortDate: { type: Function, default: (value) => value || '' },
taxRules: { type: Array, required: true },
teamSaving: { type: Boolean, default: false },
teams: { type: Array, default: () => [] },
teamNotice: { type: String, default: '' },
userSaving: { type: Boolean, default: false },
users: { type: Array, default: () => [] },
workdayConnection: { type: Object, default: () => ({}) },
workdaySaving: { type: Boolean, default: false },
workdaySyncing: { type: Boolean, default: false }
},
emits: ['create-team', 'create-user', 'save-settings', 'save-workday', 'sync-workday', 'update-user'],
emits: ['create-team', 'update-team', 'delete-team', 'create-user', 'update-user', 'create-role', 'update-role', 'delete-role', 'save-settings', 'save-workday', 'sync-workday', 'categories-updated'],
data() {
return {
localSettings: { ...this.settings },
localWorkday: { ...this.workdayConnection, client_secret: '' },
teamDialog: false,
teamForm: { name: '', description: '' },
teamForm: { id: null, name: '', description: '' },
userDialog: false,
userForm: this.blankUser()
userForm: this.blankUser(),
roleDialog: false,
roleForm: this.blankRole(),
roleError: '',
roleDeleteDialog: false,
roleDeleteTarget: null,
userColumns: [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'team_name', label: 'Team' },
{ key: 'role', label: 'Role' },
{ key: 'status', label: 'Status' }
],
teamColumns: [
{ key: 'name', label: 'Team' },
{ key: 'description', label: 'Description' }
],
roleColumns: [
{ key: 'name', label: 'Role' },
{ key: 'capabilities', label: 'Capabilities', sortable: false },
{ key: 'user_count', label: 'Users', format: 'number' }
]
};
},
computed: {
teamOptions() {
return this.teams.map((team) => ({ title: team.name, value: team.id }));
},
roleSelectItems() {
return this.roles.map((role) => ({ title: role.name, value: role.key }));
},
capabilityGroups() {
const groups = [];
for (const cap of this.capabilityCatalog) {
let group = groups.find((g) => g.name === cap.group);
if (!group) { group = { name: cap.group, items: [] }; groups.push(group); }
group.items.push(cap);
}
return groups;
},
adminSection() {
return ['admin-users', 'admin-config', 'admin-integrations'].includes(this.section) ? this.section : 'admin-users';
}
},
watch: {
@@ -229,13 +396,73 @@ export default {
};
this.userDialog = true;
},
roleSummary(role) {
return (this.rolePermissions[role] || []).join(' · ');
roleName(roleKey) {
return (this.roles.find((r) => r.key === roleKey) || {}).name || roleKey;
},
roleSummary(roleKey) {
const role = this.roles.find((r) => r.key === roleKey);
if (!role) return '';
return role.description || this.capabilitySummary(role);
},
capabilitySummary(role) {
if ((role.capabilities || []).includes('*')) return 'All capabilities';
return `${(role.capabilities || []).length} capabilit${(role.capabilities || []).length === 1 ? 'y' : 'ies'}`;
},
blankRole() {
return { exists: false, locked: false, key: '', name: '', description: '', capabilities: [] };
},
openNewRole() {
this.roleForm = this.blankRole();
this.roleError = '';
this.roleDialog = true;
},
openEditRole(role) {
this.roleForm = {
exists: true,
locked: Boolean(role.locked),
key: role.key,
name: role.name,
description: role.description || '',
capabilities: [...(role.capabilities || [])].filter((c) => c !== '*')
};
this.roleError = '';
this.roleDialog = true;
},
saveRole() {
if (!this.roleForm.name.trim()) return;
const payload = {
key: this.roleForm.key,
name: this.roleForm.name.trim(),
description: this.roleForm.description,
capabilities: this.roleForm.capabilities
};
this.$emit(this.roleForm.exists ? 'update-role' : 'create-role', payload);
this.roleDialog = false;
},
confirmDeleteRole(role) {
this.roleDeleteTarget = role;
this.roleDeleteDialog = true;
},
performDeleteRole() {
if (this.roleDeleteTarget) this.$emit('delete-role', this.roleDeleteTarget.key);
this.roleDeleteDialog = false;
this.roleDeleteTarget = null;
},
openNewTeam() {
this.teamForm = { id: null, name: '', description: '' };
this.teamDialog = true;
},
editTeam(team) {
this.teamForm = { id: team.id, name: team.name, description: team.description || '' };
this.teamDialog = true;
},
removeTeam(team) {
if (window.confirm(`Delete team "${team.name}"?`)) this.$emit('delete-team', team.id);
},
saveTeam() {
this.$emit('create-team', { ...this.teamForm });
this.$emit(this.teamForm.id ? 'update-team' : 'create-team', { ...this.teamForm });
this.teamDialog = false;
this.teamForm = { name: '', description: '' };
this.teamForm = { id: null, name: '', description: '' };
},
saveUser() {
const payload = { ...this.userForm };
@@ -247,3 +474,17 @@ export default {
}
};
</script>
<style scoped>
.cap-group {
margin-bottom: 12px;
}
.cap-group-title {
font-weight: 700;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.7;
margin: 6px 0 2px;
}
</style>

219
src/views/AlertsView.vue Normal file
View File

@@ -0,0 +1,219 @@
<template>
<section class="content-grid">
<v-card class="metric span-3">
<div class="metric-label">Open</div>
<div class="metric-value">{{ summary.open || 0 }}</div>
<div class="metric-subtle">Needing attention</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Critical</div>
<div class="metric-value">{{ summary.critical || 0 }}</div>
<div class="metric-subtle">Overdue or expired</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Acknowledged</div>
<div class="metric-value">{{ summary.acknowledged || 0 }}</div>
<div class="metric-subtle">In progress</div>
</v-card>
<v-card class="metric span-3 d-flex flex-column justify-center">
<v-btn color="primary" prepend-icon="mdi-refresh" :loading="loading" @click="refresh">Run scan</v-btn>
<v-btn v-if="isAdmin" class="mt-2" variant="tonal" size="small" prepend-icon="mdi-email-fast-outline" :loading="emailing" @click="emailDigest">Email digest now</v-btn>
</v-card>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<h2 class="section-title">Reminders &amp; alerts</h2>
<v-spacer />
<v-select v-model="statusFilter" :items="statusItems" label="Status" density="compact" hide-details style="max-width:180px" />
<v-select v-model="typeFilter" :items="typeItems" label="Type" density="compact" hide-details clearable style="max-width:220px" />
</div>
<DataTable
:columns="columns"
:rows="filteredAlerts"
row-key="id"
has-actions
searchable
search-placeholder="Filter alerts"
empty-text="No alerts you're all caught up."
>
<template #cell-severity="{ row }">
<v-chip :color="severityColor(row.severity)" size="small" variant="tonal">{{ row.severity }}</v-chip>
</template>
<template #cell-type="{ row }">{{ humanize(row.type) }}</template>
<template #cell-asset_code="{ row }">{{ row.asset_code || '—' }}</template>
<template #cell-status="{ row }">
<span class="text-capitalize">{{ row.status }}</span>
</template>
<template #cell-external_ticket_number="{ row }">
<a v-if="row.external_ticket_number" :href="row.external_ticket_url" target="_blank" rel="noopener" class="ticket-link">
<v-icon icon="mdi-ticket-confirmation-outline" size="14" /> {{ row.external_ticket_number }}
</a>
<span v-else class="quiet"></span>
</template>
<template #actions="{ row }">
<v-btn v-if="row.status === 'open'" size="small" variant="text" @click="setStatus(row, 'acknowledge')">Ack</v-btn>
<v-btn
v-if="!row.external_ticket_number && row.status !== 'dismissed'"
size="small"
variant="text"
color="primary"
:loading="ticketingId === row.id"
@click="submitTicket(row)"
>ServiceNow</v-btn>
<v-btn v-if="row.status !== 'dismissed'" size="small" variant="text" color="error" @click="setStatus(row, 'dismiss')">Dismiss</v-btn>
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
<v-alert v-if="message" type="success" density="compact" class="mt-3">{{ message }}</v-alert>
</v-card>
</section>
</template>
<script>
import DataTable from '../components/DataTable.vue';
import { apiRequest } from '../services/api';
export default {
components: { DataTable },
props: {
token: { type: String, required: true },
isAdmin: { type: Boolean, default: false }
},
data() {
return {
alerts: [],
summary: {},
statusFilter: 'open',
typeFilter: null,
loading: false,
emailing: false,
error: '',
message: '',
statusItems: [
{ title: 'Open', value: 'open' },
{ title: 'Acknowledged', value: 'acknowledged' },
{ title: 'Dismissed', value: 'dismissed' },
{ title: 'Resolved', value: 'resolved' },
{ title: 'All', value: 'all' }
],
typeItems: [
{ title: 'Maintenance overdue', value: 'maintenance_overdue' },
{ title: 'Maintenance due', value: 'maintenance_due' },
{ title: 'Warranty expired', value: 'warranty_expired' },
{ title: 'Warranty expiring', value: 'warranty_expiring' },
{ title: 'Lease ending', value: 'lease_expiring' }
],
columns: [
{ key: 'severity', label: 'Severity' },
{ key: 'type', label: 'Type' },
{ key: 'title', label: 'Alert' },
{ key: 'message', label: 'Detail' },
{ key: 'due_date', label: 'Due', format: 'date' },
{ key: 'asset_code', label: 'Asset' },
{ key: 'status', label: 'Status' },
{ key: 'external_ticket_number', label: 'Ticket', sortable: false }
],
ticketingId: null
};
},
computed: {
filteredAlerts() {
return this.alerts.filter((a) =>
(this.statusFilter === 'all' || a.status === this.statusFilter) &&
(!this.typeFilter || a.type === this.typeFilter)
);
}
},
mounted() {
this.refresh();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async refresh() {
this.loading = true;
this.error = '';
this.message = '';
try {
const data = await (await this.api('/api/alerts/scan', { method: 'POST' })).json();
this.alerts = data.alerts;
this.summary = data.summary;
} catch (error) {
// Fall back to a read-only list if the user cannot scan.
try {
const data = await (await this.api('/api/alerts')).json();
this.alerts = data.alerts;
this.summary = data.summary;
} catch (inner) {
this.error = inner.message;
}
} finally {
this.loading = false;
}
},
async setStatus(row, action) {
try {
await this.api(`/api/alerts/${row.id}/${action}`, { method: 'PUT' });
const data = await (await this.api('/api/alerts')).json();
this.alerts = data.alerts;
this.summary = data.summary;
} catch (error) {
this.error = error.message;
}
},
async emailDigest() {
this.emailing = true;
this.error = '';
this.message = '';
try {
const result = await (await this.api('/api/alerts/run', { method: 'POST' })).json();
this.message = result.emailed
? `Digest emailed (${result.count} alert(s)).`
: 'Scan complete. No email sent (alerts disabled, SMTP not set, or nothing new).';
} catch (error) {
this.error = error.message;
} finally {
this.emailing = false;
}
},
async submitTicket(row) {
this.ticketingId = row.id;
this.error = '';
this.message = '';
try {
const { ticket } = await (await this.api(`/api/alerts/${row.id}/ticket`, { method: 'POST', body: JSON.stringify({}) })).json();
this.message = ticket.duplicate
? `Alert already linked to ${ticket.number}.`
: `Created ServiceNow incident ${ticket.number}.`;
const data = await (await this.api('/api/alerts')).json();
this.alerts = data.alerts;
this.summary = data.summary;
} catch (error) {
this.error = `ServiceNow ticket failed: ${error.message}`;
} finally {
this.ticketingId = null;
}
},
severityColor(severity) {
return { critical: 'error', warning: 'warning', info: 'info' }[severity] || 'info';
},
humanize(value) {
return String(value || '').replace(/_/g, ' ');
}
}
};
</script>
<style scoped>
.ticket-link {
color: rgb(var(--v-theme-primary));
text-decoration: none;
white-space: nowrap;
}
.ticket-link:hover {
text-decoration: underline;
}
</style>

View File

@@ -6,6 +6,9 @@
<v-select v-model="localFilters.status" :items="statusItems" clearable label="Status" hide-details style="max-width: 180px" @update:model-value="emitFilters" />
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-pencil-multiple" :disabled="!selectedAssetIds.length" @click="$emit('open-mass-edit')">Mass edit</v-btn>
<v-btn variant="tonal" prepend-icon="mdi-barcode" @click="$emit('print-labels')">
{{ selectedAssetIds.length ? `Labels (${selectedAssetIds.length})` : 'Labels' }}
</v-btn>
<v-btn variant="tonal" prepend-icon="mdi-upload" @click="$refs.importFile.click()">Import</v-btn>
<input ref="importFile" hidden type="file" accept=".csv,.json,.xlsx,.xls,.xml" @change="$emit('import-assets', $event)" />
<v-menu>
@@ -17,44 +20,39 @@
</v-list>
</v-menu>
</div>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th style="width:44px"><v-checkbox-btn :model-value="allSelected" @update:model-value="$emit('toggle-all', $event)" /></th>
<th>Asset ID</th>
<th>Description</th>
<th>Entity</th>
<th>Category</th>
<th>Status</th>
<th>Cost</th>
<th>Service date</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="asset in assets" :key="asset.id">
<td><v-checkbox-btn :model-value="selectedAssetIds.includes(asset.id)" @update:model-value="$emit('toggle-asset', asset.id)" /></td>
<td class="font-weight-bold">{{ asset.asset_id }}</td>
<td>{{ asset.description }}</td>
<td>{{ asset.entity_name }}</td>
<td>{{ asset.category }}</td>
<td><span :class="['status-chip', `status-${asset.status}`]">{{ asset.status }}</span></td>
<td>{{ currency(asset.acquisition_cost) }}</td>
<td>{{ asset.in_service_date }}</td>
<td class="text-right">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="$emit('edit-asset', asset)" />
</td>
</tr>
</tbody>
</table>
</div>
<DataTable
:columns="columns"
:rows="assets"
row-key="id"
has-lead
has-actions
empty-text="No assets match the current filters."
>
<template #lead-header>
<v-checkbox-btn :model-value="allSelected" @update:model-value="$emit('toggle-all', $event)" />
</template>
<template #lead="{ row }">
<v-checkbox-btn :model-value="selectedAssetIds.includes(row.id)" @update:model-value="$emit('toggle-asset', row.id)" />
</template>
<template #cell-asset_id="{ row }">
<span class="font-weight-bold">{{ row.asset_id }}</span>
</template>
<template #cell-status="{ row }">
<span :class="['status-chip', `status-${row.status}`]">{{ row.status }}</span>
</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="$emit('edit-asset', row)" />
</template>
</DataTable>
</v-card>
</section>
</template>
<script>
import DataTable from '../components/DataTable.vue';
export default {
components: { DataTable },
props: {
allSelected: { type: Boolean, default: false },
assets: { type: Array, required: true },
@@ -63,10 +61,19 @@ export default {
selectedAssetIds: { type: Array, required: true },
statusItems: { type: Array, required: true }
},
emits: ['edit-asset', 'export-assets', 'filter-assets', 'import-assets', 'open-mass-edit', 'toggle-all', 'toggle-asset'],
emits: ['edit-asset', 'export-assets', 'filter-assets', 'import-assets', 'open-mass-edit', 'print-labels', 'toggle-all', 'toggle-asset'],
data() {
return {
localFilters: { ...this.assetFilters }
localFilters: { ...this.assetFilters },
columns: [
{ key: 'asset_id', label: 'Asset ID' },
{ key: 'description', label: 'Description' },
{ key: 'entity_name', label: 'Entity' },
{ key: 'category', label: 'Category' },
{ key: 'status', label: 'Status' },
{ key: 'acquisition_cost', label: 'Cost', format: 'currency' },
{ key: 'in_service_date', label: 'Service date', format: 'date' }
]
};
},
watch: {

View File

@@ -37,15 +37,22 @@
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-account-plus" @click="employeeDialog = true">Add employee</v-btn>
</div>
<v-list lines="two">
<v-list-item v-for="employee in employees" :key="employee.id" prepend-icon="mdi-account">
<v-list-item-title>{{ employee.name }}</v-list-item-title>
<v-list-item-subtitle>{{ employee.email || employee.employee_number }} · {{ employee.department || 'No department' }} · {{ employee.location || 'No location' }}</v-list-item-subtitle>
<template #append>
<v-chip size="small" variant="tonal">{{ employee.source }}</v-chip>
</template>
</v-list-item>
</v-list>
<DataTable
:columns="employeeColumns"
:rows="employees"
row-key="id"
:page-size="10"
searchable
search-placeholder="Filter employees"
empty-text="No employees yet."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-source="{ row }">
<v-chip size="small" variant="tonal">{{ row.source }}</v-chip>
</template>
</DataTable>
</v-card>
<v-card class="span-12 panel-pad">
@@ -54,37 +61,29 @@
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-refresh" @click="$emit('reload')">Refresh</v-btn>
</div>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th>Asset</th>
<th>Employee</th>
<th>Department</th>
<th>Location</th>
<th>Assigned</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="assignment in assignments" :key="assignment.id">
<td>
<strong>{{ assignment.asset_code }}</strong><br />
<span class="quiet">{{ assignment.asset_description }}</span>
</td>
<td>{{ assignment.employee_name || 'Unassigned employee' }}</td>
<td>{{ assignment.department || '-' }}</td>
<td>{{ assignment.location || '-' }}</td>
<td>{{ shortDate(assignment.assigned_at) }}</td>
<td><span :class="['status-chip', assignment.active ? 'status-in_service' : 'status-disposed']">{{ assignment.status }}</span></td>
<td class="text-right">
<v-btn v-if="assignment.active" size="small" variant="text" color="warning" @click="$emit('release-assignment', assignment)">Release</v-btn>
</td>
</tr>
</tbody>
</table>
</div>
<DataTable
:columns="assignmentColumns"
:rows="assignments"
row-key="id"
has-actions
searchable
search-placeholder="Filter assignments"
empty-text="No assignment history yet."
>
<template #cell-asset_code="{ row }">
<strong>{{ row.asset_code }}</strong><br />
<span class="quiet">{{ row.asset_description }}</span>
</template>
<template #cell-employee_name="{ row }">{{ row.employee_name || 'Unassigned employee' }}</template>
<template #cell-department="{ row }">{{ row.department || '-' }}</template>
<template #cell-location="{ row }">{{ row.location || '-' }}</template>
<template #cell-status="{ row }">
<span :class="['status-chip', row.active ? 'status-in_service' : 'status-disposed']">{{ row.status }}</span>
</template>
<template #actions="{ row }">
<v-btn v-if="row.active" size="small" variant="text" color="warning" @click="$emit('release-assignment', row)">Release</v-btn>
</template>
</DataTable>
</v-card>
<v-dialog v-model="employeeDialog" max-width="480">
@@ -108,7 +107,10 @@
</template>
<script>
import DataTable from '../components/DataTable.vue';
export default {
components: { DataTable },
props: {
assignments: { type: Array, required: true },
assets: { type: Array, required: true },
@@ -120,6 +122,22 @@ export default {
emits: ['add-employee', 'assign-asset', 'release-assignment', 'reload'],
data() {
return {
assignmentColumns: [
{ key: 'asset_code', label: 'Asset' },
{ key: 'employee_name', label: 'Employee' },
{ key: 'department', label: 'Department' },
{ key: 'location', label: 'Location' },
{ key: 'assigned_at', label: 'Assigned', format: 'date' },
{ key: 'status', label: 'Status' }
],
employeeColumns: [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'employee_number', label: 'Emp #' },
{ key: 'department', label: 'Department' },
{ key: 'location', label: 'Location' },
{ key: 'source', label: 'Source' }
],
assignmentForm: {
asset_id: null,
employee_id: null,

357
src/views/BooksView.vue Normal file
View File

@@ -0,0 +1,357 @@
<template>
<section class="content-grid">
<!-- Book configuration -->
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-1">
<h2 class="section-title">Books</h2>
<v-spacer />
<v-btn color="primary" size="small" prepend-icon="mdi-plus" @click="openNewBook">New book</v-btn>
</div>
<p class="quiet text-body-2 mb-3">Configure each set of books, assign the depreciation rule set it should use, and set entry defaults. Add user-defined books for additional reporting scenarios.</p>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th>Book</th>
<th>Type</th>
<th>Tax rule set</th>
<th>Default method</th>
<th>Convention</th>
<th>Assets</th>
<th>Enabled</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="book in books" :key="book.id">
<td>
<div class="font-weight-bold">{{ book.code }}<v-chip v-if="book.is_primary" size="x-small" color="primary" variant="tonal" class="ml-2">primary</v-chip></div>
<v-text-field v-model="book.name" density="compact" hide-details variant="plain" />
</td>
<td style="min-width:140px">
<v-select v-model="book.book_type" :items="bookTypeItems" item-title="title" item-value="value" density="compact" hide-details />
</td>
<td style="min-width:220px">
<v-select
v-model="book.tax_rule_set_id"
:items="ruleSetItems"
item-title="title"
item-value="value"
density="compact"
hide-details
clearable
placeholder="Active set"
/>
</td>
<td style="min-width:220px">
<v-select v-model="book.default_method" :items="methodItems" item-title="title" item-value="value" density="compact" hide-details />
</td>
<td style="min-width:160px">
<v-select v-model="book.default_convention" :items="conventionItems" item-title="title" item-value="value" density="compact" hide-details />
</td>
<td>{{ book.asset_count }}</td>
<td><v-switch v-model="book.enabled" color="primary" density="compact" hide-details /></td>
<td class="text-right" style="white-space:nowrap">
<v-btn size="small" color="primary" variant="tonal" prepend-icon="mdi-content-save" :loading="savingId === book.id" @click="save(book)">Save</v-btn>
<v-btn
v-if="!book.is_primary && book.book_type !== 'maintenance'"
icon="mdi-delete-outline"
size="small"
variant="text"
color="error"
class="ml-1"
@click="removeBook(book)"
/>
</td>
</tr>
</tbody>
</table>
</div>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
<v-alert v-if="message" type="success" density="compact" class="mt-3">{{ message }}</v-alert>
</v-card>
<!-- General ledger -->
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<h2 class="section-title">General ledger</h2>
<v-spacer />
<v-select v-model="ledgerBook" :items="bookCodeItems" label="Book" density="compact" hide-details style="max-width:180px" @update:model-value="loadLedger" />
<v-text-field v-model.number="ledgerYear" type="number" label="Year" density="compact" hide-details style="max-width:120px" @update:model-value="loadLedger" />
<v-menu>
<template #activator="{ props }">
<v-btn v-bind="props" color="secondary" size="small" prepend-icon="mdi-download">Export</v-btn>
</template>
<v-list density="compact">
<v-list-item v-for="format in ['pdf', 'xlsx', 'csv']" :key="format" :title="format.toUpperCase()" @click="exportLedger(format)" />
</v-list>
</v-menu>
</div>
<div v-if="ledger">
<div v-if="ledger.kind === 'maintenance'" class="ledger-summary mb-4">
<div class="ledger-chip"><span class="quiet">Assets serviced</span><strong>{{ ledger.summary.assets }}</strong></div>
<div class="ledger-chip"><span class="quiet">Services ({{ ledger.year }})</span><strong>{{ ledger.summary.completions }}</strong></div>
<div class="ledger-chip"><span class="quiet">Total PM spend</span><strong>{{ currency(ledger.summary.cost) }}</strong></div>
</div>
<div v-else class="ledger-summary mb-4">
<div class="ledger-chip"><span class="quiet">Assets</span><strong>{{ ledger.summary.assets }}</strong></div>
<div class="ledger-chip"><span class="quiet">Cost</span><strong>{{ currency(ledger.summary.cost) }}</strong></div>
<div class="ledger-chip"><span class="quiet">Accumulated depr.</span><strong>{{ currency(ledger.summary.accumulated) }}</strong></div>
<div class="ledger-chip"><span class="quiet">Net book value</span><strong>{{ currency(ledger.summary.nbv) }}</strong></div>
<div class="ledger-chip"><span class="quiet">{{ ledger.year }} depreciation</span><strong>{{ currency(ledger.summary.depreciation) }}</strong></div>
<div class="ledger-chip"><span class="quiet">Rule set</span><strong>{{ ledger.rule_set.name || 'Active set' }}</strong></div>
</div>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr><th>GL account</th><th>Account type</th><th class="text-right">Debit</th><th class="text-right">Credit</th></tr>
</thead>
<tbody>
<tr v-for="(line, index) in ledger.accounts" :key="index">
<td class="font-weight-bold">{{ line.account }}</td>
<td>{{ line.type }}</td>
<td class="text-right">{{ line.debit ? currency(line.debit) : '' }}</td>
<td class="text-right">{{ line.credit ? currency(line.credit) : '' }}</td>
</tr>
<tr v-if="!ledger.accounts.length"><td colspan="4" class="quiet">No postings for this book and year.</td></tr>
</tbody>
<tfoot>
<tr>
<td class="font-weight-bold" colspan="2">Totals</td>
<td class="text-right font-weight-bold">{{ currency(ledger.accountTotals.debit) }}</td>
<td class="text-right font-weight-bold">{{ currency(ledger.accountTotals.credit) }}</td>
</tr>
</tfoot>
</table>
</div>
<v-expansion-panels class="mt-4" variant="accordion">
<v-expansion-panel title="Asset detail">
<v-expansion-panel-text>
<DataTable
:columns="detailColumns"
:rows="ledger.assets"
row-key="asset_id"
searchable
search-placeholder="Filter assets"
empty-text="No assets in this book."
>
<template #cell-asset_id="{ row }">
<span class="font-weight-bold">{{ row.asset_id }}</span>
</template>
</DataTable>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
<div v-else class="quiet text-body-2">Select a book to view its general ledger.</div>
</v-card>
<v-dialog v-model="newBookDialog" max-width="560">
<v-card>
<v-card-title>New book</v-card-title>
<v-card-text>
<div class="form-grid">
<v-text-field v-model="newBook.code" label="Code" hint="Short unique code, e.g. IFRS" persistent-hint />
<v-text-field v-model="newBook.name" label="Name" />
<v-select v-model="newBook.book_type" :items="bookTypeItems" item-title="title" item-value="value" label="Type" />
<v-select v-model="newBook.tax_rule_set_id" :items="ruleSetItems" item-title="title" item-value="value" clearable placeholder="Active set" label="Tax rule set" />
<v-select v-model="newBook.default_method" :items="methodItems" item-title="title" item-value="value" label="Default method" />
<v-select v-model="newBook.default_convention" :items="conventionItems" item-title="title" item-value="value" label="Default convention" />
<v-textarea v-model="newBook.description" class="full" label="Description" rows="2" />
<v-switch v-model="newBook.enabled" label="Enabled (apply to assets)" color="primary" density="compact" hide-details />
</div>
<v-alert v-if="newBookError" type="error" density="compact" class="mt-2">{{ newBookError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="newBookDialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :disabled="!newBook.code" :loading="creating" @click="createBook">Create book</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
import DataTable from '../components/DataTable.vue';
import { apiRequest, saveBlob } from '../services/api';
import { currency } from '../utils/format';
import { conventionItems } from '../constants';
export default {
components: { DataTable },
props: {
token: { type: String, required: true },
methodItems: { type: Array, default: () => [] }
},
emits: ['updated'],
data() {
return {
conventionItems,
bookTypeItems: [
{ title: 'Financial', value: 'financial' },
{ title: 'Tax', value: 'tax' },
{ title: 'Internal', value: 'internal' }
],
books: [],
ruleSets: [],
ledger: null,
ledgerBook: 'GAAP',
ledgerYear: new Date().getFullYear(),
savingId: null,
error: '',
message: '',
newBookDialog: false,
newBookError: '',
creating: false,
newBook: this.blankBook()
};
},
computed: {
ruleSetItems() {
return this.ruleSets.map((set) => ({ title: `${set.name} · ${set.version}`, value: set.id }));
},
bookCodeItems() {
return this.books.map((book) => book.code);
},
detailColumns() {
const year = this.ledger?.year || this.ledgerYear;
if (this.ledger?.kind === 'maintenance') {
return [
{ key: 'asset_id', label: 'Asset ID' },
{ key: 'description', label: 'Description' },
{ key: 'completions', label: 'Services', format: 'number' },
{ key: 'last_service', label: 'Last service', format: 'date' },
{ key: 'cost', label: `${year} PM cost`, format: 'currency' }
];
}
return [
{ key: 'asset_id', label: 'Asset ID' },
{ key: 'description', label: 'Description' },
{ key: 'asset_account', label: 'Asset acct' },
{ key: 'expense_account', label: 'Expense acct' },
{ key: 'cost', label: 'Cost', format: 'currency' },
{ key: 'depreciation', label: `${year} depr.`, format: 'currency' },
{ key: 'accumulated', label: 'Accum.', format: 'currency' },
{ key: 'nbv', label: 'NBV', format: 'currency' }
];
}
},
mounted() {
this.load();
},
methods: {
currency,
blankBook() {
return { code: '', name: '', description: '', book_type: 'internal', tax_rule_set_id: null, default_method: 'straight_line', default_convention: 'half_year', enabled: true };
},
openNewBook() {
this.newBook = this.blankBook();
this.newBookError = '';
this.newBookDialog = true;
},
async createBook() {
this.creating = true;
this.newBookError = '';
try {
await this.api('/api/books', { method: 'POST', body: JSON.stringify(this.newBook) });
this.newBookDialog = false;
await this.load();
this.$emit('updated');
} catch (error) {
this.newBookError = error.message;
} finally {
this.creating = false;
}
},
async removeBook(book) {
this.error = '';
if (!window.confirm(`Delete book "${book.code}"?`)) return;
try {
await this.api(`/api/books/${book.id}`, { method: 'DELETE' });
await this.load();
this.$emit('updated');
} catch (error) {
this.error = error.message;
}
},
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
const data = await (await this.api('/api/books')).json();
this.books = data.books;
this.ruleSets = data.ruleSets;
if (this.books.length) {
if (!this.books.some((book) => book.code === this.ledgerBook)) this.ledgerBook = this.books[0].code;
this.loadLedger();
}
},
async save(book) {
this.savingId = book.id;
this.error = '';
this.message = '';
try {
await this.api(`/api/books/${book.id}`, {
method: 'PUT',
body: JSON.stringify({
name: book.name,
book_type: book.book_type,
enabled: book.enabled,
tax_rule_set_id: book.tax_rule_set_id,
default_method: book.default_method,
default_convention: book.default_convention
})
});
this.message = `${book.code} saved.`;
this.$emit('updated');
if (book.code === this.ledgerBook) this.loadLedger();
} catch (error) {
this.error = error.message;
} finally {
this.savingId = null;
}
},
async loadLedger() {
if (!this.ledgerBook) return;
this.error = '';
try {
const data = await (await this.api(`/api/books/${this.ledgerBook}/ledger?year=${this.ledgerYear}`)).json();
this.ledger = data.ledger;
} catch (error) {
this.error = error.message;
}
},
async exportLedger(format) {
const blob = await (await this.api(`/api/books/${this.ledgerBook}/ledger/export`, {
method: 'POST',
body: JSON.stringify({ year: this.ledgerYear, format })
})).blob();
saveBlob(blob, `mixedassets-${this.ledgerBook}-ledger-${this.ledgerYear}.${format}`);
}
}
};
</script>
<style scoped>
.ledger-summary {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.ledger-chip {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 140px;
padding: 10px 14px;
border: 1px solid rgba(128, 128, 128, 0.25);
border-radius: 8px;
}
.ledger-chip strong {
font-size: 1.05rem;
}
</style>

253
src/views/ContactsView.vue Normal file
View File

@@ -0,0 +1,253 @@
<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Contacts</h2>
<div class="quiet text-body-2">Employees, vendors, service providers, contractors, work locations, and more.</div>
</div>
<v-spacer />
<v-select v-model="typeFilter" :items="typeFilterItems" item-title="title" item-value="value" label="Type" density="compact" hide-details style="max-width:200px" @update:model-value="load" />
<v-btn color="primary" prepend-icon="mdi-account-plus" @click="openNew">New contact</v-btn>
</div>
<DataTable
:columns="columns"
:rows="contacts"
row-key="id"
:page-size="15"
has-actions
searchable
search-placeholder="Filter contacts"
empty-text="No contacts yet."
>
<template #cell-display_name="{ row }">
<span class="font-weight-bold">{{ row.display_name }}</span>
<v-chip v-if="row.source === 'workday'" size="x-small" variant="tonal" class="ml-2">Workday</v-chip>
</template>
<template #cell-type="{ row }">{{ typeLabel(row.type) }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn v-if="canDelete" icon="mdi-delete-outline" size="small" variant="text" color="error" @click="remove(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
</v-card>
<v-dialog v-model="dialog" max-width="760" scrollable>
<v-card>
<v-card-title>{{ form.id ? 'Edit contact' : 'New contact' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<v-select v-model="form.type" :items="contactTypeItems" item-title="title" item-value="value" label="Type" />
<v-select v-model="form.status" :items="['active', 'inactive']" label="Status" />
<template v-if="showName">
<v-text-field v-model="form.first_name" label="First name" />
<v-text-field v-model="form.last_name" label="Last name" />
</template>
<v-text-field v-if="showOrg" v-model="form.organization" class="full" :label="form.type === 'work_location' ? 'Location name' : 'Organization'" />
<v-text-field v-model="form.email" label="Email" />
<v-text-field v-model="form.phone" label="Phone (international)" placeholder="+1 555-0100" />
<!-- Employee / contractor job info -->
<template v-if="showJob">
<v-text-field v-if="form.type === 'employee'" v-model="form.employee_id" label="Employee ID" />
<v-text-field v-if="form.type === 'contractor'" v-model="form.tax_id" label="Tax ID / business license #" />
<v-text-field v-model="form.department" label="Department" />
<v-text-field v-model="form.work_location" label="Work location" />
<v-text-field v-model="form.start_date" type="date" label="Start date" />
<v-text-field v-model="form.manager_first_name" label="Manager first name" />
<v-text-field v-model="form.manager_last_name" label="Manager last name" />
<v-text-field v-model="form.manager_email" label="Manager email" />
</template>
<!-- Vendor / service provider -->
<template v-if="showVendor">
<div>
<div class="quiet text-caption mb-1">Rating</div>
<v-rating v-model="form.rating" length="5" color="amber" active-color="amber" half-increments="false" density="compact" clearable />
</div>
<v-select v-model="form.credit_term" :items="creditTermItems" item-title="title" item-value="value" label="Credit term" />
</template>
</div>
<v-divider class="my-4" />
<h3 class="section-title mb-2">Address</h3>
<div class="form-grid">
<v-text-field v-model="form.address_line1" class="full" label="Address line 1" />
<v-text-field v-model="form.address_line2" class="full" label="Address line 2" />
<v-text-field v-model="form.city" label="City / locality" />
<v-text-field v-model="form.state_region" label="State / region / province" />
<v-text-field v-model="form.postal_code" label="Postal code" />
<v-text-field v-model="form.country" label="Country" />
</div>
<v-textarea v-model="form.notes" class="mt-3" label="Notes" rows="2" />
<template v-if="form.id">
<v-divider class="my-4" />
<div class="toolbar-row mb-2">
<h3 class="section-title">Notes log</h3>
</div>
<div class="toolbar-row">
<v-text-field v-model="noteDraft" label="Add a note" density="compact" hide-details @keyup.enter="addNote" />
<v-btn variant="tonal" prepend-icon="mdi-plus" :disabled="!noteDraft.trim()" @click="addNote">Add</v-btn>
</div>
<v-list v-if="form.contact_notes && form.contact_notes.length" density="compact">
<v-list-item v-for="note in form.contact_notes" :key="note.id">
<v-list-item-title style="white-space:normal">{{ note.body }}</v-list-item-title>
<v-list-item-subtitle>{{ note.author || 'Unknown' }} · {{ note.created_at }}</v-list-item-subtitle>
<template #append>
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="deleteNote(note.id)" />
</template>
</v-list-item>
</v-list>
</template>
<v-alert v-if="dialogError" type="error" density="compact" class="mt-3">{{ dialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save contact</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
import DataTable from '../components/DataTable.vue';
import { apiRequest } from '../services/api';
import { contactTypeItems, creditTermItems } from '../constants';
import { clonePlain } from '../utils/clone';
function blankContact() {
return {
id: null, type: 'employee', status: 'active', first_name: '', last_name: '', organization: '',
email: '', phone: '', address_line1: '', address_line2: '', city: '', state_region: '', postal_code: '', country: '',
notes: '', employee_id: '', tax_id: '', department: '', work_location: '',
manager_first_name: '', manager_last_name: '', manager_email: '', start_date: '', rating: null, credit_term: 'na',
contact_notes: []
};
}
export default {
components: { DataTable },
props: {
token: { type: String, required: true },
canDelete: { type: Boolean, default: false }
},
data() {
return {
contactTypeItems,
creditTermItems,
contacts: [],
typeFilter: null,
dialog: false,
form: blankContact(),
noteDraft: '',
saving: false,
error: '',
dialogError: '',
columns: [
{ key: 'display_name', label: 'Name / organization' },
{ key: 'type', label: 'Type' },
{ key: 'email', label: 'Email' },
{ key: 'phone', label: 'Phone' },
{ key: 'department', label: 'Department' },
{ key: 'work_location', label: 'Location' }
]
};
},
computed: {
typeFilterItems() {
return [{ title: 'All types', value: null }, ...contactTypeItems];
},
showName() {
return ['employee', 'contractor', 'other'].includes(this.form.type);
},
showOrg() {
return ['vendor', 'service_provider', 'work_location', 'other'].includes(this.form.type);
},
showJob() {
return ['employee', 'contractor'].includes(this.form.type);
},
showVendor() {
return ['vendor', 'service_provider'].includes(this.form.type);
}
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
typeLabel(type) {
return (contactTypeItems.find((t) => t.value === type) || {}).title || type;
},
async load() {
this.error = '';
try {
const query = this.typeFilter ? `?type=${this.typeFilter}` : '';
const data = await (await this.api(`/api/contacts${query}`)).json();
this.contacts = data.contacts;
} catch (error) {
this.error = error.message;
}
},
openNew() {
this.form = blankContact();
this.dialogError = '';
this.noteDraft = '';
this.dialog = true;
},
async openEdit(contact) {
this.dialogError = '';
this.noteDraft = '';
const data = await (await this.api(`/api/contacts/${contact.id}`)).json();
this.form = { ...blankContact(), ...clonePlain(data.contact) };
this.dialog = true;
},
async save() {
this.saving = true;
this.dialogError = '';
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/contacts/${this.form.id}` : '/api/contacts';
const data = await (await this.api(url, { method, body: JSON.stringify(this.form) })).json();
this.form = { ...blankContact(), ...data.contact };
await this.load();
} catch (error) {
this.dialogError = error.message;
} finally {
this.saving = false;
}
},
async remove(contact) {
if (!window.confirm(`Delete ${contact.display_name}?`)) return;
try {
await this.api(`/api/contacts/${contact.id}`, { method: 'DELETE' });
await this.load();
} catch (error) {
this.error = error.message;
}
},
async addNote() {
const body = this.noteDraft.trim();
if (!body || !this.form.id) return;
await this.api(`/api/contacts/${this.form.id}/notes`, { method: 'POST', body: JSON.stringify({ body }) });
this.noteDraft = '';
const data = await (await this.api(`/api/contacts/${this.form.id}`)).json();
this.form.contact_notes = data.contact.contact_notes;
},
async deleteNote(noteId) {
await this.api(`/api/contacts/${this.form.id}/notes/${noteId}`, { method: 'DELETE' });
this.form.contact_notes = this.form.contact_notes.filter((n) => n.id !== noteId);
}
}
};
</script>

View File

@@ -28,6 +28,34 @@
<div class="chart-box">
<Bar :data="categoryChart" :options="chartOptions" />
</div>
<div class="mt-4" style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th>Category</th>
<th class="text-right">Assets</th>
<th class="text-right">Value</th>
</tr>
</thead>
<tbody>
<tr v-for="row in dashboard.byCategory" :key="row.category">
<td class="font-weight-bold">{{ row.category }}</td>
<td class="text-right">{{ row.count }}</td>
<td class="text-right">{{ currency(row.value) }}</td>
</tr>
<tr v-if="!dashboard.byCategory.length">
<td colspan="3" class="quiet">No assets yet.</td>
</tr>
</tbody>
<tfoot v-if="dashboard.byCategory.length">
<tr>
<td class="font-weight-bold">Total</td>
<td class="text-right font-weight-bold">{{ totalAssets }}</td>
<td class="text-right font-weight-bold">{{ currency(totalValue) }}</td>
</tr>
</tfoot>
</table>
</div>
</v-card>
<v-card class="span-5 panel-pad">
@@ -56,6 +84,14 @@ export default {
currency: { type: Function, required: true },
dashboard: { type: Object, required: true },
shortDate: { type: Function, required: true }
},
computed: {
totalAssets() {
return this.dashboard.byCategory.reduce((sum, row) => sum + Number(row.count || 0), 0);
},
totalValue() {
return this.dashboard.byCategory.reduce((sum, row) => sum + Number(row.value || 0), 0);
}
}
};
</script>

509
src/views/PartsView.vue Normal file
View File

@@ -0,0 +1,509 @@
<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Parts inventory</h2>
<div class="quiet text-body-2">Maintenance parts &amp; supplies stock levels, aisle/bin locations, and reservations. Separate from the depreciation register.</div>
</div>
<v-spacer />
<v-select
v-model="categoryFilter"
:items="categoryFilterItems"
label="Category"
density="compact"
hide-details
style="max-width:200px"
@update:model-value="load"
/>
<v-select
v-model="statusFilter"
:items="statusFilterItems"
item-title="title"
item-value="value"
label="Status"
density="compact"
hide-details
style="max-width:170px"
@update:model-value="load"
/>
<v-switch v-model="lowStockOnly" label="Low stock" color="warning" density="compact" hide-details inset @update:model-value="load" />
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New part</v-btn>
</div>
<div class="kpi-row mb-3">
<div class="kpi"><span class="kpi-value">{{ parts.length }}</span><span class="kpi-label">Parts</span></div>
<div class="kpi"><span class="kpi-value">{{ totalOnHand }}</span><span class="kpi-label">Units on hand</span></div>
<div class="kpi"><span class="kpi-value">{{ totalReserved }}</span><span class="kpi-label">Reserved</span></div>
<div class="kpi"><span class="kpi-value">{{ currency(totalValue) }}</span><span class="kpi-label">Stock value</span></div>
<div class="kpi"><span class="kpi-value text-warning">{{ lowStockCount }}</span><span class="kpi-label">Below reorder</span></div>
</div>
<DataTable
:columns="columns"
:rows="parts"
row-key="id"
:page-size="15"
has-actions
searchable
search-placeholder="Filter parts"
empty-text="No parts yet."
>
<template #cell-part_number="{ row }">
<span class="font-weight-bold">{{ row.part_number }}</span>
<v-chip v-if="row.low_stock" size="x-small" color="warning" variant="flat" class="ml-2">Low</v-chip>
<v-chip v-if="row.status !== 'active'" size="x-small" variant="tonal" class="ml-2">{{ row.status }}</v-chip>
</template>
<template #cell-available="{ row }">
<span :class="{ 'text-warning font-weight-bold': row.low_stock }">{{ row.available }}</span>
</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn v-if="canDelete" icon="mdi-delete-outline" size="small" variant="text" color="error" @click="remove(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
</v-card>
<v-dialog v-model="dialog" max-width="920" scrollable>
<v-card>
<v-card-title>{{ form.id ? `Edit part · ${form.part_number}` : 'New part' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<v-text-field v-model="form.part_number" label="Part number *" />
<v-text-field v-model="form.name" label="Name *" />
<v-combobox v-model="form.category" :items="categoryOptions" label="Category" />
<v-select v-model="form.status" :items="partStatusItems" item-title="title" item-value="value" label="Status" />
<v-combobox v-model="form.unit_of_measure" :items="unitOfMeasureItems" label="Unit of measure" />
<v-text-field v-model.number="form.unit_cost" type="number" label="Unit cost" prefix="$" />
<v-text-field v-model.number="form.reorder_point" type="number" label="Reorder point" hint="Flag low stock at/below this" persistent-hint />
<v-text-field v-model.number="form.reorder_quantity" type="number" label="Reorder quantity" />
<v-text-field v-model="form.manufacturer" label="Manufacturer" />
<v-text-field v-model="form.manufacturer_part_number" label="Manufacturer part #" />
<v-text-field v-model="form.barcode" label="Barcode / UPC" />
<v-autocomplete
v-model="form.supplier_contact_id"
:items="supplierItems"
item-title="title"
item-value="value"
label="Supplier (from contacts)"
clearable
/>
</div>
<v-textarea v-model="form.measurements" class="mt-3" label="Measurements / specifications" rows="2" placeholder="e.g. 12mm x 50mm, M8 thread, 24V" />
<v-textarea v-model="form.description" class="mt-2" label="Description / identification notes" rows="2" />
<v-textarea v-model="form.notes" class="mt-2" label="Internal notes" rows="2" />
<template v-if="form.id">
<!-- ---- Stock locations ---- -->
<v-divider class="my-4" />
<div class="toolbar-row mb-2">
<h3 class="section-title">Stock locations</h3>
<v-spacer />
<v-chip size="small" variant="tonal">On hand {{ form.on_hand }} · Reserved {{ form.reserved }} · Available {{ form.available }}</v-chip>
</div>
<v-table density="compact" class="mb-3">
<thead>
<tr>
<th>Location</th><th>Aisle</th><th>Bin</th>
<th class="text-right">On hand</th><th class="text-right">Reserved</th><th class="text-right">Available</th><th />
</tr>
</thead>
<tbody>
<tr v-for="s in form.stock" :key="s.id">
<td>{{ s.location_name }}</td>
<td>{{ s.aisle }}</td>
<td>{{ s.bin }}</td>
<td class="text-right">{{ s.quantity }}</td>
<td class="text-right">{{ s.reserved }}</td>
<td class="text-right">{{ s.available }}</td>
<td class="text-right">
<v-btn icon="mdi-pencil" size="x-small" variant="text" @click="editStock(s)" />
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="removeStock(s)" />
</td>
</tr>
<tr v-if="!form.stock.length"><td colspan="7" class="quiet">No stock locations yet.</td></tr>
</tbody>
</v-table>
<div class="form-grid">
<v-select v-model="stockDraft.location_contact_id" :items="locationItems" item-title="title" item-value="value" label="Work location" clearable />
<v-text-field v-model="stockDraft.aisle" label="Aisle" />
<v-text-field v-model="stockDraft.bin" label="Bin" />
<v-text-field v-model.number="stockDraft.quantity" type="number" label="On hand" />
<v-text-field v-model.number="stockDraft.reserved" type="number" label="Reserved" />
<div class="d-flex align-center">
<v-btn variant="tonal" prepend-icon="mdi-content-save" @click="saveStock">{{ stockDraft.id ? 'Update location' : 'Add location' }}</v-btn>
<v-btn v-if="stockDraft.id" variant="text" class="ml-1" @click="resetStockDraft">Cancel</v-btn>
</div>
</div>
<!-- ---- Stock movements ---- -->
<v-divider class="my-4" />
<h3 class="section-title mb-2">Record movement</h3>
<div class="form-grid">
<v-select v-model="txDraft.stock_id" :items="stockLocationItems" item-title="title" item-value="value" label="Stock location" />
<v-select v-model="txDraft.type" :items="partTransactionTypes" item-title="title" item-value="value" label="Movement" />
<v-text-field v-model.number="txDraft.quantity" type="number" :label="txDraft.type === 'adjust' ? 'New on-hand count' : 'Quantity'" />
<v-autocomplete v-model="txDraft.asset_id" :items="assetItems" item-title="title" item-value="value" label="Asset (optional)" clearable />
<v-text-field v-model="txDraft.notes" class="full" label="Notes / reference" />
</div>
<div class="text-right mb-3">
<v-btn color="primary" variant="tonal" prepend-icon="mdi-swap-vertical" :disabled="!txDraft.stock_id" @click="recordTransaction">Apply movement</v-btn>
</div>
<v-table v-if="form.transactions && form.transactions.length" density="compact" class="movement-log">
<thead>
<tr><th>When</th><th>Movement</th><th class="text-right">Qty</th><th>Location</th><th>Asset</th><th>By</th><th>Notes</th></tr>
</thead>
<tbody>
<tr v-for="t in form.transactions" :key="t.id">
<td>{{ shortDate(t.created_at) }}</td>
<td>{{ txLabel(t.type) }}</td>
<td class="text-right">{{ t.quantity }}</td>
<td>{{ t.location_name }}<span v-if="t.aisle"> · {{ t.aisle }}/{{ t.bin }}</span></td>
<td>{{ t.asset_code }}</td>
<td>{{ t.created_by_name }}</td>
<td style="white-space:normal">{{ t.notes }}</td>
</tr>
</tbody>
</v-table>
<!-- ---- Photos ---- -->
<v-divider class="my-4" />
<div class="toolbar-row mb-2">
<h3 class="section-title">Photos</h3>
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-camera-plus-outline" @click="$refs.photoInput.click()">Add photo</v-btn>
<input ref="photoInput" type="file" accept="image/*" hidden @change="onPhoto" />
</div>
<div v-if="form.photos && form.photos.length" class="photo-grid">
<div v-for="p in form.photos" :key="p.id" class="photo-tile">
<img :src="p.data" :alt="p.name || 'part photo'" />
<v-btn icon="mdi-close" size="x-small" color="error" variant="flat" class="photo-remove" @click="removePhoto(p.id)" />
</div>
</div>
<div v-else class="quiet text-body-2">No photos yet.</div>
</template>
<v-alert v-if="dialogError" type="error" density="compact" class="mt-3">{{ dialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Close</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">{{ form.id ? 'Save changes' : 'Create part' }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
import DataTable from '../components/DataTable.vue';
import { apiRequest } from '../services/api';
import { currency, shortDate } from '../utils/format';
import { clonePlain } from '../utils/clone';
import { partStatusItems, partTransactionTypes, unitOfMeasureItems } from '../constants';
function blankPart() {
return {
id: null, part_number: '', name: '', description: '', category: '', manufacturer: '',
manufacturer_part_number: '', barcode: '', unit_of_measure: 'each', unit_cost: 0,
reorder_point: 0, reorder_quantity: 0, measurements: '', supplier_contact_id: null,
status: 'active', notes: '',
on_hand: 0, reserved: 0, available: 0, stock: [], photos: [], transactions: []
};
}
function blankStockDraft() {
return { id: null, location_contact_id: null, aisle: '', bin: '', quantity: 0, reserved: 0 };
}
function blankTxDraft() {
return { stock_id: null, type: 'receive', quantity: 1, asset_id: null, notes: '' };
}
export default {
components: { DataTable },
props: {
token: { type: String, required: true },
canDelete: { type: Boolean, default: false }
},
data() {
return {
partStatusItems,
partTransactionTypes,
unitOfMeasureItems,
parts: [],
contacts: [],
assets: [],
managedCategories: [],
categoryFilter: null,
statusFilter: null,
lowStockOnly: false,
dialog: false,
form: blankPart(),
stockDraft: blankStockDraft(),
txDraft: blankTxDraft(),
saving: false,
error: '',
dialogError: '',
columns: [
{ key: 'part_number', label: 'Part #' },
{ key: 'name', label: 'Name' },
{ key: 'category', label: 'Category' },
{ key: 'supplier_name', label: 'Supplier' },
{ key: 'on_hand', label: 'On hand', format: 'number' },
{ key: 'reserved', label: 'Reserved', format: 'number' },
{ key: 'available', label: 'Available', format: 'number' },
{ key: 'location_count', label: 'Locations', format: 'number' }
]
};
},
computed: {
categoryOptions() {
// Admin-managed parts categories, plus any value already in use so existing parts stay selectable.
const inUse = this.parts.map((p) => p.category).filter(Boolean);
return [...new Set([...this.managedCategories, ...inUse])].sort((a, b) => String(a).localeCompare(String(b)));
},
categoryFilterItems() {
return ['All categories', ...this.categoryOptions].map((c) => (c === 'All categories' ? { title: c, value: null } : c));
},
statusFilterItems() {
return [{ title: 'All statuses', value: null }, ...partStatusItems];
},
supplierItems() {
return this.contacts
.filter((c) => ['vendor', 'service_provider', 'contractor'].includes(c.type))
.map((c) => ({ title: c.display_name, value: c.id }));
},
locationItems() {
return this.contacts
.filter((c) => c.type === 'work_location')
.map((c) => ({ title: c.display_name, value: c.id }));
},
assetItems() {
return this.assets.map((a) => ({ title: `${a.asset_id} · ${a.description}`, value: a.id }));
},
stockLocationItems() {
return (this.form.stock || []).map((s) => ({
title: `${s.location_name}${s.aisle ? ` · ${s.aisle}/${s.bin || '—'}` : ''} (on hand ${s.quantity})`,
value: s.id
}));
},
totalOnHand() {
return this.parts.reduce((sum, p) => sum + Number(p.on_hand || 0), 0);
},
totalReserved() {
return this.parts.reduce((sum, p) => sum + Number(p.reserved || 0), 0);
},
totalValue() {
return this.parts.reduce((sum, p) => sum + Number(p.stock_value || 0), 0);
},
lowStockCount() {
return this.parts.filter((p) => p.low_stock).length;
}
},
mounted() {
this.load();
this.loadContacts();
this.loadAssets();
this.loadCategories();
},
methods: {
currency,
shortDate,
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
txLabel(type) {
return (partTransactionTypes.find((t) => t.value === type) || {}).title || type;
},
async load() {
this.error = '';
try {
const params = new URLSearchParams();
if (this.categoryFilter) params.set('category', this.categoryFilter);
if (this.statusFilter) params.set('status', this.statusFilter);
if (this.lowStockOnly) params.set('low_stock', 'true');
const data = await (await this.api(`/api/parts?${params.toString()}`)).json();
this.parts = data.parts;
} catch (error) {
this.error = error.message;
}
},
async loadContacts() {
try {
const data = await (await this.api('/api/contacts')).json();
this.contacts = data.contacts;
} catch {
this.contacts = [];
}
},
async loadAssets() {
try {
const data = await (await this.api('/api/assets')).json();
this.assets = data.assets;
} catch {
this.assets = [];
}
},
async loadCategories() {
try {
const data = await (await this.api('/api/part-categories')).json();
this.managedCategories = (data.categories || []).map((category) => category.name).filter(Boolean);
} catch {
this.managedCategories = [];
}
},
openNew() {
this.form = blankPart();
this.resetStockDraft();
this.txDraft = blankTxDraft();
this.dialogError = '';
this.dialog = true;
},
async openEdit(part) {
this.dialogError = '';
this.resetStockDraft();
this.txDraft = blankTxDraft();
const data = await (await this.api(`/api/parts/${part.id}`)).json();
this.form = { ...blankPart(), ...clonePlain(data.part) };
this.dialog = true;
},
setForm(part) {
this.form = { ...blankPart(), ...clonePlain(part) };
},
async save() {
this.saving = true;
this.dialogError = '';
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/parts/${this.form.id}` : '/api/parts';
const data = await (await this.api(url, { method, body: JSON.stringify(this.form) })).json();
this.setForm(data.part);
await this.load();
} catch (error) {
this.dialogError = error.message;
} finally {
this.saving = false;
}
},
async remove(part) {
if (!window.confirm(`Delete part ${part.part_number}?`)) return;
try {
await this.api(`/api/parts/${part.id}`, { method: 'DELETE' });
await this.load();
} catch (error) {
this.error = error.message;
}
},
resetStockDraft() {
this.stockDraft = blankStockDraft();
},
editStock(s) {
this.stockDraft = { id: s.id, location_contact_id: s.location_contact_id, aisle: s.aisle, bin: s.bin, quantity: s.quantity, reserved: s.reserved };
},
async saveStock() {
this.dialogError = '';
try {
const data = await (await this.api(`/api/parts/${this.form.id}/stock`, { method: 'POST', body: JSON.stringify(this.stockDraft) })).json();
this.setForm(data.part);
this.resetStockDraft();
await this.load();
} catch (error) {
this.dialogError = error.message;
}
},
async removeStock(s) {
if (!window.confirm('Remove this stock location?')) return;
const data = await (await this.api(`/api/parts/${this.form.id}/stock/${s.id}`, { method: 'DELETE' })).json();
this.setForm(data.part);
await this.load();
},
async recordTransaction() {
this.dialogError = '';
try {
const data = await (await this.api(`/api/parts/${this.form.id}/transactions`, { method: 'POST', body: JSON.stringify(this.txDraft) })).json();
this.setForm(data.part);
this.txDraft = blankTxDraft();
await this.load();
} catch (error) {
this.dialogError = error.message;
}
},
onPhoto(event) {
const [file] = event.target.files || [];
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
try {
const data = await (await this.api(`/api/parts/${this.form.id}/photos`, {
method: 'POST',
body: JSON.stringify({ name: file.name, mime: file.type, data: reader.result })
})).json();
this.setForm(data.part);
} catch (error) {
this.dialogError = error.message;
}
};
reader.readAsDataURL(file);
event.target.value = '';
},
async removePhoto(id) {
const data = await (await this.api(`/api/parts/${this.form.id}/photos/${id}`, { method: 'DELETE' })).json();
this.setForm(data.part);
}
}
};
</script>
<style scoped>
.kpi-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.kpi {
display: flex;
flex-direction: column;
padding: 10px 16px;
border-radius: 10px;
background: rgba(127, 127, 127, 0.08);
min-width: 120px;
}
.kpi-value {
font-size: 1.25rem;
font-weight: 700;
}
.kpi-label {
font-size: 0.75rem;
opacity: 0.7;
}
.photo-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.photo-tile {
position: relative;
width: 110px;
height: 110px;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(127, 127, 127, 0.25);
}
.photo-tile img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-remove {
position: absolute;
top: 4px;
right: 4px;
}
.movement-log {
max-height: 240px;
overflow-y: auto;
}
</style>

363
src/views/PmView.vue Normal file
View File

@@ -0,0 +1,363 @@
<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Preventative maintenance plans</h2>
<div class="quiet text-body-2">Reusable maintenance procedures with a frequency and step checklist. Attach plans to assets from the asset's PM tab.</div>
</div>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New plan</v-btn>
</div>
<DataTable
:columns="columns"
:rows="plans"
row-key="id"
has-actions
searchable
search-placeholder="Filter plans"
empty-text="No PM plans yet. Create one to get started."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-frequency="{ row }">every {{ row.frequency_value }} {{ unitLabel(row.frequency_unit) }}</template>
<template #cell-steps="{ row }">{{ row.steps.length }}</template>
<template #cell-estimated_minutes="{ row }">{{ row.estimated_minutes ? row.estimated_minutes + ' min' : '' }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="remove(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
</v-card>
<v-card class="span-12 panel-pad">
<h2 class="section-title mb-1">Completed PM forms</h2>
<p class="quiet text-body-2 mb-3">Signed completion records with condition ratings, notes, and photos.</p>
<DataTable
:columns="completionColumns"
:rows="completions"
row-key="id"
:page-size="15"
has-actions
searchable
search-placeholder="Filter completions"
empty-text="No completed PM forms yet."
>
<template #cell-completed_at="{ row }">{{ (row.completed_at || '').slice(0, 10) }}</template>
<template #cell-rating_safety="{ row }">{{ row.rating_safety ? row.rating_safety + '' : '' }}</template>
<template #cell-photo_count="{ row }">{{ row.photo_count }}</template>
<template #actions="{ row }">
<v-btn size="small" variant="text" prepend-icon="mdi-file-eye-outline" @click="viewCompletion(row.id)">View</v-btn>
</template>
</DataTable>
</v-card>
<PmCompletionDialog v-model="completionDialog" :token="token" :completion-id="completionId" />
<v-dialog v-model="dialog" max-width="720" scrollable>
<v-card>
<v-card-title>{{ form.id ? 'Edit PM plan' : 'New PM plan' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<v-text-field v-model="form.name" class="full" label="Plan name" />
<v-combobox v-model="form.category" :items="categoryItems" label="Category" clearable />
<v-text-field :model-value="totalStepMinutes" type="number" label="Estimated minutes (from steps)" readonly hint="Auto-totaled from step times" persistent-hint />
<v-text-field v-model.number="form.frequency_value" type="number" label="Every" />
<v-select v-model="form.frequency_unit" :items="pmFrequencyUnits" item-title="title" item-value="value" label="Frequency unit" />
<v-textarea v-model="form.description" class="full" label="Description" rows="2" />
<v-textarea v-model="form.instructions" class="full" label="Safety / general instructions" rows="2" />
</div>
<div class="toolbar-row mt-4 mb-1">
<h3 class="section-title">Steps</h3>
<v-spacer />
<span class="quiet text-caption">Total {{ totalStepMinutes }} min</span>
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="addStep">Add step</v-btn>
</div>
<p v-if="!form.steps.length" class="quiet text-body-2">No steps yet — add the procedure steps (e.g. "Tighten lug bolt A3").</p>
<div v-for="(step, index) in form.steps" :key="index" class="pm-step">
<div class="pm-step-num">{{ index + 1 }}</div>
<div class="pm-step-fields">
<div class="d-flex" style="gap:8px">
<v-text-field v-model="step.title" density="compact" hide-details placeholder="Step title" style="flex:1 1 auto" />
<v-text-field v-model.number="step.est_minutes" type="number" density="compact" hide-details placeholder="Min" style="max-width:88px" suffix="min" />
</div>
<v-text-field v-model="step.details" density="compact" hide-details placeholder="Details (optional)" class="mt-1" />
</div>
<div class="pm-step-actions">
<v-btn icon="mdi-arrow-up" size="x-small" variant="text" :disabled="index === 0" @click="moveStep(index, -1)" />
<v-btn icon="mdi-arrow-down" size="x-small" variant="text" :disabled="index === form.steps.length - 1" @click="moveStep(index, 1)" />
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="form.steps.splice(index, 1)" />
</div>
</div>
<div class="toolbar-row mt-4 mb-1">
<h3 class="section-title">Components / parts</h3>
<v-spacer />
<span class="quiet text-caption">Total {{ currency(totalComponentCost) }}</span>
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="addComponent">Add component</v-btn>
</div>
<p v-if="!form.components.length" class="quiet text-body-2">No components — add parts (part #, description, supplier, cost) to track PM cost.</p>
<div v-for="(component, index) in form.components" :key="`c${index}`" class="pm-component">
<v-text-field v-model="component.part_number" density="compact" hide-details placeholder="Part #" style="max-width:120px" />
<v-text-field v-model="component.description" density="compact" hide-details placeholder="Description" style="flex:1 1 auto" />
<v-text-field v-model="component.supplier" density="compact" hide-details placeholder="Supplier" style="max-width:140px" />
<v-text-field v-model.number="component.cost" type="number" density="compact" hide-details placeholder="Cost" prefix="$" style="max-width:110px" />
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="form.components.splice(index, 1)" />
</div>
<div class="toolbar-row mt-4 mb-1">
<h3 class="section-title">Guidance photos</h3>
<v-spacer />
<v-btn size="small" variant="tonal" prepend-icon="mdi-camera-plus-outline" @click="$refs.planPhotoInput.click()">Add photos</v-btn>
<input ref="planPhotoInput" hidden type="file" accept="image/*" capture="environment" multiple @change="onPhotoPick" />
</div>
<p v-if="!form.photos.length" class="quiet text-body-2">Add reference photos (diagrams, part locations, before/after) shown to technicians during the service.</p>
<div v-else class="pm-plan-photo-grid">
<div v-for="(photo, index) in form.photos" :key="photo.id || `np${index}`" class="pm-plan-photo">
<div class="pm-plan-photo-frame">
<img :src="photo.data" :alt="photo.caption || 'guidance'" />
<v-btn icon="mdi-close" size="x-small" color="error" variant="flat" class="pm-plan-photo-remove" @click="form.photos.splice(index, 1)" />
</div>
<v-text-field v-model="photo.caption" density="compact" hide-details placeholder="Caption" class="mt-1" />
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :disabled="!form.name" :loading="saving" @click="save">Save plan</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
import DataTable from '../components/DataTable.vue';
import PmCompletionDialog from '../components/PmCompletionDialog.vue';
import { apiRequest } from '../services/api';
import { pmFrequencyUnits } from '../constants';
import { currency } from '../utils/format';
import { clonePlain } from '../utils/clone';
function blankPlan() {
return { id: null, name: '', description: '', category: '', frequency_value: 3, frequency_unit: 'months', estimated_minutes: null, instructions: '', steps: [], components: [], photos: [] };
}
export default {
components: { DataTable, PmCompletionDialog },
props: {
token: { type: String, required: true }
},
data() {
return {
pmFrequencyUnits,
plans: [],
pmCategories: [],
completions: [],
completionDialog: false,
completionId: null,
dialog: false,
form: blankPlan(),
saving: false,
error: '',
columns: [
{ key: 'name', label: 'Plan' },
{ key: 'category', label: 'Category' },
{ key: 'frequency', label: 'Frequency', sortable: false },
{ key: 'steps', label: 'Steps' },
{ key: 'estimated_minutes', label: 'Est. time' },
{ key: 'components_total', label: 'Parts cost', format: 'currency' }
],
completionColumns: [
{ key: 'asset_code', label: 'Asset' },
{ key: 'plan_name', label: 'Plan' },
{ key: 'completed_at', label: 'Completed', format: 'date' },
{ key: 'completed_by_name', label: 'By' },
{ key: 'rating_safety', label: 'Safety' },
{ key: 'photo_count', label: 'Photos', format: 'number' }
]
};
},
computed: {
categoryItems() {
// Admin-managed PM categories, plus any value already in use so existing plans stay selectable.
const inUse = this.plans.map((plan) => plan.category).filter(Boolean);
return [...new Set([...this.pmCategories, ...inUse])].sort((a, b) => String(a).localeCompare(String(b)));
},
totalStepMinutes() {
return this.form.steps.reduce((sum, step) => sum + (Number(step.est_minutes) || 0), 0);
},
totalComponentCost() {
return this.form.components.reduce((sum, component) => sum + (Number(component.cost) || 0), 0);
}
},
mounted() {
this.load();
this.loadCompletions();
this.loadCategories();
},
methods: {
currency,
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
const data = await (await this.api('/api/pm-plans')).json();
this.plans = data.plans;
},
async loadCompletions() {
const data = await (await this.api('/api/pm-completions')).json();
this.completions = data.completions;
},
async loadCategories() {
try {
const data = await (await this.api('/api/pm-categories')).json();
this.pmCategories = (data.categories || []).map((category) => category.name).filter(Boolean);
} catch {
this.pmCategories = [];
}
},
viewCompletion(id) {
this.completionId = id;
this.completionDialog = true;
},
unitLabel(unit) {
return (pmFrequencyUnits.find((u) => u.value === unit) || {}).title || unit;
},
openNew() {
this.form = blankPlan();
this.error = '';
this.dialog = true;
},
async openEdit(plan) {
this.error = '';
// Fetch the full plan so guidance-photo image data (omitted from the list) is available.
let full = plan;
try {
const data = await (await this.api(`/api/pm-plans/${plan.id}`)).json();
full = data.plan;
} catch {
// fall back to the list row (photos will lack image data)
}
this.form = {
...clonePlain(full),
steps: clonePlain(full.steps || []),
components: clonePlain(full.components || []),
photos: clonePlain(full.photos || [])
};
this.dialog = true;
},
addStep() {
this.form.steps.push({ title: '', details: '', est_minutes: null });
},
addComponent() {
this.form.components.push({ part_number: '', description: '', supplier: '', cost: null });
},
onPhotoPick(event) {
for (const file of Array.from(event.target.files || [])) {
const reader = new FileReader();
reader.onload = () => this.form.photos.push({ name: file.name, mime: file.type, data: String(reader.result), caption: '' });
reader.readAsDataURL(file);
}
event.target.value = '';
},
moveStep(index, dir) {
const target = index + dir;
const steps = this.form.steps;
[steps[index], steps[target]] = [steps[target], steps[index]];
},
async save() {
this.saving = true;
this.error = '';
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/pm-plans/${this.form.id}` : '/api/pm-plans';
// Existing photos carry an id; resend only {id, caption} so the stored blob isn't re-uploaded.
const photos = (this.form.photos || []).map((p) => (p.id
? { id: p.id, caption: p.caption }
: { data: p.data, name: p.name, mime: p.mime, caption: p.caption }));
await this.api(url, { method, body: JSON.stringify({ ...this.form, photos }) });
this.dialog = false;
await this.load();
} catch (error) {
this.error = error.message;
} finally {
this.saving = false;
}
},
async remove(plan) {
this.error = '';
try {
await this.api(`/api/pm-plans/${plan.id}`, { method: 'DELETE' });
await this.load();
} catch (error) {
this.error = error.message;
}
}
}
};
</script>
<style scoped>
.pm-step {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.pm-step-num {
flex: 0 0 26px;
height: 26px;
border-radius: 50%;
background: rgba(var(--v-theme-primary), 0.15);
color: rgb(var(--v-theme-primary));
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.8rem;
margin-top: 4px;
}
.pm-step-fields {
flex: 1 1 auto;
}
.pm-step-actions {
display: flex;
align-items: center;
}
.pm-component {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.pm-plan-photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.pm-plan-photo-frame {
position: relative;
aspect-ratio: 4 / 3;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(var(--v-theme-on-surface), 0.18);
}
.pm-plan-photo-frame img {
width: 100%;
height: 100%;
object-fit: cover;
}
.pm-plan-photo-remove {
position: absolute;
top: 6px;
right: 6px;
}
</style>

View File

@@ -1,76 +1,287 @@
<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<v-select v-model="localFilters.book" :items="bookItems" label="Book" hide-details style="max-width: 180px" @update:model-value="emitFilters" />
<v-text-field v-model.number="localFilters.year" type="number" label="Year" hide-details style="max-width: 140px" @update:model-value="emitFilters" />
<v-spacer />
<div class="font-weight-bold">Annual depreciation: {{ currency(depreciationReport.totals.depreciation) }}</div>
</div>
<div class="chart-box mb-4">
<Bar :data="monthlyChart" :options="chartOptions" />
</div>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th>Asset ID</th>
<th>Description</th>
<th>Method</th>
<th>Cost</th>
<th>Depreciation</th>
<th>Accumulated</th>
<th>NBV</th>
</tr>
</thead>
<tbody>
<tr v-for="row in depreciationReport.rows" :key="row.asset_id">
<td class="font-weight-bold">{{ row.asset_id }}</td>
<td>{{ row.description }}</td>
<td>{{ row.methodLabel || row.method }}</td>
<td>{{ currency(row.cost) }}</td>
<td>{{ currency(row.depreciation) }}</td>
<td>{{ currency(row.accumulated) }}</td>
<td>{{ currency(row.net_book_value) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Report picker -->
<v-card class="span-4 panel-pad">
<h2 class="section-title mb-2">Reports</h2>
<v-list density="compact" nav>
<template v-for="group in groupedReports" :key="group.name">
<v-list-subheader>{{ group.name }}</v-list-subheader>
<v-list-item
v-for="report in group.reports"
:key="report.key"
:active="selectedType === report.key"
:title="report.title"
rounded="sm"
@click="selectReport(report.key)"
/>
</template>
</v-list>
<template v-if="savedReports.length">
<v-divider class="my-3" />
<h3 class="section-title mb-2">Saved reports</h3>
<v-list density="compact">
<v-list-item v-for="saved in savedReports" :key="saved.id" :title="saved.name" :subtitle="saved.report_type" @click="applySaved(saved)">
<template #append>
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click.stop="deleteSaved(saved.id)" />
</template>
</v-list-item>
</v-list>
</template>
</v-card>
<!-- Report output -->
<v-card class="span-8 panel-pad">
<div class="toolbar-row mb-3" style="flex-wrap:wrap;gap:10px">
<template v-for="key in currentParams" :key="key">
<v-select
v-if="paramSpec(key).type === 'select'"
v-model="options[key]"
:items="normalizeOptions(paramSpec(key).options)"
:label="paramSpec(key).label"
:clearable="paramSpec(key).clearable"
item-title="title"
item-value="value"
density="compact"
hide-details
style="max-width:200px"
@update:model-value="run"
/>
<v-select
v-else-if="paramSpec(key).type === 'multiselect'"
v-model="options[key]"
:items="normalizeOptions(paramSpec(key).options)"
:label="paramSpec(key).label"
item-title="title"
item-value="value"
density="compact"
hide-details
chips
multiple
closable-chips
style="min-width:280px"
@update:model-value="run"
/>
<v-text-field
v-else-if="paramSpec(key).type === 'date'"
v-model="options[key]"
:label="paramSpec(key).label"
type="date"
density="compact"
hide-details
style="max-width:180px"
@update:model-value="run"
/>
<v-text-field
v-else
v-model.number="options[key]"
:label="paramSpec(key).label"
type="number"
density="compact"
hide-details
style="max-width:130px"
@update:model-value="run"
/>
</template>
<v-spacer />
<v-btn variant="tonal" size="small" prepend-icon="mdi-content-save-outline" @click="openSave">Save</v-btn>
<v-menu>
<template #activator="{ props }">
<v-btn v-bind="props" color="secondary" size="small" prepend-icon="mdi-download">Export</v-btn>
</template>
<v-list density="compact">
<v-list-item v-for="format in ['pdf', 'xlsx', 'csv']" :key="format" :title="format.toUpperCase()" @click="exportReport(format)" />
</v-list>
</v-menu>
</div>
<div v-if="report">
<div class="toolbar-row mb-2">
<h2 class="section-title">{{ report.title }}</h2>
<v-spacer />
<span class="quiet text-caption">{{ report.rows.length }} row(s)</span>
</div>
<v-alert v-if="report.meta && report.meta.note" type="info" density="compact" variant="tonal" class="mb-3">{{ report.meta.note }}</v-alert>
<div v-if="report.meta && report.meta.asset" class="form-grid mb-3">
<div v-for="(value, key) in report.meta.asset" :key="key">
<div class="quiet text-caption">{{ key.replace(/_/g, ' ') }}</div>
<div class="text-body-2 font-weight-medium">{{ value || '—' }}</div>
</div>
</div>
<div v-if="report.chart" class="chart-box mb-4">
<Bar :data="chartData" :options="chartOptions" />
</div>
<DataTable
v-if="report.columns.length"
:columns="report.columns"
:totals="hasTotals ? report.totals : null"
:rows="report.rows"
searchable
search-placeholder="Filter this report"
empty-text="No data for the selected options."
/>
</div>
<div v-else class="quiet text-body-2">Select a report to begin.</div>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
</v-card>
<v-dialog v-model="saveDialog" max-width="440">
<v-card>
<v-card-title>Save report</v-card-title>
<v-card-text>
<v-text-field v-model="saveName" label="Report name" autofocus />
<div class="quiet text-caption">{{ report ? report.title : '' }} · current options will be saved.</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="saveDialog = false">Cancel</v-btn>
<v-btn color="primary" :disabled="!saveName" @click="saveReport">Save</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
import { Bar } from 'vue-chartjs';
import '../utils/charts';
import DataTable from '../components/DataTable.vue';
import { apiRequest, saveBlob } from '../services/api';
export default {
components: { Bar },
components: { Bar, DataTable },
props: {
bookItems: { type: Array, required: true },
chartOptions: { type: Object, required: true },
currency: { type: Function, required: true },
depreciationReport: { type: Object, required: true },
monthlyChart: { type: Object, required: true },
reportFilters: { type: Object, required: true }
token: { type: String, required: true }
},
emits: ['filter-reports'],
data() {
return {
localFilters: { ...this.reportFilters }
reports: [],
params: {},
selectedType: null,
options: {},
report: null,
savedReports: [],
saveDialog: false,
saveName: '',
error: '',
chartOptions: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { callback: (value) => `$${Number(value).toLocaleString()}` } } }
}
};
},
watch: {
reportFilters: {
handler(value) {
this.localFilters = { ...value };
},
deep: true
computed: {
groupedReports() {
const groups = {};
for (const report of this.reports) {
groups[report.group] = groups[report.group] || [];
groups[report.group].push(report);
}
return Object.entries(groups).map(([name, reports]) => ({ name, reports }));
},
currentParams() {
return this.reports.find((report) => report.key === this.selectedType)?.params || [];
},
hasTotals() {
return this.report && this.report.totals && Object.keys(this.report.totals).length > 0;
},
chartData() {
const chart = this.report?.chart;
if (!chart) return { labels: [], datasets: [] };
return {
labels: chart.labels,
datasets: [{ data: chart.values, backgroundColor: '#2457a6' }]
};
}
},
mounted() {
this.loadCatalog();
this.loadSavedReports();
},
methods: {
emitFilters() {
this.$emit('filter-reports', { ...this.localFilters });
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async loadCatalog() {
try {
const data = await (await this.api('/api/reports/catalog')).json();
this.reports = data.reports;
this.params = data.params;
if (this.reports.length) this.selectReport(this.reports[0].key);
} catch (error) {
this.error = error.message;
}
},
async loadSavedReports() {
const data = await (await this.api('/api/saved-reports')).json();
this.savedReports = data.reports;
},
paramSpec(key) {
return this.params[key] || { label: key, type: 'text' };
},
normalizeOptions(options) {
return (options || []).map((option) => (typeof option === 'object' ? option : { title: String(option), value: option }));
},
defaultOptions(type) {
const report = this.reports.find((item) => item.key === type);
const options = {};
for (const key of report?.params || []) {
const spec = this.paramSpec(key);
options[key] = Array.isArray(spec.default) ? [...spec.default] : spec.default;
}
return options;
},
selectReport(type) {
this.selectedType = type;
this.options = this.defaultOptions(type);
this.run();
},
applySaved(saved) {
this.selectedType = saved.report_type;
this.options = { ...this.defaultOptions(saved.report_type), ...(saved.options_json || {}) };
this.run();
},
async run() {
if (!this.selectedType) return;
this.error = '';
try {
const data = await (await this.api('/api/reports/run', {
method: 'POST',
body: JSON.stringify({ type: this.selectedType, options: this.options })
})).json();
this.report = data.report;
} catch (error) {
this.error = error.message;
}
},
async exportReport(format) {
const blob = await (await this.api('/api/reports/export', {
method: 'POST',
body: JSON.stringify({ type: this.selectedType, options: this.options, format })
})).blob();
saveBlob(blob, `mixedassets-${this.selectedType}.${format}`);
},
openSave() {
this.saveName = this.report?.title || 'Saved report';
this.saveDialog = true;
},
async saveReport() {
await this.api('/api/saved-reports', {
method: 'POST',
body: JSON.stringify({ name: this.saveName, report_type: this.selectedType, options: this.options })
});
this.saveDialog = false;
await this.loadSavedReports();
},
async deleteSaved(id) {
await this.api(`/api/saved-reports/${id}`, { method: 'DELETE' });
await this.loadSavedReports();
}
}
};

224
src/views/ScanView.vue Normal file
View File

@@ -0,0 +1,224 @@
<template>
<section class="content-grid">
<v-card class="span-6 panel-pad">
<h2 class="section-title mb-1">Scan an asset</h2>
<p class="quiet text-body-2 mb-3">
Point your camera at an asset barcode, or type the code to look it up and update it in the field.
</p>
<div class="scan-viewport mb-3">
<video ref="video" class="scan-video" muted playsinline></video>
<div v-if="!scanning" class="scan-placeholder">
<v-icon icon="mdi-barcode-scan" size="40" />
<span>Camera off</span>
</div>
</div>
<div class="toolbar-row">
<v-btn v-if="!scanning" color="primary" prepend-icon="mdi-camera" :loading="starting" @click="startScan">Start camera</v-btn>
<v-btn v-else color="error" variant="tonal" prepend-icon="mdi-stop" @click="stopScan">Stop</v-btn>
<v-select
v-if="deviceItems.length > 1"
v-model="deviceId"
:items="deviceItems"
item-title="title"
item-value="value"
density="compact"
hide-details
label="Camera"
style="max-width: 240px"
@update:model-value="restartScan"
/>
</div>
<v-divider class="my-4" />
<div class="toolbar-row">
<v-text-field
v-model="manualCode"
label="Enter barcode / asset ID"
hide-details
prepend-inner-icon="mdi-keyboard-outline"
@keyup.enter="lookup(manualCode)"
/>
<v-btn variant="tonal" prepend-icon="mdi-magnify" @click="lookup(manualCode)">Look up</v-btn>
</div>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
<v-alert v-if="message" type="success" density="compact" class="mt-3">{{ message }}</v-alert>
</v-card>
<v-card v-if="asset" class="span-6 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">{{ asset.asset_id }}</h2>
<div class="quiet text-body-2">{{ asset.description }}</div>
</div>
<v-spacer />
<span :class="['status-chip', `status-${form.status}`]">{{ form.status }}</span>
</div>
<div class="form-grid">
<v-select v-model="form.status" :items="statusItems" label="Status" />
<v-select v-model="form.condition" :items="conditionItems" clearable label="Condition" />
<v-text-field v-model="form.location" label="Location" />
<v-text-field v-model="form.department" label="Department" />
<v-text-field v-model="form.custodian" label="Custodian" />
<v-textarea v-model="fieldNote" class="full" label="Add field note" rows="2" />
</div>
<div class="toolbar-row mt-3">
<v-spacer />
<v-btn variant="text" @click="clearAsset">Clear</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Update asset</v-btn>
</div>
</v-card>
<v-card v-else class="span-6 panel-pad d-flex align-center justify-center" style="min-height: 220px">
<div class="quiet text-body-2 text-center">
<v-icon icon="mdi-cube-scan" size="40" class="mb-2" /><br>
Scan or look up an asset to update it here.
</div>
</v-card>
</section>
</template>
<script>
import { apiRequest } from '../services/api';
import { conditionItems, statusItems } from '../constants';
export default {
props: {
token: { type: String, required: true }
},
emits: ['asset-updated'],
data() {
return {
conditionItems,
statusItems,
reader: null,
controls: null,
scanning: false,
starting: false,
devices: [],
deviceId: null,
asset: null,
form: { status: 'in_service', condition: null, location: '', department: '', custodian: '' },
fieldNote: '',
manualCode: '',
error: '',
message: '',
saving: false
};
},
computed: {
deviceItems() {
return this.devices.map((device, index) => ({
title: device.label || `Camera ${index + 1}`,
value: device.deviceId
}));
}
},
beforeUnmount() {
this.stopScan();
},
methods: {
async startScan() {
this.error = '';
this.message = '';
this.starting = true;
try {
const { BrowserMultiFormatReader } = await import('@zxing/browser');
this.reader = new BrowserMultiFormatReader();
this.devices = await BrowserMultiFormatReader.listVideoInputDevices();
if (!this.devices.length) throw new Error('No camera found on this device');
// Prefer a rear-facing camera when we can identify one.
if (!this.deviceId) {
const back = this.devices.find((device) => /back|rear|environment/i.test(device.label || ''));
this.deviceId = (back || this.devices[this.devices.length - 1]).deviceId;
}
this.scanning = true;
this.controls = await this.reader.decodeFromVideoDevice(this.deviceId, this.$refs.video, (result, _err, controls) => {
if (result) {
controls.stop();
this.scanning = false;
this.lookup(result.getText());
}
});
} catch (error) {
this.scanning = false;
this.error = `Camera unavailable: ${error.message}. Camera access needs HTTPS or localhost — you can still enter codes manually.`;
} finally {
this.starting = false;
}
},
stopScan() {
try {
this.controls?.stop();
} catch {
// controls may already be stopped
}
this.controls = null;
this.scanning = false;
},
async restartScan() {
if (this.scanning) {
this.stopScan();
await this.startScan();
}
},
async lookup(code) {
const value = String(code || '').trim();
if (!value) return;
this.error = '';
this.message = '';
try {
const response = await apiRequest(`/api/assets/lookup?code=${encodeURIComponent(value)}`, this.token);
const data = await response.json();
this.asset = data.asset;
this.manualCode = value;
this.form = {
status: data.asset.status,
condition: data.asset.condition || null,
location: data.asset.location || '',
department: data.asset.department || '',
custodian: data.asset.custodian || ''
};
this.fieldNote = '';
} catch (error) {
this.asset = null;
this.error = error.message;
}
},
async save() {
if (!this.asset) return;
this.saving = true;
this.error = '';
try {
await apiRequest(`/api/assets/${this.asset.id}`, this.token, {
method: 'PUT',
body: JSON.stringify(this.form)
});
if (this.fieldNote.trim()) {
await apiRequest(`/api/assets/${this.asset.id}/notes`, this.token, {
method: 'POST',
body: JSON.stringify({ body: this.fieldNote.trim() })
});
}
this.message = `${this.asset.asset_id} updated`;
this.$emit('asset-updated');
this.clearAsset();
} catch (error) {
this.error = error.message;
} finally {
this.saving = false;
}
},
clearAsset() {
this.asset = null;
this.fieldNote = '';
this.manualCode = '';
}
}
};
</script>

254
src/views/TaxRulesView.vue Normal file
View File

@@ -0,0 +1,254 @@
<template>
<section class="content-grid">
<!-- Rule set list -->
<v-card class="span-4 panel-pad">
<div class="toolbar-row mb-2">
<h2 class="section-title">Tax rule sets</h2>
<v-spacer />
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="newDraft">New</v-btn>
</div>
<p class="quiet text-body-2 mb-2">Template-driven JSON rule sets for federal, state, and local depreciation law updates.</p>
<v-btn block variant="tonal" prepend-icon="mdi-upload" class="mb-3" @click="$refs.uploadInput.click()">Upload JSON</v-btn>
<input ref="uploadInput" hidden type="file" accept=".json,application/json" @change="onUpload" />
<v-list density="compact" nav>
<v-list-item
v-for="ruleSet in ruleSets"
:key="ruleSet.id"
:active="selected && selected.id === ruleSet.id"
rounded="sm"
@click="select(ruleSet)"
>
<v-list-item-title>{{ ruleSet.name }}</v-list-item-title>
<v-list-item-subtitle>{{ ruleSet.jurisdiction }} · {{ ruleSet.version }} · {{ ruleSet.effective_date }}</v-list-item-subtitle>
<template #append>
<v-chip v-if="ruleSet.active" size="x-small" color="success" variant="tonal">active</v-chip>
</template>
</v-list-item>
</v-list>
</v-card>
<!-- Editor -->
<v-card class="span-8 panel-pad">
<div class="form-grid mb-3">
<v-text-field v-model="form.name" label="Name" />
<v-text-field v-model="form.jurisdiction" label="Jurisdiction" placeholder="US-FED, US-CA, ..." />
<v-text-field v-model="form.version" label="Version" />
<v-text-field v-model="form.effective_date" type="date" label="Effective date" />
<v-text-field v-model="form.source_note" class="full" label="Source note" />
<v-switch v-model="form.active" label="Active" color="primary" density="compact" hide-details />
</div>
<div class="toolbar-row mb-2" style="flex-wrap:wrap;gap:8px">
<v-btn size="small" variant="text" prepend-icon="mdi-code-braces" @click="formatJson">Format</v-btn>
<v-btn size="small" variant="text" prepend-icon="mdi-table-refresh" :loading="expanding" @click="regenerateMethods">Regenerate method catalog</v-btn>
<v-spacer />
<v-btn size="small" variant="text" prepend-icon="mdi-download" @click="exportJson">Export</v-btn>
<v-btn v-if="selected && !form.active" size="small" variant="tonal" prepend-icon="mdi-check-circle-outline" @click="activate">Activate</v-btn>
<v-btn v-if="selected" size="small" variant="text" color="error" prepend-icon="mdi-delete-outline" @click="remove">Delete</v-btn>
<v-btn color="primary" size="small" prepend-icon="mdi-content-save" :disabled="!jsonValid" :loading="saving" @click="save">Save</v-btn>
</div>
<v-alert v-if="jsonError" type="error" density="compact" class="mb-2">{{ jsonError }}</v-alert>
<v-alert v-if="message" type="success" density="compact" class="mb-2">{{ message }}</v-alert>
<v-alert v-if="error" type="error" density="compact" class="mb-2">{{ error }}</v-alert>
<component :is="editorComponent" ref="editor" v-model="editorContent" :theme="monacoTheme" />
</v-card>
</section>
</template>
<script>
import { defineAsyncComponent } from 'vue';
import { apiRequest, saveBlob } from '../services/api';
function blankForm() {
return {
id: null,
name: '',
jurisdiction: 'US-FED',
version: '',
effective_date: new Date().toISOString().slice(0, 10),
source_note: '',
active: true
};
}
export default {
props: {
token: { type: String, required: true },
dark: { type: Boolean, default: true }
},
emits: ['updated'],
data() {
return {
editorComponent: defineAsyncComponent(() => import('../components/MonacoJsonEditor.vue')),
ruleSets: [],
selected: null,
form: blankForm(),
editorContent: '{\n "jurisdiction": "US-FED",\n "name": "New rule set",\n "version": "1.0",\n "effectiveDate": "2026-01-01",\n "limits": {}\n}',
saving: false,
expanding: false,
error: '',
message: ''
};
},
computed: {
monacoTheme() {
return this.dark ? 'vs-dark' : 'vs';
},
parsed() {
try {
return { value: JSON.parse(this.editorContent), error: '' };
} catch (error) {
return { value: null, error: error.message };
}
},
jsonValid() {
return this.parsed.error === '';
},
jsonError() {
return this.editorContent.trim() ? this.parsed.error : '';
}
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
const data = await (await this.api('/api/tax-rules')).json();
this.ruleSets = data.ruleSets;
if (!this.selected && this.ruleSets.length) this.select(this.ruleSets[0]);
},
select(ruleSet) {
this.message = '';
this.error = '';
this.selected = ruleSet;
this.form = {
id: ruleSet.id,
name: ruleSet.name,
jurisdiction: ruleSet.jurisdiction,
version: ruleSet.version,
effective_date: ruleSet.effective_date,
source_note: ruleSet.source_note || '',
active: ruleSet.active
};
this.editorContent = JSON.stringify(ruleSet.rules_json, null, 2);
},
newDraft() {
this.selected = null;
this.form = blankForm();
this.editorContent = JSON.stringify({
jurisdiction: 'US-FED',
name: 'New rule set',
version: '1.0',
effectiveDate: new Date().toISOString().slice(0, 10),
limits: { section179: { deductionLimit: 0, phaseOutBegins: 0 }, bonusDepreciation: { qualifiedPropertyPercent: 0 } }
}, null, 2);
this.message = '';
this.error = '';
},
onUpload(event) {
const [file] = event.target.files || [];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
this.selected = null;
this.editorContent = String(reader.result || '');
try {
const parsed = JSON.parse(this.editorContent);
this.form = {
id: null,
name: parsed.name || file.name.replace(/\.json$/i, ''),
jurisdiction: parsed.jurisdiction || 'US-FED',
version: parsed.version || '1.0',
effective_date: parsed.effectiveDate || parsed.effective_date || new Date().toISOString().slice(0, 10),
source_note: parsed.sourceNote || '',
active: false
};
this.message = 'File loaded — review and Save to import.';
} catch {
this.message = '';
this.error = 'Uploaded file is not valid JSON; fix it in the editor before saving.';
}
};
reader.readAsText(file);
event.target.value = '';
},
formatJson() {
this.$refs.editor?.format?.();
},
async regenerateMethods() {
if (!this.jsonValid) return;
this.expanding = true;
this.error = '';
try {
const data = await (await this.api('/api/tax-rules/expand', {
method: 'POST',
body: JSON.stringify({ rules_json: this.parsed.value })
})).json();
this.editorContent = JSON.stringify(data.rules_json, null, 2);
this.message = 'Method catalog regenerated (68 methods).';
} catch (error) {
this.error = error.message;
} finally {
this.expanding = false;
}
},
async save() {
if (!this.jsonValid) return;
this.saving = true;
this.error = '';
this.message = '';
try {
const payload = { ...this.form, rules_json: this.parsed.value };
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/tax-rules/${this.form.id}` : '/api/tax-rules';
const data = await (await this.api(url, { method, body: JSON.stringify(payload) })).json();
this.message = 'Rule set saved.';
await this.load();
this.select(this.ruleSets.find((set) => set.id === data.ruleSet.id) || data.ruleSet);
this.$emit('updated');
} catch (error) {
this.error = error.message;
} finally {
this.saving = false;
}
},
async activate() {
if (!this.selected) return;
this.error = '';
try {
const data = await (await this.api(`/api/tax-rules/${this.selected.id}/activate`, { method: 'POST' })).json();
this.ruleSets = data.ruleSets;
this.select(this.ruleSets.find((set) => set.id === this.selected.id));
this.message = 'Rule set activated.';
this.$emit('updated');
} catch (error) {
this.error = error.message;
}
},
async remove() {
if (!this.selected) return;
this.error = '';
try {
await this.api(`/api/tax-rules/${this.selected.id}`, { method: 'DELETE' });
this.selected = null;
this.form = blankForm();
await this.load();
this.$emit('updated');
} catch (error) {
this.error = error.message;
}
},
exportJson() {
const name = `${this.form.jurisdiction || 'rules'}-${this.form.version || 'export'}.json`.replace(/\s+/g, '-');
saveBlob(new Blob([this.editorContent], { type: 'application/json' }), name);
}
}
};
</script>

View File

@@ -1,7 +1,11 @@
<template>
<section class="content-grid">
<v-card class="span-5 panel-pad">
<h2 class="section-title mb-4">Asset template</h2>
<div class="toolbar-row mb-4">
<h2 class="section-title">{{ localForm.id ? 'Edit template' : 'New asset template' }}</h2>
<v-spacer />
<v-btn v-if="localForm.id" variant="text" size="small" prepend-icon="mdi-plus" @click="startNew">New</v-btn>
</div>
<v-text-field v-model="localForm.name" label="Name" />
<v-textarea v-model="localForm.description" label="Description" rows="2" />
<v-select v-model="localForm.defaults.category" :items="categoryItems" label="Category" />
@@ -23,41 +27,115 @@
<v-switch v-model="field.required" label="Required" color="primary" density="compact" hide-details />
<v-btn icon="mdi-delete" variant="text" color="error" @click="localForm.custom_fields.splice(index, 1)" />
</div>
<v-btn color="primary" prepend-icon="mdi-content-save" @click="$emit('save-template', localForm)">Save template</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :disabled="!localForm.name" @click="$emit('save-template', localForm)">
{{ localForm.id ? 'Update template' : 'Save template' }}
</v-btn>
</v-card>
<v-card class="span-7 panel-pad">
<h2 class="section-title mb-4">Templates</h2>
<v-list lines="two">
<v-list-item v-for="template in templates" :key="template.id" prepend-icon="mdi-form-select">
<v-list-item-title>{{ template.name }}</v-list-item-title>
<v-list-item-subtitle>{{ template.description }}</v-list-item-subtitle>
<template #append>
<v-chip size="small" variant="tonal">{{ template.custom_fields.length }} fields</v-chip>
</template>
</v-list-item>
</v-list>
<h2 class="section-title mb-3">Templates</h2>
<DataTable
:columns="columns"
:rows="templateRows"
row-key="id"
:page-size="10"
has-actions
searchable
search-placeholder="Filter templates"
empty-text="No templates yet. Create one on the left."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-description="{ row }">{{ row.description || '—' }}</template>
<template #actions="{ row }">
<v-btn v-if="canDelete" icon="mdi-pencil" size="small" variant="text" @click="editTemplate(row)" />
<v-btn v-if="canDelete" icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
</template>
</DataTable>
</v-card>
<v-dialog v-model="deleteDialog" max-width="520">
<v-card>
<v-card-title class="text-error">Delete template</v-card-title>
<v-card-text>
<p class="mb-3">Delete the template <strong>{{ deleteTarget?.name }}</strong>?</p>
<v-alert
:type="deleteTarget && deleteTarget.asset_count ? 'warning' : 'info'"
variant="tonal"
density="comfortable"
class="mb-3"
>
<template v-if="deleteTarget && deleteTarget.asset_count">
This template is currently assigned to <strong>{{ deleteTarget.asset_count }}</strong>
asset{{ deleteTarget.asset_count === 1 ? '' : 's' }}.
</template>
<template v-else>
This template is not assigned to any assets.
</template>
</v-alert>
<p class="mb-2">When you delete it:</p>
<ul class="delete-impact">
<li>Assigned assets <strong>keep all their data and custom-field values</strong> nothing is removed from them.</li>
<li>Those assets are simply <strong>unlinked</strong> from this template.</li>
<li>The template will <strong>no longer be available</strong> when creating new assets.</li>
</ul>
<p class="quiet text-body-2 mt-3">This action cannot be undone.</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete template</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
import DataTable from '../components/DataTable.vue';
import { slugFieldKey } from '../utils/assets';
import { clonePlain } from '../utils/clone';
function fieldId() {
return crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random());
}
export default {
components: { DataTable },
props: {
categoryItems: { type: Array, required: true },
customFieldTypes: { type: Array, required: true },
methodItems: { type: Array, required: true },
templateForm: { type: Object, required: true },
templates: { type: Array, required: true }
templates: { type: Array, required: true },
canDelete: { type: Boolean, default: false }
},
emits: ['save-template'],
emits: ['save-template', 'delete-template'],
data() {
return {
localForm: clonePlain(this.templateForm)
localForm: clonePlain(this.templateForm),
deleteDialog: false,
deleteTarget: null,
columns: [
{ key: 'name', label: 'Template' },
{ key: 'description', label: 'Description' },
{ key: 'fields_count', label: 'Fields', format: 'number' },
{ key: 'asset_count', label: 'Assets', format: 'number' }
]
};
},
computed: {
templateRows() {
return this.templates.map((template) => ({
id: template.id,
name: template.name,
description: template.description,
fields_count: (template.custom_fields || []).length,
asset_count: template.asset_count || 0
}));
}
},
watch: {
templateForm: {
handler(value) {
@@ -69,7 +147,7 @@ export default {
methods: {
addField() {
this.localForm.custom_fields.push({
local_id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
local_id: fieldId(),
key: '',
label: '',
type: 'text',
@@ -79,7 +157,59 @@ export default {
},
syncFieldKey(field) {
if (!field.key) field.key = slugFieldKey(field.label);
},
startNew() {
this.localForm = {
id: null,
name: '',
description: '',
defaults: { category: 'Computer Equipment', useful_life_months: 60, depreciation_method: 'straight_line' },
custom_fields: []
};
},
editTemplate(row) {
const template = this.templates.find((item) => item.id === row.id);
if (!template) return;
this.localForm = {
id: template.id,
name: template.name,
description: template.description || '',
defaults: {
category: 'Computer Equipment',
useful_life_months: 60,
depreciation_method: 'straight_line',
...(template.defaults || {})
},
custom_fields: (template.custom_fields || []).map((f) => ({
local_id: fieldId(),
key: f.key || '',
label: f.label || '',
type: f.type || 'text',
required: Boolean(f.required),
defaultValue: f.defaultValue ?? f.default ?? ''
}))
};
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
},
confirmDelete(row) {
this.deleteTarget = row;
this.deleteDialog = true;
},
performDelete() {
if (this.deleteTarget) this.$emit('delete-template', this.deleteTarget.id);
this.deleteDialog = false;
this.deleteTarget = null;
}
}
};
</script>
<style scoped>
.delete-impact {
padding-left: 20px;
line-height: 1.6;
}
.delete-impact li {
margin-bottom: 4px;
}
</style>