Initial
This commit is contained in:
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