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>