Initial
This commit is contained in:
657
src/App.vue
Normal file
657
src/App.vue
Normal 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>
|
||||
102
src/components/AssetDrawer.vue
Normal file
102
src/components/AssetDrawer.vue
Normal 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>
|
||||
41
src/components/MassEditDialog.vue
Normal file
41
src/components/MassEditDialog.vue
Normal 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
99
src/components/TopNav.vue
Normal 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
24
src/constants.js
Normal 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
59
src/main.js
Normal 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
35
src/services/api.js
Normal 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
342
src/styles/app.css
Normal 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
62
src/utils/assets.js
Normal 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
11
src/utils/charts.js
Normal 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
3
src/utils/clone.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export function clonePlain(value) {
|
||||
return JSON.parse(JSON.stringify(value || {}));
|
||||
}
|
||||
11
src/utils/format.js
Normal file
11
src/utils/format.js
Normal 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
249
src/views/AdminView.vue
Normal 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
86
src/views/AssetsView.vue
Normal 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>
|
||||
168
src/views/AssignmentsView.vue
Normal file
168
src/views/AssignmentsView.vue
Normal 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>
|
||||
61
src/views/DashboardView.vue
Normal file
61
src/views/DashboardView.vue
Normal 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
46
src/views/LoginView.vue
Normal 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
77
src/views/ReportsView.vue
Normal 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>
|
||||
85
src/views/TemplatesView.vue
Normal file
85
src/views/TemplatesView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user