This commit is contained in:
2026-06-03 13:58:12 -05:00
commit 2f13b8c590
54 changed files with 5136 additions and 0 deletions

657
src/App.vue Normal file
View File

@@ -0,0 +1,657 @@
<template>
<fluent-design-system-provider accent-base-color="#2457a6" :base-layer-luminance="fluentLuminance">
<v-app :theme="activeTheme">
<LoginView v-if="!token" :error="error" :form="loginForm" :loading="loading" @login="login" />
<div v-else class="app-shell">
<v-navigation-drawer class="rail" width="254" permanent>
<div class="brand-lockup">
<span class="brand-mark">MA</span>
<span class="brand-name">MixedAssets</span>
</div>
<v-divider />
<v-list nav density="compact" class="pa-3">
<v-list-item
v-for="item in visibleNavItems"
:key="item.key"
:active="activeView === item.key"
:prepend-icon="item.icon"
:title="item.label"
rounded="sm"
@click="activeView = item.key"
/>
</v-list>
<template #append>
<div class="pa-4">
<div class="text-body-2 font-weight-bold">{{ user?.name }}</div>
<div class="text-caption quiet">{{ user?.role }}</div>
<v-btn class="mt-3" block variant="tonal" prepend-icon="mdi-logout" @click="logout">Sign out</v-btn>
</div>
</template>
</v-navigation-drawer>
<TopNav
:preferences="profilePreferences"
:saving="preferenceSaving"
:subtitle="currentSubtitle"
:theme-items="themeItems"
:title="currentTitle"
:user="user"
@logout="logout"
@save-preferences="saveProfilePreferences"
>
<template #actions>
<v-btn v-if="activeView === 'assets' && canEditAssets" color="primary" prepend-icon="mdi-plus" @click="newAsset">New asset</v-btn>
<v-btn v-if="activeView === 'reports'" color="secondary" prepend-icon="mdi-file-pdf-box" @click="downloadPdf">PDF</v-btn>
</template>
</TopNav>
<v-main>
<DashboardView
v-if="activeView === 'dashboard'"
:category-chart="categoryChart"
:chart-options="chartOptions"
:currency="currency"
:dashboard="dashboard"
:short-date="shortDate"
/>
<AssetsView
v-if="activeView === 'assets'"
:all-selected="allSelected"
:assets="assets"
:asset-filters="assetFilters"
:currency="currency"
:selected-asset-ids="selectedAssetIds"
:status-items="statusItems"
@edit-asset="editAsset"
@export-assets="exportAssets"
@filter-assets="filterAssets"
@import-assets="importAssets"
@open-mass-edit="massDialog = true"
@toggle-all="toggleAll"
@toggle-asset="toggleAsset"
/>
<AssignmentsView
v-if="activeView === 'assignments'"
:assignments="assignments"
:assets="assets"
:assignment-saving="assignmentSaving"
:employees="employees"
:short-date="shortDate"
:summary="assignmentSummary"
@add-employee="addEmployee"
@assign-asset="assignAsset"
@release-assignment="releaseAssignment"
@reload="loadAssignments"
/>
<ReportsView
v-if="activeView === 'reports'"
:book-items="bookItems"
:chart-options="chartOptions"
:currency="currency"
:depreciation-report="depreciationReport"
:monthly-chart="monthlyChart"
:report-filters="reportFilters"
@filter-reports="filterReports"
/>
<TemplatesView
v-if="activeView === 'templates'"
:category-items="categoryItems"
:custom-field-types="customFieldTypes"
:method-items="methodItems"
:template-form="templateForm"
:templates="templates"
@save-template="saveTemplate"
/>
<AdminView
v-if="activeView === 'admin'"
:role-capabilities="roleCapabilities"
:role-permissions="rolePermissions"
:roles="roles"
:short-date="shortDate"
:settings="settings"
:tax-rules="taxRules"
:team-saving="teamSaving"
:teams="teams"
:user-saving="userSaving"
:users="users"
:workday-connection="workdayConnection"
:workday-saving="workdaySaving"
:workday-syncing="workdaySyncing"
@create-team="createTeam"
@create-user="createUser"
@save-settings="saveSettings"
@save-workday="saveWorkdayConnection"
@sync-workday="syncWorkday"
@update-user="updateUser"
/>
</v-main>
<AssetDrawer
v-model="assetDrawer"
:asset-form="assetForm"
:category-items="categoryItems"
:drawer-error="drawerError"
:entity-options="entityOptions"
:method-items="methodItems"
:saving="saving"
:selected-template-fields="selectedTemplateFields"
:selected-template-id="selectedTemplateId"
:status-items="statusItems"
:template-options="templateOptions"
@save-asset="saveAsset"
@select-template="applyTemplate"
/>
<MassEditDialog
v-model="massDialog"
:changes="massChanges"
:status-items="statusItems"
@apply="massUpdate"
/>
</div>
</v-app>
</fluent-design-system-provider>
</template>
<script>
import AdminView from './views/AdminView.vue';
import AssignmentsView from './views/AssignmentsView.vue';
import AssetsView from './views/AssetsView.vue';
import DashboardView from './views/DashboardView.vue';
import LoginView from './views/LoginView.vue';
import ReportsView from './views/ReportsView.vue';
import TemplatesView from './views/TemplatesView.vue';
import AssetDrawer from './components/AssetDrawer.vue';
import MassEditDialog from './components/MassEditDialog.vue';
import TopNav from './components/TopNav.vue';
import { apiRequest, loginRequest, saveBlob } from './services/api';
import { blankAsset, defaultValueForField, normalizeCustomFields } from './utils/assets';
import { currency, shortDate } from './utils/format';
import { bookItems, customFieldTypes, navItems, statusItems, themeItems } from './constants';
const emptyStats = {
asset_count: 0,
total_cost: 0,
disposed_count: 0,
in_service_count: 0
};
export default {
components: {
AdminView,
AssignmentsView,
AssetDrawer,
AssetsView,
DashboardView,
LoginView,
MassEditDialog,
ReportsView,
TemplatesView,
TopNav
},
data() {
return {
activeView: 'dashboard',
assets: [],
assetDrawer: false,
assetFilters: { search: '', status: null },
assetForm: blankAsset(),
assignments: [],
assignmentSaving: false,
assignmentSummary: {},
bookItems,
chartOptions: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { callback: (value) => `$${Number(value).toLocaleString()}` } } }
},
customFieldTypes,
dashboard: { stats: { ...emptyStats }, byCategory: [], byStatus: [], upcomingTasks: [], auditTrail: [] },
depreciationReport: { totals: { depreciation: 0 }, rows: [] },
drawerError: '',
employees: [],
entities: [],
error: '',
loading: false,
loginForm: { email: 'admin@mixedassets.local', password: 'ChangeMe123!' },
massChanges: { status: null, location: '', department: '' },
massDialog: false,
monthlyReport: { months: [] },
navItems,
preferenceSaving: false,
profilePreferences: { theme: localStorage.getItem('mixedassets.theme') || 'mixedAssetsDark' },
references: [],
reportFilters: { book: 'GAAP', year: new Date().getFullYear() },
roleCapabilities: [],
rolePermissions: {},
roles: [],
saving: false,
selectedAssetIds: [],
selectedTemplateId: null,
settings: {},
statusItems,
taxRules: [],
teamSaving: false,
teams: [],
templateForm: {
name: '',
description: '',
defaults: { category: 'Computer Equipment', useful_life_months: 60, depreciation_method: 'straight_line' },
custom_fields: []
},
templates: [],
themeItems,
token: localStorage.getItem('mixedassets.token') || '',
user: JSON.parse(localStorage.getItem('mixedassets.user') || 'null'),
userSaving: false,
users: [],
workdayConnection: {},
workdaySaving: false,
workdaySyncing: false
};
},
computed: {
allSelected() {
return this.assets.length > 0 && this.selectedAssetIds.length === this.assets.length;
},
categoryChart() {
return {
labels: this.dashboard.byCategory.map((row) => row.category),
datasets: [{ data: this.dashboard.byCategory.map((row) => row.value), backgroundColor: '#2457a6' }]
};
},
categoryItems() {
const fromRefs = this.references.filter((item) => item.type === 'category').map((item) => item.name);
return [...new Set([...fromRefs, 'Computer Equipment', 'Office Furniture', 'Vehicles', 'Leasehold Improvements', 'Uncategorized'])];
},
canEditAssets() {
return ['admin', 'finance', 'operations'].includes(this.user?.role);
},
currentSubtitle() {
return {
dashboard: 'Portfolio value, lifecycle activity, and open work',
assets: 'Asset register, imports, exports, and lifecycle updates',
assignments: 'Assign assets to people, departments, and locations with full custody history',
reports: 'Depreciation schedules, monthly expense, and book values',
templates: 'JSON-driven entry defaults and custom fields',
admin: 'Users, database, CORS, and tax-rule configuration'
}[this.activeView];
},
currentTitle() {
return {
dashboard: 'Dashboard',
assets: 'Asset register',
assignments: 'Assignments',
reports: 'Reports',
templates: 'Templates',
admin: 'Configuration'
}[this.activeView];
},
entityOptions() {
return this.entities.map((entity) => ({ title: entity.name, value: entity.id }));
},
activeTheme() {
return this.profilePreferences.theme || 'mixedAssetsDark';
},
fluentLuminance() {
return this.activeTheme === 'mixedAssetsDark' ? '0.12' : '1';
},
methodItems() {
const methods = this.taxRules[0]?.rules_json?.methods || [];
const fallback = [
{ code: 'straight_line', family: 'GAAP', label: 'Straight-line' },
{ code: 'sum_of_years_digits', family: 'GAAP', label: 'Sum-of-years-digits' },
{ code: 'declining_balance_200', family: 'GAAP', label: '200% declining balance' },
{ code: 'manual', family: 'Manual', label: 'Manual depreciation entry' }
];
return (methods.length ? methods : fallback).map((method) => ({
title: `${method.family} · ${method.label}`,
value: method.code
}));
},
monthlyChart() {
return {
labels: this.monthlyReport.months.map((row) => `M${row.month}`),
datasets: [{ data: this.monthlyReport.months.map((row) => row.depreciation), backgroundColor: '#006c6a' }]
};
},
selectedTemplate() {
return this.templates.find((template) => template.id === this.selectedTemplateId) || null;
},
selectedTemplateFields() {
return normalizeCustomFields(this.selectedTemplate?.custom_fields || []);
},
templateOptions() {
return this.templates.map((template) => ({ title: template.name, value: template.id }));
},
visibleNavItems() {
const allowedByRole = {
admin: ['dashboard', 'assets', 'assignments', 'reports', 'templates', 'admin'],
finance: ['dashboard', 'assets', 'assignments', 'reports', 'templates'],
operations: ['dashboard', 'assets', 'assignments'],
viewer: ['dashboard', 'assets', 'assignments', 'reports', 'templates']
};
const allowed = allowedByRole[this.user?.role] || allowedByRole.viewer;
return this.navItems.filter((item) => allowed.includes(item.key));
}
},
watch: {
activeView(view) {
if (view === 'reports') this.loadReports();
if (view === 'admin') this.loadAdmin();
if (view === 'assignments') this.loadAssignments();
},
visibleNavItems(items) {
if (!items.some((item) => item.key === this.activeView)) {
this.activeView = items[0]?.key || 'dashboard';
}
}
},
mounted() {
if (this.token) this.loadInitial();
},
methods: {
currency,
shortDate,
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async addEmployee(employee) {
await this.api('/api/employees', { method: 'POST', body: JSON.stringify(employee) });
await this.loadAssignments();
},
applyTemplate(id) {
this.selectedTemplateId = id || null;
const template = this.templates.find((item) => item.id === id);
if (!template) {
this.assetForm = { ...this.assetForm, template_id: null };
return;
}
const customFields = { ...(this.assetForm.custom_fields || {}) };
for (const field of normalizeCustomFields(template.custom_fields)) {
if (customFields[field.key] === undefined) customFields[field.key] = defaultValueForField(field);
}
this.assetForm = { ...this.assetForm, ...template.defaults, custom_fields: customFields, template_id: template.id };
},
async assignAsset(form) {
this.assignmentSaving = true;
try {
await this.api('/api/assignments', { method: 'POST', body: JSON.stringify(form) });
await Promise.all([this.loadAssignments(), this.loadAssets(), this.loadDashboard()]);
} finally {
this.assignmentSaving = false;
}
},
async createTeam(team) {
this.teamSaving = true;
try {
await this.api('/api/teams', { method: 'POST', body: JSON.stringify(team) });
await this.loadAdmin();
} finally {
this.teamSaving = false;
}
},
async createUser(user) {
this.userSaving = true;
try {
await this.api('/api/users', { method: 'POST', body: JSON.stringify(user) });
await this.loadAdmin();
} finally {
this.userSaving = false;
}
},
async downloadPdf() {
const blob = await (await this.api(`/api/reports/depreciation.pdf?year=${this.reportFilters.year}&book=${this.reportFilters.book}`)).blob();
saveBlob(blob, `mixedassets-${this.reportFilters.book}-${this.reportFilters.year}.pdf`);
},
editAsset(asset) {
this.assetForm = { ...blankAsset(), ...asset, custom_fields: asset.custom_fields || {} };
this.selectedTemplateId = asset.template_id || null;
this.assetDrawer = true;
},
async exportAssets(format) {
const blob = await (await this.api(`/api/export/assets?format=${format}`)).blob();
saveBlob(blob, `mixedassets-assets.${format}`);
},
filterAssets(filters) {
this.assetFilters = filters;
this.loadAssets();
},
filterReports(filters) {
this.reportFilters = filters;
this.loadReports();
},
async importAssets(event) {
const [file] = event.target.files || [];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
await this.api('/api/import/assets', { method: 'POST', body: formData });
event.target.value = '';
await this.loadAssets();
await this.loadDashboard();
},
async loadAdmin() {
const [settings, rules, workday, users, teams, accessControl] = await Promise.all([
this.api('/api/settings').then((r) => r.json()).catch(() => ({ settings: {} })),
this.api('/api/tax-rules').then((r) => r.json()),
this.api('/api/workday/connection').then((r) => r.json()).catch(() => ({ connection: {} })),
this.api('/api/users').then((r) => r.json()).catch(() => ({ users: [] })),
this.api('/api/teams').then((r) => r.json()).catch(() => ({ teams: [] })),
this.api('/api/access-control').then((r) => r.json()).catch(() => ({
roles: [],
rolePermissions: {},
roleCapabilities: []
}))
]);
this.settings = settings.settings;
this.taxRules = rules.ruleSets;
this.workdayConnection = workday.connection || {};
this.users = users.users || [];
this.teams = teams.teams || [];
this.roles = accessControl.roles || [];
this.rolePermissions = accessControl.rolePermissions || {};
this.roleCapabilities = accessControl.roleCapabilities || [];
},
async loadAssets() {
const params = new URLSearchParams();
if (this.assetFilters.search) params.set('search', this.assetFilters.search);
if (this.assetFilters.status) params.set('status', this.assetFilters.status);
const data = await (await this.api(`/api/assets?${params.toString()}`)).json();
this.assets = data.assets;
this.selectedAssetIds = this.selectedAssetIds.filter((id) => this.assets.some((asset) => asset.id === id));
},
async loadDashboard() {
this.dashboard = await (await this.api('/api/dashboard')).json();
},
async loadInitial() {
await Promise.all([
this.loadProfile(),
this.loadDashboard(),
this.loadAssets(),
this.loadAssignments(),
this.api('/api/entities').then((r) => r.json()).then((data) => { this.entities = data.entities; }),
this.api('/api/references').then((r) => r.json()).then((data) => { this.references = data.references; }),
this.api('/api/templates').then((r) => r.json()).then((data) => { this.templates = data.templates; }),
this.api('/api/tax-rules').then((r) => r.json()).then((data) => { this.taxRules = data.ruleSets; })
]);
},
async loadAssignments() {
const [employees, assignments, workday] = await Promise.all([
this.api('/api/employees').then((r) => r.json()),
this.api('/api/assignments').then((r) => r.json()),
this.api('/api/workday/connection').then((r) => r.json()).catch(() => ({ connection: {} }))
]);
this.employees = employees.employees;
this.assignments = assignments.assignments;
this.assignmentSummary = assignments.summary;
this.workdayConnection = workday.connection || {};
},
async loadProfile() {
const profile = await (await this.api('/api/profile')).json();
this.user = profile.user;
this.profilePreferences = { theme: 'mixedAssetsDark', ...(profile.preferences || {}) };
localStorage.setItem('mixedassets.user', JSON.stringify(profile.user));
localStorage.setItem('mixedassets.theme', this.activeTheme);
},
async loadReports() {
const query = `year=${this.reportFilters.year}&book=${this.reportFilters.book}`;
const [annual, monthly] = await Promise.all([
this.api(`/api/reports/depreciation?${query}`).then((r) => r.json()),
this.api(`/api/reports/monthly-depreciation?${query}`).then((r) => r.json())
]);
this.depreciationReport = annual;
this.monthlyReport = monthly;
},
async login(form) {
this.loading = true;
this.error = '';
try {
const data = await loginRequest(form);
this.loginForm = { ...form };
this.token = data.token;
this.user = data.user;
localStorage.setItem('mixedassets.token', data.token);
localStorage.setItem('mixedassets.user', JSON.stringify(data.user));
await this.loadInitial();
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
},
logout() {
this.token = '';
this.user = null;
localStorage.removeItem('mixedassets.token');
localStorage.removeItem('mixedassets.user');
},
async massUpdate(changesInput) {
const changes = Object.fromEntries(Object.entries(changesInput).filter(([, value]) => value !== '' && value !== null));
await this.api('/api/assets/mass-update', { method: 'POST', body: JSON.stringify({ ids: this.selectedAssetIds, changes }) });
this.massDialog = false;
this.massChanges = { status: null, location: '', department: '' };
await this.loadAssets();
await this.loadDashboard();
},
newAsset() {
this.assetForm = blankAsset();
this.assetForm.entity_id = this.entities[0]?.id || null;
this.selectedTemplateId = null;
this.drawerError = '';
this.assetDrawer = true;
},
async saveAsset(assetInput) {
this.saving = true;
this.drawerError = '';
try {
const payload = {
...assetInput,
books: this.bookItems.map((book) => ({
book_type: book,
depreciation_method: assetInput.depreciation_method,
useful_life_months: assetInput.useful_life_months,
cost: assetInput.acquisition_cost
}))
};
const method = assetInput.id ? 'PUT' : 'POST';
const url = assetInput.id ? `/api/assets/${assetInput.id}` : '/api/assets';
await this.api(url, { method, body: JSON.stringify(payload) });
this.assetDrawer = false;
await this.loadAssets();
await this.loadDashboard();
} catch (error) {
this.drawerError = error.message;
} finally {
this.saving = false;
}
},
async saveSettings(settings) {
await this.api('/api/settings', { method: 'PUT', body: JSON.stringify({ settings }) });
await this.loadAdmin();
},
async releaseAssignment(assignment) {
await this.api(`/api/assignments/${assignment.id}/release`, {
method: 'PUT',
body: JSON.stringify({ release_reason: 'Released from UI' })
});
await Promise.all([this.loadAssignments(), this.loadAssets()]);
},
async saveProfilePreferences(preferences, options = { persist: true }) {
this.profilePreferences = { ...this.profilePreferences, ...(preferences || {}) };
localStorage.setItem('mixedassets.theme', this.activeTheme);
if (!options.persist) return;
this.preferenceSaving = true;
try {
const profile = await (await this.api('/api/profile', {
method: 'PUT',
body: JSON.stringify({ preferences: this.profilePreferences })
})).json();
this.user = profile.user;
this.profilePreferences = { theme: 'mixedAssetsDark', ...(profile.preferences || {}) };
localStorage.setItem('mixedassets.user', JSON.stringify(profile.user));
localStorage.setItem('mixedassets.theme', this.activeTheme);
} finally {
this.preferenceSaving = false;
}
},
async saveWorkdayConnection(connection) {
this.workdaySaving = true;
try {
const body = { ...connection };
if (!body.client_secret) delete body.client_secret;
await this.api('/api/workday/connection', { method: 'PUT', body: JSON.stringify(body) });
await this.loadAssignments();
} finally {
this.workdaySaving = false;
}
},
async saveTemplate(form) {
const payload = {
...form,
custom_fields: normalizeCustomFields(form.custom_fields)
};
await this.api('/api/templates', { method: 'POST', body: JSON.stringify(payload) });
const data = await (await this.api('/api/templates')).json();
this.templates = data.templates;
this.templateForm = {
name: '',
description: '',
defaults: { category: 'Computer Equipment', useful_life_months: 60, depreciation_method: 'straight_line' },
custom_fields: []
};
},
async syncWorkday() {
this.workdaySyncing = true;
try {
await this.api('/api/workday/sync-workers', { method: 'POST', body: JSON.stringify({}) });
await this.loadAssignments();
} finally {
this.workdaySyncing = false;
}
},
async updateUser(user) {
this.userSaving = true;
try {
const body = { ...user };
if (!body.password) delete body.password;
await this.api(`/api/users/${user.id}`, { method: 'PUT', body: JSON.stringify(body) });
await this.loadAdmin();
} finally {
this.userSaving = false;
}
},
toggleAll(value) {
this.selectedAssetIds = value ? this.assets.map((asset) => asset.id) : [];
},
toggleAsset(id) {
this.selectedAssetIds = this.selectedAssetIds.includes(id)
? this.selectedAssetIds.filter((assetId) => assetId !== id)
: [...this.selectedAssetIds, id];
}
}
};
</script>

View File

@@ -0,0 +1,102 @@
<template>
<v-navigation-drawer :model-value="modelValue" location="right" temporary width="520" @update:model-value="$emit('update:modelValue', $event)">
<div class="page-band">
<h2 class="text-h6 font-weight-bold">{{ assetForm.id ? 'Edit asset' : 'New asset' }}</h2>
</div>
<div class="pa-4">
<v-select :model-value="selectedTemplateId" :items="templateOptions" label="Template" clearable @update:model-value="$emit('select-template', $event)" />
<div class="form-grid">
<v-text-field v-model="localAsset.asset_id" label="Asset ID" />
<v-select v-model="localAsset.entity_id" :items="entityOptions" label="Entity" />
<v-text-field v-model="localAsset.description" class="full" label="Description" />
<v-select v-model="localAsset.category" :items="categoryItems" label="Category" />
<v-select v-model="localAsset.status" :items="statusItems" label="Status" />
<v-text-field v-model.number="localAsset.acquisition_cost" type="number" label="Acquisition cost" />
<v-text-field v-model.number="localAsset.salvage_value" type="number" label="Salvage value" />
<v-text-field v-model="localAsset.acquired_date" type="date" label="Acquired date" />
<v-text-field v-model="localAsset.in_service_date" type="date" label="In-service date" />
<v-text-field v-model.number="localAsset.useful_life_months" type="number" label="Useful life months" />
<v-select v-model="localAsset.depreciation_method" :items="methodItems" item-title="title" item-value="value" label="Method" />
<v-text-field v-model="localAsset.location" label="Location" />
<v-text-field v-model="localAsset.department" label="Department" />
<v-text-field v-model="localAsset.custodian" label="Custodian" />
<v-text-field v-model="localAsset.vendor" label="Vendor" />
<v-text-field v-model="localAsset.serial_number" label="Serial number" />
<v-text-field v-model="localAsset.invoice_number" label="Invoice number" />
<v-text-field v-model="localAsset.gl_asset_account" label="Asset GL" />
<v-text-field v-model="localAsset.gl_expense_account" label="Expense GL" />
<v-textarea v-model="localAsset.notes" class="full" label="Notes" rows="2" />
</div>
<template v-if="selectedTemplateFields.length">
<v-divider class="my-4" />
<h3 class="section-title mb-3">Template fields</h3>
<div class="form-grid">
<template v-for="field in selectedTemplateFields" :key="field.key">
<v-switch
v-if="field.type === 'bool'"
v-model="localAsset.custom_fields[field.key]"
:label="field.label"
color="primary"
density="compact"
hide-details
/>
<v-text-field
v-else
v-model="localAsset.custom_fields[field.key]"
:label="field.label"
:type="inputTypeForCustomField(field)"
:rules="field.required ? [requiredRule] : []"
/>
</template>
</div>
</template>
<v-alert v-if="drawerError" class="mt-4" density="compact" type="error">{{ drawerError }}</v-alert>
<div class="toolbar-row mt-4">
<v-spacer />
<v-btn variant="text" @click="$emit('update:modelValue', false)">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="$emit('save-asset', localAsset)">Save asset</v-btn>
</div>
</div>
</v-navigation-drawer>
</template>
<script>
import { inputTypeForCustomField } from '../utils/assets';
import { clonePlain } from '../utils/clone';
export default {
props: {
assetForm: { type: Object, required: true },
categoryItems: { type: Array, required: true },
drawerError: { type: String, default: '' },
entityOptions: { type: Array, required: true },
methodItems: { type: Array, required: true },
modelValue: { type: Boolean, default: false },
saving: { type: Boolean, default: false },
selectedTemplateFields: { type: Array, required: true },
selectedTemplateId: { type: [Number, String], default: null },
statusItems: { type: Array, required: true },
templateOptions: { type: Array, required: true }
},
emits: ['save-asset', 'select-template', 'update:modelValue'],
data() {
return {
localAsset: clonePlain(this.assetForm),
requiredRule: (value) => value !== null && value !== undefined && value !== '' || 'Required'
};
},
watch: {
assetForm: {
handler(value) {
this.localAsset = clonePlain(value);
},
deep: true
}
},
methods: {
inputTypeForCustomField
}
};
</script>

View File

@@ -0,0 +1,41 @@
<template>
<v-dialog :model-value="modelValue" max-width="460" @update:model-value="$emit('update:modelValue', $event)">
<v-card>
<v-card-title>Mass edit</v-card-title>
<v-card-text>
<v-select v-model="localChanges.status" :items="statusItems" clearable label="Status" />
<v-text-field v-model="localChanges.location" label="Location" />
<v-text-field v-model="localChanges.department" label="Department" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="$emit('update:modelValue', false)">Cancel</v-btn>
<v-btn color="primary" @click="$emit('apply', localChanges)">Apply</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
export default {
props: {
changes: { type: Object, required: true },
modelValue: { type: Boolean, default: false },
statusItems: { type: Array, required: true }
},
emits: ['apply', 'update:modelValue'],
data() {
return {
localChanges: { ...this.changes }
};
},
watch: {
changes: {
handler(value) {
this.localChanges = { ...value };
},
deep: true
}
}
};
</script>

99
src/components/TopNav.vue Normal file
View File

@@ -0,0 +1,99 @@
<template>
<v-app-bar class="top-nav" density="compact" flat height="64">
<div class="top-nav-title">
<div class="text-subtitle-2 quiet">{{ subtitle }}</div>
<div class="text-h6 font-weight-bold">{{ title }}</div>
</div>
<v-spacer />
<slot name="actions" />
<v-menu v-model="profileMenu" :close-on-content-click="false" location="bottom end">
<template #activator="{ props }">
<v-btn v-bind="props" class="profile-trigger" variant="text">
<v-avatar color="primary" size="32">
<span class="text-caption font-weight-bold">{{ initials }}</span>
</v-avatar>
<span class="profile-name">{{ user?.name }}</span>
<v-icon icon="mdi-chevron-down" size="18" />
</v-btn>
</template>
<v-card width="340">
<v-card-text>
<div class="d-flex align-center ga-3 mb-4">
<v-avatar color="primary" size="42">
<span class="font-weight-bold">{{ initials }}</span>
</v-avatar>
<div>
<div class="font-weight-bold">{{ user?.name }}</div>
<div class="text-caption quiet">{{ user?.email }} · {{ user?.role }}</div>
</div>
</div>
<v-select
v-model="localPreferences.theme"
:items="themeItems"
item-title="title"
item-value="value"
label="Theme"
prepend-inner-icon="mdi-theme-light-dark"
/>
<v-alert v-if="status" class="mt-2" density="compact" type="success">{{ status }}</v-alert>
</v-card-text>
<v-card-actions>
<v-btn variant="text" prepend-icon="mdi-logout" @click="$emit('logout')">Sign out</v-btn>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save</v-btn>
</v-card-actions>
</v-card>
</v-menu>
</v-app-bar>
</template>
<script>
export default {
props: {
preferences: { type: Object, required: true },
saving: { type: Boolean, default: false },
subtitle: { type: String, required: true },
themeItems: { type: Array, required: true },
title: { type: String, required: true },
user: { type: Object, required: true }
},
emits: ['logout', 'save-preferences'],
data() {
return {
localPreferences: { ...this.preferences },
profileMenu: false,
status: ''
};
},
computed: {
initials() {
return String(this.user?.name || 'MA')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('');
}
},
watch: {
preferences: {
handler(value) {
this.localPreferences = { ...value };
},
deep: true
},
'localPreferences.theme'(theme) {
this.$emit('save-preferences', { ...this.localPreferences, theme }, { persist: false });
}
},
methods: {
async save() {
await this.$emit('save-preferences', { ...this.localPreferences }, { persist: true });
this.status = 'Preferences saved';
window.setTimeout(() => {
this.status = '';
}, 1800);
}
}
};
</script>

24
src/constants.js Normal file
View File

@@ -0,0 +1,24 @@
export const bookItems = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
export const statusItems = ['in_service', 'cip', 'reserved', 'checked_out', 'disposed', 'retired'];
export const customFieldTypes = [
{ title: 'Text', value: 'text' },
{ title: 'Number', value: 'numeric' },
{ title: 'Date', value: 'date' },
{ title: 'Checkbox', value: 'bool' }
];
export const themeItems = [
{ title: 'Dark', value: 'mixedAssetsDark' },
{ title: 'Light', value: 'mixedAssets' }
];
export const navItems = [
{ key: 'dashboard', label: 'Dashboard', icon: 'mdi-view-dashboard-outline' },
{ key: 'assets', label: 'Assets', icon: 'mdi-archive-search-outline' },
{ key: 'assignments', label: 'Assignments', icon: 'mdi-account-switch-outline' },
{ key: 'reports', label: 'Reports', icon: 'mdi-chart-bar' },
{ key: 'templates', label: 'Templates', icon: 'mdi-form-select' },
{ key: 'admin', label: 'Admin', icon: 'mdi-cog-outline' }
];

59
src/main.js Normal file
View File

@@ -0,0 +1,59 @@
import { createApp } from 'vue';
import '@mdi/font/css/materialdesignicons.css';
import 'vuetify/styles';
import './styles/app.css';
import { allComponents, provideFluentDesignSystem } from '@fluentui/web-components';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import App from './App.vue';
provideFluentDesignSystem().register(allComponents);
const vuetify = createVuetify({
components,
directives,
theme: {
defaultTheme: 'mixedAssetsDark',
themes: {
mixedAssets: {
dark: false,
colors: {
background: '#f7f8fb',
surface: '#ffffff',
primary: '#2457a6',
secondary: '#006c6a',
accent: '#8f4f00',
info: '#3f6f99',
success: '#2f7d4b',
warning: '#a56a00',
error: '#b3261e'
}
},
mixedAssetsDark: {
dark: true,
colors: {
background: '#11161d',
surface: '#171d25',
primary: '#8fb8ff',
secondary: '#54d3c7',
accent: '#f2b36b',
info: '#9cc8e8',
success: '#8fd7a3',
warning: '#f0c06a',
error: '#ffb4ab'
}
}
}
},
defaults: {
VBtn: { rounded: 'sm', style: 'text-transform:none; letter-spacing:0' },
VCard: { rounded: 'sm' },
VTextField: { variant: 'outlined', density: 'compact' },
VSelect: { variant: 'outlined', density: 'compact' },
VTextarea: { variant: 'outlined', density: 'compact' },
VDataTable: { density: 'comfortable' }
}
});
createApp(App).use(vuetify).mount('#app');

35
src/services/api.js Normal file
View File

@@ -0,0 +1,35 @@
export async function apiRequest(path, token, options = {}) {
const response = await fetch(path, {
...options,
headers: {
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers || {})
}
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${response.status}`);
}
return response;
}
export async function loginRequest(credentials) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Unable to sign in');
return data;
}
export function saveBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}

342
src/styles/app.css Normal file
View File

@@ -0,0 +1,342 @@
:root {
color: #17202c;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
background: #11161d;
}
.app-shell {
min-height: 100vh;
}
.rail {
border-right: 1px solid #dde3ec;
}
.brand-lockup {
align-items: center;
display: flex;
gap: 10px;
min-height: 56px;
padding: 0 18px;
}
.brand-mark {
align-items: center;
background: #2457a6;
border-radius: 6px;
color: #fff;
display: inline-flex;
font-weight: 800;
height: 34px;
justify-content: center;
width: 34px;
}
.brand-name {
font-size: 1.05rem;
font-weight: 750;
letter-spacing: 0;
}
.page-band {
border-bottom: 1px solid #dde3ec;
padding: 22px 24px 18px;
}
.top-nav {
border-bottom: 1px solid rgba(120, 138, 163, 0.24);
padding: 0 18px;
}
.top-nav-title {
min-width: 0;
}
.profile-trigger {
min-width: 0;
}
.profile-name {
display: inline-block;
font-weight: 700;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.content-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(12, minmax(0, 1fr));
padding: 18px 24px 28px;
}
.span-3 {
grid-column: span 3;
}
.span-4 {
grid-column: span 4;
}
.span-5 {
grid-column: span 5;
}
.span-6 {
grid-column: span 6;
}
.span-7 {
grid-column: span 7;
}
.span-8 {
grid-column: span 8;
}
.span-12 {
grid-column: span 12;
}
.metric {
min-height: 116px;
padding: 18px;
}
.metric-label {
color: #617085;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
.metric-value {
font-size: clamp(1.45rem, 2.4vw, 2.15rem);
font-weight: 800;
line-height: 1.1;
margin-top: 12px;
}
.metric-subtle {
color: #617085;
font-size: 0.86rem;
margin-top: 8px;
}
.toolbar-row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.section-title {
font-size: 1rem;
font-weight: 800;
margin: 0;
}
.quiet {
color: #617085;
}
.asset-table {
border-collapse: collapse;
width: 100%;
}
.asset-table th,
.asset-table td {
border-bottom: 1px solid #e7ebf1;
font-size: 0.88rem;
padding: 10px 12px;
text-align: left;
vertical-align: middle;
}
.asset-table th {
color: #617085;
font-size: 0.74rem;
font-weight: 800;
text-transform: uppercase;
white-space: nowrap;
}
.asset-table tr:hover td {
background: #f8fafc;
}
.status-chip {
border-radius: 999px;
display: inline-flex;
font-size: 0.78rem;
font-weight: 750;
padding: 4px 9px;
}
.status-in_service {
background: #e4f2eb;
color: #246140;
}
.status-disposed {
background: #f7e3df;
color: #8d2b1d;
}
.status-cip,
.status-reserved {
background: #f4ead9;
color: #7a4a00;
}
.panel-pad {
padding: 16px;
}
.chart-box {
height: 280px;
}
.login-screen {
align-items: center;
background:
linear-gradient(110deg, rgba(36, 87, 166, 0.92), rgba(0, 108, 106, 0.86)),
url('/asset-workbench.png');
background-position: center;
background-size: cover;
display: grid;
min-height: 100vh;
padding: 24px;
}
.login-card {
max-width: 430px;
width: 100%;
}
.form-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-grid .full {
grid-column: 1 / -1;
}
.custom-field-row {
align-items: center;
display: grid;
gap: 10px;
grid-template-columns: minmax(120px, 1fr) minmax(110px, 0.9fr) 130px minmax(110px, 0.8fr) 104px 40px;
margin-bottom: 10px;
}
.v-theme--mixedAssetsDark {
color-scheme: dark;
}
.v-theme--mixedAssetsDark .rail {
border-right-color: rgba(143, 184, 255, 0.2);
}
.v-theme--mixedAssetsDark .page-band,
.v-theme--mixedAssetsDark .asset-table th,
.v-theme--mixedAssetsDark .asset-table td {
border-color: rgba(143, 184, 255, 0.16);
}
.v-theme--mixedAssetsDark .quiet,
.v-theme--mixedAssetsDark .metric-label,
.v-theme--mixedAssetsDark .metric-subtle,
.v-theme--mixedAssetsDark .asset-table th {
color: #a8b3c2;
}
.v-theme--mixedAssetsDark .asset-table tr:hover td {
background: rgba(143, 184, 255, 0.07);
}
.v-theme--mixedAssetsDark .status-in_service {
background: rgba(143, 215, 163, 0.16);
color: #9ee8b5;
}
.v-theme--mixedAssetsDark .status-disposed {
background: rgba(255, 180, 171, 0.16);
color: #ffb4ab;
}
.v-theme--mixedAssetsDark .status-cip,
.v-theme--mixedAssetsDark .status-reserved {
background: rgba(240, 192, 106, 0.18);
color: #ffd18a;
}
@media (max-width: 980px) {
.content-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.span-3,
.span-4,
.span-5,
.span-6,
.span-7,
.span-8 {
grid-column: span 6;
}
}
@media (max-width: 680px) {
.content-grid,
.page-band {
padding-left: 14px;
padding-right: 14px;
}
.content-grid {
grid-template-columns: 1fr;
}
.span-3,
.span-4,
.span-5,
.span-6,
.span-7,
.span-8,
.span-12 {
grid-column: 1;
}
.form-grid {
grid-template-columns: 1fr;
}
.custom-field-row {
align-items: stretch;
grid-template-columns: 1fr;
}
.profile-name {
display: none;
}
.asset-table {
min-width: 760px;
}
}

62
src/utils/assets.js Normal file
View File

@@ -0,0 +1,62 @@
export function blankAsset() {
const today = new Date().toISOString().slice(0, 10);
return {
asset_id: '',
entity_id: null,
description: '',
category: 'Computer Equipment',
status: 'in_service',
acquisition_cost: 0,
acquired_date: today,
in_service_date: today,
useful_life_months: 60,
salvage_value: 0,
depreciation_method: 'straight_line',
location: '',
department: '',
custodian: '',
vendor: '',
serial_number: '',
invoice_number: '',
gl_asset_account: '',
gl_expense_account: '',
notes: '',
custom_fields: {}
};
}
export function slugFieldKey(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
export function normalizeCustomFields(fields) {
return (fields || [])
.map((field) => ({
key: slugFieldKey(field.key || field.label),
label: field.label || field.key || 'Custom field',
type: ['text', 'date', 'bool', 'numeric'].includes(field.type) ? field.type : 'text',
required: Boolean(field.required),
defaultValue: field.defaultValue ?? field.default ?? ''
}))
.filter((field) => field.key);
}
export function defaultValueForField(field) {
if (field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== '') {
if (field.type === 'bool') return Boolean(field.defaultValue);
if (field.type === 'numeric') return Number(field.defaultValue);
return field.defaultValue;
}
if (field.type === 'bool') return false;
return '';
}
export function inputTypeForCustomField(field) {
if (field.type === 'numeric') return 'number';
if (field.type === 'date') return 'date';
return 'text';
}

11
src/utils/charts.js Normal file
View File

@@ -0,0 +1,11 @@
import {
BarElement,
CategoryScale,
Chart as ChartJS,
Legend,
LinearScale,
Title,
Tooltip
} from 'chart.js';
ChartJS.register(BarElement, CategoryScale, LinearScale, Legend, Title, Tooltip);

3
src/utils/clone.js Normal file
View File

@@ -0,0 +1,3 @@
export function clonePlain(value) {
return JSON.parse(JSON.stringify(value || {}));
}

11
src/utils/format.js Normal file
View File

@@ -0,0 +1,11 @@
export function currency(value) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
}).format(Number(value || 0));
}
export function shortDate(value) {
return value ? new Date(value).toLocaleString() : '';
}

249
src/views/AdminView.vue Normal file
View File

@@ -0,0 +1,249 @@
<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<div>
<h2 class="section-title">Users and roles</h2>
<div class="quiet text-body-2">Manage account access, team membership, role assignment, and active status.</div>
</div>
<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>
</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>
</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-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>
</v-card>
<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" />
<v-text-field v-model="localSettings.cors_allowed_domains" label="Allowed CORS domains" />
<v-text-field v-model="localSettings.database_driver" label="Database driver" readonly />
<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>
<v-card class="span-6 panel-pad">
<h2 class="section-title mb-4">Tax rule sets</h2>
<v-list lines="two">
<v-list-item v-for="rule in taxRules" :key="rule.id" prepend-icon="mdi-scale-balance">
<v-list-item-title>{{ rule.name }} · {{ rule.version }}</v-list-item-title>
<v-list-item-subtitle>{{ rule.jurisdiction }} · {{ rule.effective_date }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<div>
<h2 class="section-title">Workday connector</h2>
<div class="quiet text-body-2">Configure employee, department, and location sync from your Workday tenant.</div>
</div>
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-cloud-sync" :loading="workdaySyncing" @click="$emit('sync-workday')">Sync workers</v-btn>
</div>
<div class="form-grid">
<v-text-field v-model="localWorkday.name" label="Connection name" />
<v-text-field v-model="localWorkday.tenant" label="Tenant" />
<v-text-field v-model="localWorkday.base_url" label="Base URL" />
<v-text-field v-model="localWorkday.token_url" label="Token URL" />
<v-text-field v-model="localWorkday.client_id" label="Client ID" />
<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 />
</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>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="workdaySaving" @click="$emit('save-workday', localWorkday)">Save Workday connection</v-btn>
</div>
<v-alert v-if="workdayConnection.last_sync_status" class="mt-3" density="compact" type="info">{{ workdayConnection.last_sync_status }}</v-alert>
</v-card>
<v-dialog v-model="userDialog" max-width="560">
<v-card>
<v-card-title>{{ userForm.id ? 'Edit user' : 'Add user' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<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.status" :items="['active', 'inactive']" label="Status" />
<v-text-field v-model="userForm.password" type="password" :label="userForm.id ? 'New password' : 'Password'" />
</div>
<v-alert v-if="userForm.role" class="mt-3" density="compact" type="info">
{{ roleSummary(userForm.role) }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="userDialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="userSaving" @click="saveUser">Save user</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="teamDialog" max-width="460">
<v-card>
<v-card-title>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" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="teamDialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="teamSaving" @click="saveTeam">Save team</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
export default {
props: {
roleCapabilities: { type: Array, default: () => [] },
rolePermissions: { type: Object, 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: () => [] },
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'],
data() {
return {
localSettings: { ...this.settings },
localWorkday: { ...this.workdayConnection, client_secret: '' },
teamDialog: false,
teamForm: { name: '', description: '' },
userDialog: false,
userForm: this.blankUser()
};
},
computed: {
teamOptions() {
return this.teams.map((team) => ({ title: team.name, value: team.id }));
}
},
watch: {
settings: {
handler(value) {
this.localSettings = { ...value };
},
deep: true
},
workdayConnection: {
handler(value) {
this.localWorkday = { ...value, client_secret: '' };
},
deep: true
}
},
methods: {
blankUser() {
return {
id: null,
name: '',
email: '',
team_id: null,
role: 'viewer',
status: 'active',
password: ''
};
},
editUser(account) {
this.userForm = {
id: account.id,
name: account.name,
email: account.email,
team_id: account.team_id,
role: account.role,
status: account.status,
password: ''
};
this.userDialog = true;
},
roleSummary(role) {
return (this.rolePermissions[role] || []).join(' · ');
},
saveTeam() {
this.$emit('create-team', { ...this.teamForm });
this.teamDialog = false;
this.teamForm = { name: '', description: '' };
},
saveUser() {
const payload = { ...this.userForm };
if (!payload.password) delete payload.password;
this.$emit(payload.id ? 'update-user' : 'create-user', payload);
this.userDialog = false;
this.userForm = this.blankUser();
}
}
};
</script>

86
src/views/AssetsView.vue Normal file
View File

@@ -0,0 +1,86 @@
<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<v-text-field v-model="localFilters.search" label="Search" hide-details prepend-inner-icon="mdi-magnify" style="max-width: 300px" @update:model-value="emitFilters" />
<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-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>
<template #activator="{ props }">
<v-btn v-bind="props" variant="tonal" prepend-icon="mdi-download">Export</v-btn>
</template>
<v-list density="compact">
<v-list-item v-for="format in ['json', 'csv', 'xlsx', 'xml']" :key="format" :title="format.toUpperCase()" @click="$emit('export-assets', format)" />
</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>
</v-card>
</section>
</template>
<script>
export default {
props: {
allSelected: { type: Boolean, default: false },
assets: { type: Array, required: true },
assetFilters: { type: Object, required: true },
currency: { type: Function, required: true },
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'],
data() {
return {
localFilters: { ...this.assetFilters }
};
},
watch: {
assetFilters: {
handler(value) {
this.localFilters = { ...value };
},
deep: true
}
},
methods: {
emitFilters() {
this.$emit('filter-assets', { ...this.localFilters });
}
}
};
</script>

View File

@@ -0,0 +1,168 @@
<template>
<section class="content-grid">
<v-card class="metric span-3">
<div class="metric-label">Assigned</div>
<div class="metric-value">{{ summary.active_assignments || 0 }}</div>
<div class="metric-subtle">Assets with active custody</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Unassigned</div>
<div class="metric-value">{{ summary.unassigned_assets || 0 }}</div>
<div class="metric-subtle">Need owner/location review</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Employees</div>
<div class="metric-value">{{ employees.length }}</div>
<div class="metric-subtle">Manual or Workday synced</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Locations</div>
<div class="metric-value">{{ summary.assigned_locations || 0 }}</div>
<div class="metric-subtle">Active assignment sites</div>
</v-card>
<v-card class="span-5 panel-pad">
<h2 class="section-title mb-4">Assign asset</h2>
<v-select v-model="assignmentForm.asset_id" :items="assetOptions" item-title="title" item-value="value" label="Asset" />
<v-select v-model="assignmentForm.employee_id" :items="employeeOptions" item-title="title" item-value="value" clearable label="Employee" @update:model-value="applyEmployeeDefaults" />
<v-text-field v-model="assignmentForm.department" label="Department" />
<v-text-field v-model="assignmentForm.location" label="Location" />
<v-textarea v-model="assignmentForm.notes" label="Notes" rows="2" />
<v-btn color="primary" prepend-icon="mdi-account-check" :loading="assignmentSaving" @click="$emit('assign-asset', assignmentForm)">Assign asset</v-btn>
</v-card>
<v-card class="span-7 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Employees</h2>
<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>
</v-card>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Traceability</h2>
<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>
</v-card>
<v-dialog v-model="employeeDialog" max-width="480">
<v-card>
<v-card-title>Add employee</v-card-title>
<v-card-text>
<v-text-field v-model="employeeForm.name" label="Name" />
<v-text-field v-model="employeeForm.email" label="Email" />
<v-text-field v-model="employeeForm.employee_number" label="Employee number" />
<v-text-field v-model="employeeForm.department" label="Department" />
<v-text-field v-model="employeeForm.location" label="Location" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="employeeDialog = false">Cancel</v-btn>
<v-btn color="primary" @click="saveEmployee">Save employee</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
export default {
props: {
assignments: { type: Array, required: true },
assets: { type: Array, required: true },
assignmentSaving: { type: Boolean, default: false },
employees: { type: Array, required: true },
shortDate: { type: Function, required: true },
summary: { type: Object, required: true }
},
emits: ['add-employee', 'assign-asset', 'release-assignment', 'reload'],
data() {
return {
assignmentForm: {
asset_id: null,
employee_id: null,
department: '',
location: '',
notes: ''
},
employeeDialog: false,
employeeForm: {
name: '',
email: '',
employee_number: '',
department: '',
location: ''
}
};
},
computed: {
assetOptions() {
return this.assets.map((asset) => ({
title: `${asset.asset_id} · ${asset.description}`,
value: asset.id
}));
},
employeeOptions() {
return this.employees.map((employee) => ({
title: `${employee.name}${employee.department ? ` · ${employee.department}` : ''}`,
value: employee.id
}));
}
},
methods: {
applyEmployeeDefaults(id) {
const employee = this.employees.find((item) => item.id === id);
if (!employee) return;
this.assignmentForm.department = employee.department || '';
this.assignmentForm.location = employee.location || '';
},
saveEmployee() {
this.$emit('add-employee', { ...this.employeeForm });
this.employeeDialog = false;
this.employeeForm = { name: '', email: '', employee_number: '', department: '', location: '' };
}
}
};
</script>

View File

@@ -0,0 +1,61 @@
<template>
<section class="content-grid">
<v-card class="metric span-3">
<div class="metric-label">Assets</div>
<div class="metric-value">{{ dashboard.stats.asset_count }}</div>
<div class="metric-subtle">{{ dashboard.stats.in_service_count }} in service</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Capitalized cost</div>
<div class="metric-value">{{ currency(dashboard.stats.total_cost) }}</div>
<div class="metric-subtle">{{ dashboard.byCategory.length }} categories</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Disposed</div>
<div class="metric-value">{{ dashboard.stats.disposed_count }}</div>
<div class="metric-subtle">Tracked gain/loss ready</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Open tasks</div>
<div class="metric-value">{{ dashboard.upcomingTasks.length }}</div>
<div class="metric-subtle">Maintenance and reviews</div>
</v-card>
<v-card class="span-7 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Category value</h2>
</div>
<div class="chart-box">
<Bar :data="categoryChart" :options="chartOptions" />
</div>
</v-card>
<v-card class="span-5 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Activity</h2>
</div>
<v-list lines="two" density="compact">
<v-list-item v-for="log in dashboard.auditTrail" :key="log.id" prepend-icon="mdi-history">
<v-list-item-title>{{ log.action }} {{ log.entity_type }}</v-list-item-title>
<v-list-item-subtitle>{{ log.actor_name || 'System' }} · {{ shortDate(log.created_at) }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card>
</section>
</template>
<script>
import { Bar } from 'vue-chartjs';
import '../utils/charts';
export default {
components: { Bar },
props: {
categoryChart: { type: Object, required: true },
chartOptions: { type: Object, required: true },
currency: { type: Function, required: true },
dashboard: { type: Object, required: true },
shortDate: { type: Function, required: true }
}
};
</script>

46
src/views/LoginView.vue Normal file
View File

@@ -0,0 +1,46 @@
<template>
<div class="login-screen">
<v-card class="login-card" elevation="12">
<div class="brand-lockup">
<span class="brand-mark">MA</span>
<span class="brand-name">MixedAssets</span>
</div>
<v-divider />
<v-card-text class="pa-5">
<h1 class="text-h5 font-weight-bold mb-5">Fixed asset command center</h1>
<v-text-field v-model="localForm.email" label="Email" autocomplete="email" prepend-inner-icon="mdi-email-outline" />
<v-text-field
v-model="localForm.password"
label="Password"
type="password"
autocomplete="current-password"
prepend-inner-icon="mdi-lock-outline"
@keyup.enter="$emit('login', localForm)"
/>
<v-alert v-if="error" class="mb-4" density="compact" type="error">{{ error }}</v-alert>
<v-btn block color="primary" :loading="loading" prepend-icon="mdi-login" @click="$emit('login', localForm)">Sign in</v-btn>
</v-card-text>
</v-card>
</div>
</template>
<script>
export default {
props: {
error: { type: String, default: '' },
form: { type: Object, required: true },
loading: { type: Boolean, default: false }
},
emits: ['login'],
data() {
return {
localForm: { ...this.form }
};
},
watch: {
form(value) {
this.localForm = { ...value };
}
}
};
</script>

77
src/views/ReportsView.vue Normal file
View File

@@ -0,0 +1,77 @@
<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>
</v-card>
</section>
</template>
<script>
import { Bar } from 'vue-chartjs';
import '../utils/charts';
export default {
components: { Bar },
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 }
},
emits: ['filter-reports'],
data() {
return {
localFilters: { ...this.reportFilters }
};
},
watch: {
reportFilters: {
handler(value) {
this.localFilters = { ...value };
},
deep: true
}
},
methods: {
emitFilters() {
this.$emit('filter-reports', { ...this.localFilters });
}
}
};
</script>

View File

@@ -0,0 +1,85 @@
<template>
<section class="content-grid">
<v-card class="span-5 panel-pad">
<h2 class="section-title mb-4">Asset template</h2>
<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" />
<v-text-field v-model.number="localForm.defaults.useful_life_months" type="number" label="Useful life months" />
<v-select v-model="localForm.defaults.depreciation_method" :items="methodItems" item-title="title" item-value="value" label="Depreciation method" />
<v-divider class="my-4" />
<div class="toolbar-row mb-3">
<h3 class="section-title">Custom fields</h3>
<v-spacer />
<v-btn variant="tonal" size="small" prepend-icon="mdi-plus" @click="addField">Add field</v-btn>
</div>
<div v-if="!localForm.custom_fields.length" class="quiet text-body-2 mb-4">No custom fields defined.</div>
<div v-for="(field, index) in localForm.custom_fields" :key="field.local_id" class="custom-field-row">
<v-text-field v-model="field.label" label="Label" @update:model-value="syncFieldKey(field)" />
<v-text-field v-model="field.key" label="Key" />
<v-select v-model="field.type" :items="customFieldTypes" item-title="title" item-value="value" label="Type" />
<v-text-field v-if="field.type !== 'bool'" v-model="field.defaultValue" label="Default" />
<v-switch v-else v-model="field.defaultValue" label="Default" color="primary" density="compact" hide-details />
<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-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>
</v-card>
</section>
</template>
<script>
import { slugFieldKey } from '../utils/assets';
import { clonePlain } from '../utils/clone';
export default {
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 }
},
emits: ['save-template'],
data() {
return {
localForm: clonePlain(this.templateForm)
};
},
watch: {
templateForm: {
handler(value) {
this.localForm = clonePlain(value);
},
deep: true
}
},
methods: {
addField() {
this.localForm.custom_fields.push({
local_id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
key: '',
label: '',
type: 'text',
required: false,
defaultValue: ''
});
},
syncFieldKey(field) {
if (!field.key) field.key = slugFieldKey(field.label);
}
}
};
</script>