Updated GL system allows changing account defaults and add/updating journal entries
This commit is contained in:
13
src/App.vue
13
src/App.vue
@@ -86,6 +86,8 @@
|
||||
</template>
|
||||
</TopNav>
|
||||
|
||||
<!-- Keying on the active company remounts the whole view tree on a company switch, so each
|
||||
screen re-fetches its data scoped to the newly selected company. -->
|
||||
<v-main id="main-content" :key="`company-${activeCompanyId}`" tabindex="-1">
|
||||
<DashboardView
|
||||
v-if="activeView === 'dashboard'"
|
||||
@@ -165,6 +167,10 @@
|
||||
:method-items="methodItems"
|
||||
@updated="loadBookCodes"
|
||||
/>
|
||||
<GlJournalsView
|
||||
v-if="activeView === 'gl-journals'"
|
||||
:token="token"
|
||||
/>
|
||||
<TemplatesView
|
||||
v-if="activeView === 'templates'"
|
||||
:category-items="categoryItems"
|
||||
@@ -306,6 +312,7 @@ import AdminView from './views/AdminView.vue';
|
||||
import AlertsView from './views/AlertsView.vue';
|
||||
import AssignmentsView from './views/AssignmentsView.vue';
|
||||
import BooksView from './views/BooksView.vue';
|
||||
import GlJournalsView from './views/GlJournalsView.vue';
|
||||
import ContactsView from './views/ContactsView.vue';
|
||||
import AssetsView from './views/AssetsView.vue';
|
||||
import DashboardView from './views/DashboardView.vue';
|
||||
@@ -343,6 +350,7 @@ export default {
|
||||
AssetDrawer,
|
||||
AssetsView,
|
||||
BooksView,
|
||||
GlJournalsView,
|
||||
ChangePasswordCard,
|
||||
ContactsView,
|
||||
DashboardView,
|
||||
@@ -524,7 +532,8 @@ export default {
|
||||
assignments: 'Assign assets to people, departments, and locations with full custody history',
|
||||
contacts: 'Employees, vendors, service providers, contractors, and work locations',
|
||||
reports: 'Depreciation schedules, monthly expense, and book values',
|
||||
books: 'Configure books, assign tax rule sets, and view the general ledger',
|
||||
books: 'Configure books, assign tax rule sets, and set entry defaults',
|
||||
'gl-journals': 'General ledger rollup and editable journal entries per book',
|
||||
templates: 'JSON-driven entry defaults and custom fields',
|
||||
'tax-rules': 'Upload, edit, export, and activate depreciation rule sets',
|
||||
admin: 'Users, configuration, and integrations',
|
||||
@@ -547,6 +556,7 @@ export default {
|
||||
contacts: 'Contacts',
|
||||
reports: 'Reports',
|
||||
books: 'Books',
|
||||
'gl-journals': 'GL and Journals',
|
||||
templates: 'Templates',
|
||||
'tax-rules': 'Tax rules',
|
||||
admin: 'Configuration',
|
||||
@@ -612,6 +622,7 @@ export default {
|
||||
contacts: ['contacts.manage'],
|
||||
reports: ['finance.view'],
|
||||
books: ['finance.manage'],
|
||||
'gl-journals': ['finance.view', 'finance.manage'],
|
||||
'tax-rules': ['finance.manage'],
|
||||
'admin-users': ['admin.users'],
|
||||
'admin-security': ['admin.security'],
|
||||
|
||||
@@ -121,6 +121,10 @@
|
||||
import { apiRequest, saveBlob } from '../services/api';
|
||||
import { shortDate } from '../utils/format';
|
||||
|
||||
// Activity & Audit Trail viewer (Admin → Activity & Audit Trail). Server-side paginated + filtered
|
||||
// (user/action/entity-type/date/search); filter dropdown options come from the `facets` the list
|
||||
// endpoint returns. Uses its own table + pager rather than DataTable because that paginates client-side.
|
||||
|
||||
const BLANK_FILTERS = { actor: null, action: null, entity_type: null, from: null, to: null, q: '' };
|
||||
|
||||
export default {
|
||||
|
||||
@@ -59,6 +59,9 @@
|
||||
<script>
|
||||
import { apiRequest } from '../services/api';
|
||||
|
||||
// Self-service password change. Reused in two places: the forced-change screen shown at login when the
|
||||
// password is expired/admin-reset (`forced`), and the account menu's "Change password" dialog. It
|
||||
// fetches the live policy rules to display, then PUTs to /api/profile/password.
|
||||
export default {
|
||||
props: {
|
||||
token: { type: String, required: true },
|
||||
|
||||
@@ -91,6 +91,10 @@
|
||||
import DataTable from './DataTable.vue';
|
||||
import { apiRequest } from '../services/api';
|
||||
|
||||
// Admin company management (Admin → Application Configuration). Lists every company including disposed
|
||||
// ones (?includeDisposed=1), supports add/edit, and soft dispose/restore. Emits `updated` so App.vue
|
||||
// can refresh the top-bar company switcher.
|
||||
|
||||
function blankCompany() {
|
||||
return { id: null, name: '', legal_name: '', federal_tax_id: '', state_tax_id: '', local_tax_id: '', base_currency: 'USD', fiscal_year_end_month: 12 };
|
||||
}
|
||||
|
||||
@@ -103,6 +103,9 @@
|
||||
<script>
|
||||
import { apiRequest } from '../services/api';
|
||||
|
||||
// Password & account-lockout policy editor (Admin → Password & Security). Binds to GET/PUT
|
||||
// /api/password-policy; the server returns plain-English `rules` which drive the live "what users will
|
||||
// see" preview so the form and the enforced rules never drift.
|
||||
export default {
|
||||
props: {
|
||||
token: { type: String, required: true }
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
<script>
|
||||
import { apiRequest } from '../services/api';
|
||||
|
||||
// Microsoft Teams alert-delivery settings (Admin → Integrations). Configures the incoming webhook URL,
|
||||
// card format (Adaptive Card for Workflows vs legacy MessageCard), and minimum severity, with a test
|
||||
// post. The "Send test" button saves first so the test hits exactly what the user typed.
|
||||
export default {
|
||||
props: {
|
||||
token: { type: String, required: true }
|
||||
|
||||
@@ -160,6 +160,7 @@ export const navItems = [
|
||||
group: true,
|
||||
children: [
|
||||
{ key: 'books', label: 'Books', icon: 'mdi-book-open-variant' },
|
||||
{ key: 'gl-journals', label: 'GL and Journals', icon: 'mdi-book-account-outline' },
|
||||
{ key: 'tax-rules', label: 'Tax rules', icon: 'mdi-scale-balance' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
<v-spacer />
|
||||
<v-btn color="primary" size="small" prepend-icon="mdi-plus" @click="openNewBook">New book</v-btn>
|
||||
</div>
|
||||
<p class="quiet text-body-2 mb-3">Configure each set of books, assign the depreciation rule set it should use, and set entry defaults. Add user-defined books for additional reporting scenarios.</p>
|
||||
<p class="quiet text-body-2 mb-3">
|
||||
Configure each set of books, assign the depreciation rule set it should use, and set entry defaults. Add user-defined
|
||||
books for additional reporting scenarios. View each book’s general ledger and journal entries under
|
||||
<strong>Financial → GL and Journals</strong>.
|
||||
</p>
|
||||
<div style="overflow-x:auto">
|
||||
<table class="asset-table">
|
||||
<thead>
|
||||
@@ -71,237 +75,6 @@
|
||||
<v-alert v-if="message" type="success" density="compact" class="mt-3">{{ message }}</v-alert>
|
||||
</v-card>
|
||||
|
||||
<!-- General ledger -->
|
||||
<v-card class="span-12 panel-pad">
|
||||
<div class="toolbar-row mb-3">
|
||||
<h2 class="section-title">General ledger</h2>
|
||||
<v-spacer />
|
||||
<v-select v-model="ledgerBook" :items="bookCodeItems" label="Book" density="compact" hide-details style="max-width:180px" @update:model-value="loadLedger" />
|
||||
<v-text-field v-model.number="ledgerYear" type="number" label="Year" density="compact" hide-details style="max-width:120px" @update:model-value="loadLedger" />
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" color="secondary" size="small" prepend-icon="mdi-download">Export</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item v-for="format in ['pdf', 'xlsx', 'csv']" :key="format" :title="format.toUpperCase()" @click="exportLedger(format)" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<div v-if="ledger">
|
||||
<div v-if="ledger.kind === 'maintenance'" class="ledger-summary mb-4">
|
||||
<div class="ledger-chip"><span class="quiet">Assets serviced</span><strong>{{ ledger.summary.assets }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Services ({{ ledger.year }})</span><strong>{{ ledger.summary.completions }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Total PM spend</span><strong>{{ currency(ledger.summary.cost) }}</strong></div>
|
||||
</div>
|
||||
<div v-else class="ledger-summary mb-4">
|
||||
<div class="ledger-chip"><span class="quiet">Assets</span><strong>{{ ledger.summary.assets }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Cost</span><strong>{{ currency(ledger.summary.cost) }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Accumulated depr.</span><strong>{{ currency(ledger.summary.accumulated) }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Net book value</span><strong>{{ currency(ledger.summary.nbv) }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">{{ ledger.year }} depreciation</span><strong>{{ currency(ledger.summary.depreciation) }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Rule set</span><strong>{{ ledger.rule_set.name || 'Active set' }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div style="overflow-x:auto">
|
||||
<table class="asset-table">
|
||||
<thead>
|
||||
<tr><th>GL account</th><th>Account type</th><th class="text-right">Debit</th><th class="text-right">Credit</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(line, index) in ledger.accounts" :key="index">
|
||||
<td class="font-weight-bold">{{ line.account }}</td>
|
||||
<td>{{ line.type }}</td>
|
||||
<td class="text-right">{{ line.debit ? currency(line.debit) : '' }}</td>
|
||||
<td class="text-right">{{ line.credit ? currency(line.credit) : '' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!ledger.accounts.length"><td colspan="4" class="quiet">No postings for this book and year.</td></tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td class="font-weight-bold" colspan="2">Totals</td>
|
||||
<td class="text-right font-weight-bold">{{ currency(ledger.accountTotals.debit) }}</td>
|
||||
<td class="text-right font-weight-bold">{{ currency(ledger.accountTotals.credit) }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<v-expansion-panels class="mt-4" variant="accordion">
|
||||
<v-expansion-panel title="Asset detail">
|
||||
<v-expansion-panel-text>
|
||||
<DataTable
|
||||
:columns="detailColumns"
|
||||
:rows="ledger.assets"
|
||||
row-key="asset_id"
|
||||
searchable
|
||||
search-placeholder="Filter assets"
|
||||
empty-text="No assets in this book."
|
||||
>
|
||||
<template #cell-asset_id="{ row }">
|
||||
<span class="font-weight-bold">{{ row.asset_id }}</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</div>
|
||||
<div v-else class="quiet text-body-2">Select a book to view its general ledger.</div>
|
||||
</v-card>
|
||||
|
||||
<!-- GL journal entries (stored, editable, fully audited) -->
|
||||
<v-card class="span-12 panel-pad">
|
||||
<div class="toolbar-row mb-3">
|
||||
<div>
|
||||
<h2 class="section-title">Journal entries</h2>
|
||||
<div class="quiet text-body-2">
|
||||
Manual & imported GL postings for <strong>{{ ledgerBook }}</strong> ({{ ledgerYear }}). All changes are
|
||||
recorded in Admin → Activity & Audit Trail.
|
||||
</div>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-upload" @click="$refs.glFile.click()">Import</v-btn>
|
||||
<input ref="glFile" hidden type="file" accept=".csv,.xlsx,.xls" @change="onImportFile" />
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" size="small" variant="tonal" prepend-icon="mdi-download">Export</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item title="CSV" @click="exportEntries('csv')" />
|
||||
<v-list-item title="Excel (XLSX)" @click="exportEntries('xlsx')" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn size="small" color="primary" prepend-icon="mdi-plus" @click="openEntry()">Add entry</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="ledger-summary mb-3">
|
||||
<div class="ledger-chip"><span class="quiet">Entries</span><strong>{{ entrySummary.count }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Debit</span><strong>{{ currency(entrySummary.debit) }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Credit</span><strong>{{ currency(entrySummary.credit) }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Balance</span><strong>{{ currency(entrySummary.balance) }}</strong></div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="entryColumns"
|
||||
:rows="entries"
|
||||
row-key="id"
|
||||
:page-size="15"
|
||||
has-actions
|
||||
searchable
|
||||
search-placeholder="Filter entries"
|
||||
empty-text="No journal entries for this book and year."
|
||||
>
|
||||
<template #cell-debit="{ row }">{{ row.debit ? currency(row.debit) : '' }}</template>
|
||||
<template #cell-credit="{ row }">{{ row.credit ? currency(row.credit) : '' }}</template>
|
||||
<template #cell-source="{ row }"><v-chip size="x-small" variant="tonal">{{ row.source }}</v-chip></template>
|
||||
<template #actions="{ row }">
|
||||
<v-btn icon="mdi-history" size="small" variant="text" title="Change history" @click="openHistory(row)" />
|
||||
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEntry(row)" />
|
||||
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="removeEntry(row)" />
|
||||
</template>
|
||||
</DataTable>
|
||||
<v-alert v-if="entryError" type="error" density="compact" class="mt-2">{{ entryError }}</v-alert>
|
||||
<v-alert v-if="message" type="success" density="compact" class="mt-2">{{ message }}</v-alert>
|
||||
</v-card>
|
||||
|
||||
<!-- Add / edit GL entry -->
|
||||
<v-dialog v-model="entryDialog" max-width="640">
|
||||
<v-card>
|
||||
<v-card-title>{{ entryForm.id ? 'Edit GL entry' : 'New GL entry' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="form-grid">
|
||||
<v-text-field v-model="entryForm.entry_date" type="date" label="Date *" />
|
||||
<v-text-field v-model="entryForm.account" label="GL account *" />
|
||||
<v-text-field v-model="entryForm.account_type" label="Account type" />
|
||||
<v-text-field v-model="entryForm.reference" label="Reference" />
|
||||
<v-text-field v-model.number="entryForm.debit" type="number" prefix="$" label="Debit" />
|
||||
<v-text-field v-model.number="entryForm.credit" type="number" prefix="$" label="Credit" />
|
||||
<v-text-field v-model="entryForm.external_id" class="full" label="External ID (used to match on re-import)" />
|
||||
<v-textarea v-model="entryForm.description" class="full" label="Description" rows="2" />
|
||||
</div>
|
||||
<v-alert v-if="entryDialogError" type="error" density="compact" class="mt-2">{{ entryDialogError }}</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="entryDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="savingEntry" @click="saveEntry">Save entry</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Import preview / conflict resolution -->
|
||||
<v-dialog v-model="importDialog" max-width="760" scrollable>
|
||||
<v-card v-if="importPreview">
|
||||
<v-card-title>Import preview — {{ ledgerBook }}</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="ledger-summary mb-3">
|
||||
<div class="ledger-chip"><span class="quiet">New</span><strong>{{ importPreview.summary.new }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Update</span><strong>{{ importPreview.summary.update }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Duplicate</span><strong>{{ importPreview.summary.duplicate }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Errors</span><strong>{{ importPreview.summary.error }}</strong></div>
|
||||
</div>
|
||||
<div class="text-body-2 quiet mb-1">Conflict strategy</div>
|
||||
<v-radio-group v-model="importStrategy" inline hide-details density="compact" class="mb-2">
|
||||
<v-radio label="Skip conflicts" value="skip" />
|
||||
<v-radio label="Merge (update existing)" value="merge" />
|
||||
<v-radio label="Insert as new" value="insert" />
|
||||
</v-radio-group>
|
||||
<div style="overflow-x:auto; max-height:340px">
|
||||
<table class="asset-table">
|
||||
<thead><tr><th>Status</th><th>Date</th><th>Account</th><th class="text-right">Debit</th><th class="text-right">Credit</th><th>Reference</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in importPreview.rows" :key="r.index">
|
||||
<td><v-chip size="x-small" :color="statusColor(r.status)" variant="tonal">{{ r.status }}</v-chip><span v-if="r.error" class="quiet text-caption"> · {{ r.error }}</span></td>
|
||||
<td>{{ r.entry.entry_date }}</td>
|
||||
<td>{{ r.entry.account }}</td>
|
||||
<td class="text-right">{{ r.entry.debit ? currency(r.entry.debit) : '' }}</td>
|
||||
<td class="text-right">{{ r.entry.credit ? currency(r.entry.credit) : '' }}</td>
|
||||
<td>{{ r.entry.reference }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="importDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" prepend-icon="mdi-database-import" :loading="importing" @click="commitImport">Import</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Per-entry change history (from the audit log) -->
|
||||
<v-dialog v-model="historyDialog" max-width="640" scrollable>
|
||||
<v-card>
|
||||
<v-card-title>Change history</v-card-title>
|
||||
<v-card-text>
|
||||
<div v-if="historyLoading" class="quiet text-body-2">Loading…</div>
|
||||
<div v-else-if="!historyRows.length" class="quiet text-body-2">No recorded changes.</div>
|
||||
<div v-for="ev in historyRows" :key="ev.id" class="gl-history-item">
|
||||
<div class="toolbar-row mb-1" style="gap:8px">
|
||||
<v-chip size="x-small" :color="historyColor(ev.action)" variant="tonal">{{ historyLabel(ev.action) }}</v-chip>
|
||||
<span class="text-body-2 font-weight-medium">{{ ev.actor_name || 'System' }}</span>
|
||||
<v-spacer />
|
||||
<span class="quiet text-caption">{{ shortDate(ev.created_at) }}</span>
|
||||
</div>
|
||||
<table v-if="changedFields(ev.before, ev.after).length" class="gl-diff">
|
||||
<tr v-for="(c, i) in changedFields(ev.before, ev.after)" :key="i">
|
||||
<td class="quiet">{{ c.label }}</td>
|
||||
<td class="gl-from">{{ c.from }}</td>
|
||||
<td class="px-2">→</td>
|
||||
<td class="gl-to">{{ c.to }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div v-else class="quiet text-caption">No field changes.</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="historyDialog = false">Close</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="newBookDialog" max-width="560">
|
||||
<v-card>
|
||||
<v-card-title>New book</v-card-title>
|
||||
@@ -329,13 +102,10 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DataTable from '../components/DataTable.vue';
|
||||
import { apiRequest, saveBlob } from '../services/api';
|
||||
import { currency, shortDate } from '../utils/format';
|
||||
import { apiRequest } from '../services/api';
|
||||
import { conventionItems } from '../constants';
|
||||
|
||||
export default {
|
||||
components: { DataTable },
|
||||
props: {
|
||||
token: { type: String, required: true },
|
||||
methodItems: { type: Array, default: () => [] }
|
||||
@@ -351,80 +121,24 @@ export default {
|
||||
],
|
||||
books: [],
|
||||
ruleSets: [],
|
||||
ledger: null,
|
||||
ledgerBook: 'GAAP',
|
||||
ledgerYear: new Date().getFullYear(),
|
||||
savingId: null,
|
||||
error: '',
|
||||
message: '',
|
||||
newBookDialog: false,
|
||||
newBookError: '',
|
||||
creating: false,
|
||||
newBook: this.blankBook(),
|
||||
entries: [],
|
||||
entrySummary: { count: 0, debit: 0, credit: 0, balance: 0 },
|
||||
entryDialog: false,
|
||||
entryForm: this.blankEntry(),
|
||||
entryDialogError: '',
|
||||
savingEntry: false,
|
||||
entryError: '',
|
||||
importDialog: false,
|
||||
importPreview: null,
|
||||
importStrategy: 'merge',
|
||||
importing: false,
|
||||
historyDialog: false,
|
||||
historyRows: [],
|
||||
historyLoading: false,
|
||||
glFieldLabels: { entry_date: 'Date', account: 'Account', account_type: 'Type', description: 'Description', reference: 'Reference', debit: 'Debit', credit: 'Credit', external_id: 'External ID', asset_id: 'Asset' }
|
||||
newBook: this.blankBook()
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
ruleSetItems() {
|
||||
return this.ruleSets.map((set) => ({ title: `${set.name} · ${set.version}`, value: set.id }));
|
||||
},
|
||||
bookCodeItems() {
|
||||
return this.books.map((book) => book.code);
|
||||
},
|
||||
detailColumns() {
|
||||
const year = this.ledger?.year || this.ledgerYear;
|
||||
if (this.ledger?.kind === 'maintenance') {
|
||||
return [
|
||||
{ key: 'asset_id', label: 'Asset ID' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'completions', label: 'Services', format: 'number' },
|
||||
{ key: 'last_service', label: 'Last service', format: 'date' },
|
||||
{ key: 'cost', label: `${year} PM cost`, format: 'currency' }
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ key: 'asset_id', label: 'Asset ID' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'asset_account', label: 'Asset acct' },
|
||||
{ key: 'expense_account', label: 'Expense acct' },
|
||||
{ key: 'cost', label: 'Cost', format: 'currency' },
|
||||
{ key: 'depreciation', label: `${year} depr.`, format: 'currency' },
|
||||
{ key: 'accumulated', label: 'Accum.', format: 'currency' },
|
||||
{ key: 'nbv', label: 'NBV', format: 'currency' }
|
||||
];
|
||||
},
|
||||
entryColumns() {
|
||||
return [
|
||||
{ key: 'entry_date', label: 'Date', format: 'date' },
|
||||
{ key: 'account', label: 'Account' },
|
||||
{ key: 'account_type', label: 'Type' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'reference', label: 'Reference' },
|
||||
{ key: 'debit', label: 'Debit', format: 'currency' },
|
||||
{ key: 'credit', label: 'Credit', format: 'currency' },
|
||||
{ key: 'source', label: 'Source', sortable: false }
|
||||
];
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.load();
|
||||
},
|
||||
methods: {
|
||||
currency,
|
||||
blankBook() {
|
||||
return { code: '', name: '', description: '', book_type: 'internal', tax_rule_set_id: null, default_method: 'straight_line', default_convention: 'half_year', enabled: true };
|
||||
},
|
||||
@@ -465,10 +179,6 @@ export default {
|
||||
const data = await (await this.api('/api/books')).json();
|
||||
this.books = data.books;
|
||||
this.ruleSets = data.ruleSets;
|
||||
if (this.books.length) {
|
||||
if (!this.books.some((book) => book.code === this.ledgerBook)) this.ledgerBook = this.books[0].code;
|
||||
this.loadLedger();
|
||||
}
|
||||
},
|
||||
async save(book) {
|
||||
this.savingId = book.id;
|
||||
@@ -488,195 +198,12 @@ export default {
|
||||
});
|
||||
this.message = `${book.code} saved.`;
|
||||
this.$emit('updated');
|
||||
if (book.code === this.ledgerBook) this.loadLedger();
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
} finally {
|
||||
this.savingId = null;
|
||||
}
|
||||
},
|
||||
async loadLedger() {
|
||||
if (!this.ledgerBook) return;
|
||||
this.error = '';
|
||||
try {
|
||||
const data = await (await this.api(`/api/books/${this.ledgerBook}/ledger?year=${this.ledgerYear}`)).json();
|
||||
this.ledger = data.ledger;
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
}
|
||||
this.loadEntries();
|
||||
},
|
||||
async exportLedger(format) {
|
||||
const blob = await (await this.api(`/api/books/${this.ledgerBook}/ledger/export`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ year: this.ledgerYear, format })
|
||||
})).blob();
|
||||
saveBlob(blob, `deprecore-${this.ledgerBook}-ledger-${this.ledgerYear}.${format}`);
|
||||
},
|
||||
// ---- GL journal entries -------------------------------------------------
|
||||
blankEntry() {
|
||||
return { id: null, entry_date: new Date().toISOString().slice(0, 10), account: '', account_type: '', description: '', reference: '', debit: 0, credit: 0, external_id: '' };
|
||||
},
|
||||
async loadEntries() {
|
||||
if (!this.ledgerBook) return;
|
||||
this.entryError = '';
|
||||
try {
|
||||
const data = await (await this.api(`/api/books/${this.ledgerBook}/gl-entries?year=${this.ledgerYear}`)).json();
|
||||
this.entries = data.entries;
|
||||
this.entrySummary = data.summary;
|
||||
} catch (error) {
|
||||
this.entryError = error.message;
|
||||
}
|
||||
},
|
||||
openEntry(row) {
|
||||
this.entryForm = row ? { ...this.blankEntry(), ...row } : this.blankEntry();
|
||||
this.entryDialogError = '';
|
||||
this.entryDialog = true;
|
||||
},
|
||||
async saveEntry() {
|
||||
this.savingEntry = true;
|
||||
this.entryDialogError = '';
|
||||
this.message = '';
|
||||
try {
|
||||
const method = this.entryForm.id ? 'PUT' : 'POST';
|
||||
const url = this.entryForm.id
|
||||
? `/api/books/${this.ledgerBook}/gl-entries/${this.entryForm.id}`
|
||||
: `/api/books/${this.ledgerBook}/gl-entries`;
|
||||
await this.api(url, { method, body: JSON.stringify(this.entryForm) });
|
||||
this.entryDialog = false;
|
||||
await this.loadEntries();
|
||||
} catch (error) {
|
||||
this.entryDialogError = error.message;
|
||||
} finally {
|
||||
this.savingEntry = false;
|
||||
}
|
||||
},
|
||||
async removeEntry(row) {
|
||||
if (!window.confirm('Delete this GL entry? The change is recorded in the audit trail.')) return;
|
||||
this.entryError = '';
|
||||
try {
|
||||
await this.api(`/api/books/${this.ledgerBook}/gl-entries/${row.id}`, { method: 'DELETE' });
|
||||
await this.loadEntries();
|
||||
} catch (error) {
|
||||
this.entryError = error.message;
|
||||
}
|
||||
},
|
||||
async onImportFile(event) {
|
||||
const file = event.target.files[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
this.entryError = '';
|
||||
this.message = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const data = await (await this.api(`/api/books/${this.ledgerBook}/gl-entries/import/preview`, { method: 'POST', body: form })).json();
|
||||
this.importPreview = data;
|
||||
this.importStrategy = 'merge';
|
||||
this.importDialog = true;
|
||||
} catch (error) {
|
||||
this.entryError = `Import failed: ${error.message}`;
|
||||
}
|
||||
},
|
||||
async commitImport() {
|
||||
this.importing = true;
|
||||
try {
|
||||
const result = await (await this.api(`/api/books/${this.ledgerBook}/gl-entries/import/commit`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rows: this.importPreview.rows, strategy: this.importStrategy })
|
||||
})).json();
|
||||
this.importDialog = false;
|
||||
this.message = `Imported: ${result.inserted} new, ${result.updated} updated, ${result.skipped} skipped${result.errors ? `, ${result.errors} error(s)` : ''}.`;
|
||||
await this.loadEntries();
|
||||
} catch (error) {
|
||||
this.entryError = error.message;
|
||||
} finally {
|
||||
this.importing = false;
|
||||
}
|
||||
},
|
||||
async exportEntries(format) {
|
||||
const blob = await (await this.api(`/api/books/${this.ledgerBook}/gl-entries/export?year=${this.ledgerYear}&format=${format}`)).blob();
|
||||
saveBlob(blob, `deprecore-${this.ledgerBook}-gl-entries.${format}`);
|
||||
},
|
||||
statusColor(status) {
|
||||
return { new: 'success', update: 'info', duplicate: 'warning', error: 'error' }[status] || undefined;
|
||||
},
|
||||
shortDate,
|
||||
async openHistory(row) {
|
||||
this.historyRows = [];
|
||||
this.historyLoading = true;
|
||||
this.historyDialog = true;
|
||||
try {
|
||||
const data = await (await this.api(`/api/books/${this.ledgerBook}/gl-entries/${row.id}/history`)).json();
|
||||
this.historyRows = data.history;
|
||||
} catch (error) {
|
||||
this.entryError = error.message;
|
||||
this.historyDialog = false;
|
||||
} finally {
|
||||
this.historyLoading = false;
|
||||
}
|
||||
},
|
||||
// Field-level diff between an audit event's before/after snapshots.
|
||||
changedFields(before, after) {
|
||||
const norm = (v) => (v === null || v === undefined || v === '' ? '' : String(v));
|
||||
const b = before || {};
|
||||
const a = after || {};
|
||||
const out = [];
|
||||
for (const [key, label] of Object.entries(this.glFieldLabels)) {
|
||||
const from = norm(b[key]);
|
||||
const to = norm(a[key]);
|
||||
if (from !== to) out.push({ label, from: from || '—', to: to || '—' });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
historyLabel(action) {
|
||||
return { create: 'Created', update: 'Updated', delete: 'Deleted' }[action] || action;
|
||||
},
|
||||
historyColor(action) {
|
||||
return { create: 'success', update: 'info', delete: 'error' }[action] || undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ledger-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.ledger-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 140px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.ledger-chip strong {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.gl-history-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
.gl-history-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.gl-diff {
|
||||
font-size: 0.85rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.gl-diff td {
|
||||
padding: 2px 6px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.gl-from {
|
||||
color: rgb(var(--v-theme-error));
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.gl-to {
|
||||
color: rgb(var(--v-theme-success));
|
||||
}
|
||||
</style>
|
||||
|
||||
695
src/views/GlJournalsView.vue
Normal file
695
src/views/GlJournalsView.vue
Normal file
@@ -0,0 +1,695 @@
|
||||
<template>
|
||||
<section class="content-grid">
|
||||
<!-- General ledger (computed depreciation rollup) -->
|
||||
<v-card class="span-12 panel-pad">
|
||||
<div class="toolbar-row mb-3">
|
||||
<h2 class="section-title">General ledger</h2>
|
||||
<v-spacer />
|
||||
<v-select v-model="ledgerBook" :items="bookCodeItems" label="Book" density="compact" hide-details style="max-width:180px" @update:model-value="loadLedger" />
|
||||
<v-text-field v-model.number="ledgerYear" type="number" label="Year" density="compact" hide-details style="max-width:120px" @update:model-value="loadLedger" />
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" color="secondary" size="small" prepend-icon="mdi-download">Export</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item v-for="format in ['pdf', 'xlsx', 'csv']" :key="format" :title="format.toUpperCase()" @click="exportLedger(format)" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<div v-if="ledger">
|
||||
<div v-if="ledger.kind === 'maintenance'" class="ledger-summary mb-4">
|
||||
<div class="ledger-chip"><span class="quiet">Assets serviced</span><strong>{{ ledger.summary.assets }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Services ({{ ledger.year }})</span><strong>{{ ledger.summary.completions }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Total PM spend</span><strong>{{ currency(ledger.summary.cost) }}</strong></div>
|
||||
</div>
|
||||
<div v-else class="ledger-summary mb-4">
|
||||
<div class="ledger-chip"><span class="quiet">Assets</span><strong>{{ ledger.summary.assets }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Cost</span><strong>{{ currency(ledger.summary.cost) }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Accumulated depr.</span><strong>{{ currency(ledger.summary.accumulated) }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Net book value</span><strong>{{ currency(ledger.summary.nbv) }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">{{ ledger.year }} depreciation</span><strong>{{ currency(ledger.summary.depreciation) }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Rule set</span><strong>{{ ledger.rule_set.name || 'Active set' }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div style="overflow-x:auto">
|
||||
<table class="asset-table">
|
||||
<thead>
|
||||
<tr><th>GL account</th><th>Account type</th><th class="text-right">Debit</th><th class="text-right">Credit</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(line, index) in ledger.accounts" :key="index">
|
||||
<td class="font-weight-bold">{{ line.account }}</td>
|
||||
<td>{{ line.type }}</td>
|
||||
<td class="text-right">{{ line.debit ? currency(line.debit) : '' }}</td>
|
||||
<td class="text-right">{{ line.credit ? currency(line.credit) : '' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!ledger.accounts.length"><td colspan="4" class="quiet">No postings for this book and year.</td></tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td class="font-weight-bold" colspan="2">Totals</td>
|
||||
<td class="text-right font-weight-bold">{{ currency(ledger.accountTotals.debit) }}</td>
|
||||
<td class="text-right font-weight-bold">{{ currency(ledger.accountTotals.credit) }}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<v-expansion-panels class="mt-4" variant="accordion">
|
||||
<v-expansion-panel title="Asset detail">
|
||||
<v-expansion-panel-text>
|
||||
<DataTable
|
||||
:columns="detailColumns"
|
||||
:rows="ledger.assets"
|
||||
row-key="asset_id"
|
||||
searchable
|
||||
search-placeholder="Filter assets"
|
||||
empty-text="No assets in this book."
|
||||
>
|
||||
<template #cell-asset_id="{ row }">
|
||||
<span class="font-weight-bold">{{ row.asset_id }}</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</div>
|
||||
<div v-else class="quiet text-body-2">Select a book to view its general ledger.</div>
|
||||
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||
</v-card>
|
||||
|
||||
<!-- Chart of accounts + posting defaults (fully audited) -->
|
||||
<v-card class="span-12 panel-pad">
|
||||
<div class="toolbar-row mb-2">
|
||||
<div>
|
||||
<h2 class="section-title">Chart of accounts</h2>
|
||||
<div class="quiet text-body-2">
|
||||
The company's GL accounts. The <strong>posting defaults</strong> below are the fallback accounts the
|
||||
depreciation & maintenance ledgers post to when an asset has none of its own. Every change is recorded in
|
||||
Admin → Activity & Audit Trail.
|
||||
</div>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<v-btn size="small" color="primary" prepend-icon="mdi-plus" @click="openAccount()">Add account</v-btn>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="accountColumns"
|
||||
:rows="accounts"
|
||||
row-key="id"
|
||||
:page-size="10"
|
||||
has-actions
|
||||
searchable
|
||||
search-placeholder="Filter accounts"
|
||||
empty-text="No GL accounts yet."
|
||||
>
|
||||
<template #cell-type="{ row }">{{ typeLabel(row.type) }}</template>
|
||||
<template #cell-active="{ row }"><v-chip size="x-small" :color="row.active ? 'success' : 'error'" variant="tonal">{{ row.active ? 'active' : 'inactive' }}</v-chip></template>
|
||||
<template #actions="{ row }">
|
||||
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openAccount(row)" />
|
||||
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="removeAccount(row)" />
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<v-divider class="my-4" />
|
||||
<div class="toolbar-row mb-2">
|
||||
<h3 class="section-title">Posting defaults</h3>
|
||||
<v-spacer />
|
||||
<v-btn size="small" color="primary" variant="tonal" prepend-icon="mdi-content-save" :loading="savingDefaults" @click="saveDefaults">Save defaults</v-btn>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<v-select v-model="defaults.asset_account" :items="accountSelectItems" item-title="title" item-value="value" label="Asset cost account" density="compact" clearable />
|
||||
<v-select v-model="defaults.accumulated_account" :items="accountSelectItems" item-title="title" item-value="value" label="Accumulated depreciation account" density="compact" clearable />
|
||||
<v-select v-model="defaults.expense_account" :items="accountSelectItems" item-title="title" item-value="value" label="Depreciation expense account" density="compact" clearable />
|
||||
<v-select v-model="defaults.pm_expense_account" :items="accountSelectItems" item-title="title" item-value="value" label="Maintenance expense account" density="compact" clearable />
|
||||
<v-select v-model="defaults.pm_clearing_account" :items="accountSelectItems" item-title="title" item-value="value" label="Maintenance clearing account" density="compact" clearable />
|
||||
</div>
|
||||
<v-alert v-if="accountError" type="error" density="compact" class="mt-2">{{ accountError }}</v-alert>
|
||||
<v-alert v-if="accountMessage" type="success" density="compact" class="mt-2">{{ accountMessage }}</v-alert>
|
||||
</v-card>
|
||||
|
||||
<!-- GL journal entries (stored, editable, fully audited) -->
|
||||
<v-card class="span-12 panel-pad">
|
||||
<div class="toolbar-row mb-3">
|
||||
<div>
|
||||
<h2 class="section-title">Journal entries</h2>
|
||||
<div class="quiet text-body-2">
|
||||
Manual & imported GL postings for <strong>{{ ledgerBook }}</strong> ({{ ledgerYear }}). All changes are
|
||||
recorded in Admin → Activity & Audit Trail.
|
||||
</div>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-upload" @click="$refs.glFile.click()">Import</v-btn>
|
||||
<input ref="glFile" hidden type="file" accept=".csv,.xlsx,.xls" @change="onImportFile" />
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" size="small" variant="tonal" prepend-icon="mdi-download">Export</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item title="CSV" @click="exportEntries('csv')" />
|
||||
<v-list-item title="Excel (XLSX)" @click="exportEntries('xlsx')" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn size="small" color="primary" prepend-icon="mdi-plus" @click="openEntry()">Add entry</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="ledger-summary mb-3">
|
||||
<div class="ledger-chip"><span class="quiet">Entries</span><strong>{{ entrySummary.count }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Debit</span><strong>{{ currency(entrySummary.debit) }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Credit</span><strong>{{ currency(entrySummary.credit) }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Balance</span><strong>{{ currency(entrySummary.balance) }}</strong></div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="entryColumns"
|
||||
:rows="entries"
|
||||
row-key="id"
|
||||
:page-size="15"
|
||||
has-actions
|
||||
searchable
|
||||
search-placeholder="Filter entries"
|
||||
empty-text="No journal entries for this book and year."
|
||||
>
|
||||
<template #cell-debit="{ row }">{{ row.debit ? currency(row.debit) : '' }}</template>
|
||||
<template #cell-credit="{ row }">{{ row.credit ? currency(row.credit) : '' }}</template>
|
||||
<template #cell-source="{ row }"><v-chip size="x-small" variant="tonal">{{ row.source }}</v-chip></template>
|
||||
<template #actions="{ row }">
|
||||
<v-btn icon="mdi-history" size="small" variant="text" title="Change history" @click="openHistory(row)" />
|
||||
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEntry(row)" />
|
||||
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="removeEntry(row)" />
|
||||
</template>
|
||||
</DataTable>
|
||||
<v-alert v-if="entryError" type="error" density="compact" class="mt-2">{{ entryError }}</v-alert>
|
||||
<v-alert v-if="message" type="success" density="compact" class="mt-2">{{ message }}</v-alert>
|
||||
</v-card>
|
||||
|
||||
<!-- Add / edit GL entry -->
|
||||
<v-dialog v-model="entryDialog" max-width="640">
|
||||
<v-card>
|
||||
<v-card-title>{{ entryForm.id ? 'Edit GL entry' : 'New GL entry' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="form-grid">
|
||||
<v-text-field v-model="entryForm.entry_date" type="date" label="Date *" />
|
||||
<v-combobox v-model="entryForm.account" :items="accountCodes" label="GL account *" hint="Pick from the chart of accounts or type a code" />
|
||||
|
||||
<v-text-field v-model="entryForm.account_type" label="Account type" />
|
||||
<v-text-field v-model="entryForm.reference" label="Reference" />
|
||||
<v-text-field v-model.number="entryForm.debit" type="number" prefix="$" label="Debit" />
|
||||
<v-text-field v-model.number="entryForm.credit" type="number" prefix="$" label="Credit" />
|
||||
<v-text-field v-model="entryForm.external_id" class="full" label="External ID (used to match on re-import)" />
|
||||
<v-textarea v-model="entryForm.description" class="full" label="Description" rows="2" />
|
||||
</div>
|
||||
<v-alert v-if="entryDialogError" type="error" density="compact" class="mt-2">{{ entryDialogError }}</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="entryDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="savingEntry" @click="saveEntry">Save entry</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Import preview / conflict resolution -->
|
||||
<v-dialog v-model="importDialog" max-width="760" scrollable>
|
||||
<v-card v-if="importPreview">
|
||||
<v-card-title>Import preview — {{ ledgerBook }}</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="ledger-summary mb-3">
|
||||
<div class="ledger-chip"><span class="quiet">New</span><strong>{{ importPreview.summary.new }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Update</span><strong>{{ importPreview.summary.update }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Duplicate</span><strong>{{ importPreview.summary.duplicate }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Errors</span><strong>{{ importPreview.summary.error }}</strong></div>
|
||||
</div>
|
||||
<div class="text-body-2 quiet mb-1">Conflict strategy</div>
|
||||
<v-radio-group v-model="importStrategy" inline hide-details density="compact" class="mb-2">
|
||||
<v-radio label="Skip conflicts" value="skip" />
|
||||
<v-radio label="Merge (update existing)" value="merge" />
|
||||
<v-radio label="Insert as new" value="insert" />
|
||||
</v-radio-group>
|
||||
<div style="overflow-x:auto; max-height:340px">
|
||||
<table class="asset-table">
|
||||
<thead><tr><th>Status</th><th>Date</th><th>Account</th><th class="text-right">Debit</th><th class="text-right">Credit</th><th>Reference</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in importPreview.rows" :key="r.index">
|
||||
<td><v-chip size="x-small" :color="statusColor(r.status)" variant="tonal">{{ r.status }}</v-chip><span v-if="r.error" class="quiet text-caption"> · {{ r.error }}</span></td>
|
||||
<td>{{ r.entry.entry_date }}</td>
|
||||
<td>{{ r.entry.account }}</td>
|
||||
<td class="text-right">{{ r.entry.debit ? currency(r.entry.debit) : '' }}</td>
|
||||
<td class="text-right">{{ r.entry.credit ? currency(r.entry.credit) : '' }}</td>
|
||||
<td>{{ r.entry.reference }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="importDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" prepend-icon="mdi-database-import" :loading="importing" @click="commitImport">Import</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Per-entry change history (from the audit log) -->
|
||||
<v-dialog v-model="historyDialog" max-width="640" scrollable>
|
||||
<v-card>
|
||||
<v-card-title>Change history</v-card-title>
|
||||
<v-card-text>
|
||||
<div v-if="historyLoading" class="quiet text-body-2">Loading…</div>
|
||||
<div v-else-if="!historyRows.length" class="quiet text-body-2">No recorded changes.</div>
|
||||
<div v-for="ev in historyRows" :key="ev.id" class="gl-history-item">
|
||||
<div class="toolbar-row mb-1" style="gap:8px">
|
||||
<v-chip size="x-small" :color="historyColor(ev.action)" variant="tonal">{{ historyLabel(ev.action) }}</v-chip>
|
||||
<span class="text-body-2 font-weight-medium">{{ ev.actor_name || 'System' }}</span>
|
||||
<v-spacer />
|
||||
<span class="quiet text-caption">{{ shortDate(ev.created_at) }}</span>
|
||||
</div>
|
||||
<table v-if="changedFields(ev.before, ev.after).length" class="gl-diff">
|
||||
<tr v-for="(c, i) in changedFields(ev.before, ev.after)" :key="i">
|
||||
<td class="quiet">{{ c.label }}</td>
|
||||
<td class="gl-from">{{ c.from }}</td>
|
||||
<td class="px-2">→</td>
|
||||
<td class="gl-to">{{ c.to }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div v-else class="quiet text-caption">No field changes.</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="historyDialog = false">Close</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Add / edit GL account -->
|
||||
<v-dialog v-model="accountDialog" max-width="560">
|
||||
<v-card>
|
||||
<v-card-title>{{ accountForm.id ? 'Edit GL account' : 'New GL account' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="form-grid">
|
||||
<v-text-field v-model="accountForm.code" label="Account code *" />
|
||||
<v-text-field v-model="accountForm.name" label="Account name *" />
|
||||
<v-select v-model="accountForm.type" :items="accountTypeItems" item-title="title" item-value="value" label="Type" />
|
||||
<v-select v-model="accountForm.normal_balance" :items="balanceItems" item-title="title" item-value="value" label="Normal balance" />
|
||||
<v-textarea v-model="accountForm.description" class="full" label="Description" rows="2" />
|
||||
<v-switch v-model="accountForm.active" label="Active" color="primary" density="compact" hide-details />
|
||||
</div>
|
||||
<v-alert v-if="accountDialogError" type="error" density="compact" class="mt-2">{{ accountDialogError }}</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="accountDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" prepend-icon="mdi-content-save" :disabled="!accountForm.code || !accountForm.name" :loading="savingAccount" @click="saveAccount">Save account</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import DataTable from '../components/DataTable.vue';
|
||||
import { apiRequest, saveBlob } from '../services/api';
|
||||
import { currency, shortDate } from '../utils/format';
|
||||
|
||||
// GL and Journals (Financial → GL and Journals). Combines the computed depreciation GL rollup with the
|
||||
// stored, editable journal-entries layer (CRUD, CSV/Excel import with conflict-merge preview, export,
|
||||
// and per-entry change history) for the selected book + year. Loads its own book list to populate the
|
||||
// book picker. Previously these two panels lived on the Books screen.
|
||||
export default {
|
||||
components: { DataTable },
|
||||
props: {
|
||||
token: { type: String, required: true }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
books: [],
|
||||
ledger: null,
|
||||
ledgerBook: 'GAAP',
|
||||
ledgerYear: new Date().getFullYear(),
|
||||
error: '',
|
||||
message: '',
|
||||
entries: [],
|
||||
entrySummary: { count: 0, debit: 0, credit: 0, balance: 0 },
|
||||
entryDialog: false,
|
||||
entryForm: this.blankEntry(),
|
||||
entryDialogError: '',
|
||||
savingEntry: false,
|
||||
entryError: '',
|
||||
importDialog: false,
|
||||
importPreview: null,
|
||||
importStrategy: 'merge',
|
||||
importing: false,
|
||||
historyDialog: false,
|
||||
historyRows: [],
|
||||
historyLoading: false,
|
||||
glFieldLabels: { entry_date: 'Date', account: 'Account', account_type: 'Type', description: 'Description', reference: 'Reference', debit: 'Debit', credit: 'Credit', external_id: 'External ID', asset_id: 'Asset' },
|
||||
accounts: [],
|
||||
defaults: { asset_account: '', accumulated_account: '', expense_account: '', pm_expense_account: '', pm_clearing_account: '' },
|
||||
accountDialog: false,
|
||||
accountForm: this.blankAccount(),
|
||||
accountDialogError: '',
|
||||
savingAccount: false,
|
||||
savingDefaults: false,
|
||||
accountError: '',
|
||||
accountMessage: '',
|
||||
accountTypeItems: [
|
||||
{ title: 'Asset', value: 'asset' },
|
||||
{ title: 'Contra-asset', value: 'contra_asset' },
|
||||
{ title: 'Liability', value: 'liability' },
|
||||
{ title: 'Equity', value: 'equity' },
|
||||
{ title: 'Revenue', value: 'revenue' },
|
||||
{ title: 'Expense', value: 'expense' }
|
||||
],
|
||||
balanceItems: [{ title: 'Debit', value: 'debit' }, { title: 'Credit', value: 'credit' }]
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
bookCodeItems() {
|
||||
return this.books.map((book) => book.code);
|
||||
},
|
||||
detailColumns() {
|
||||
const year = this.ledger?.year || this.ledgerYear;
|
||||
if (this.ledger?.kind === 'maintenance') {
|
||||
return [
|
||||
{ key: 'asset_id', label: 'Asset ID' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'completions', label: 'Services', format: 'number' },
|
||||
{ key: 'last_service', label: 'Last service', format: 'date' },
|
||||
{ key: 'cost', label: `${year} PM cost`, format: 'currency' }
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ key: 'asset_id', label: 'Asset ID' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'asset_account', label: 'Asset acct' },
|
||||
{ key: 'expense_account', label: 'Expense acct' },
|
||||
{ key: 'cost', label: 'Cost', format: 'currency' },
|
||||
{ key: 'depreciation', label: `${year} depr.`, format: 'currency' },
|
||||
{ key: 'accumulated', label: 'Accum.', format: 'currency' },
|
||||
{ key: 'nbv', label: 'NBV', format: 'currency' }
|
||||
];
|
||||
},
|
||||
entryColumns() {
|
||||
return [
|
||||
{ key: 'entry_date', label: 'Date', format: 'date' },
|
||||
{ key: 'account', label: 'Account' },
|
||||
{ key: 'account_type', label: 'Type' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'reference', label: 'Reference' },
|
||||
{ key: 'debit', label: 'Debit', format: 'currency' },
|
||||
{ key: 'credit', label: 'Credit', format: 'currency' },
|
||||
{ key: 'source', label: 'Source', sortable: false }
|
||||
];
|
||||
},
|
||||
accountColumns() {
|
||||
return [
|
||||
{ key: 'code', label: 'Code' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'type', label: 'Type' },
|
||||
{ key: 'normal_balance', label: 'Normal balance' },
|
||||
{ key: 'description', label: 'Description' },
|
||||
{ key: 'active', label: 'Status', sortable: false }
|
||||
];
|
||||
},
|
||||
accountSelectItems() {
|
||||
return this.accounts.map((account) => ({ title: `${account.code} — ${account.name}`, value: account.code }));
|
||||
},
|
||||
accountCodes() {
|
||||
return this.accounts.map((account) => account.code);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadBooks();
|
||||
this.loadAccounts();
|
||||
},
|
||||
methods: {
|
||||
currency,
|
||||
shortDate,
|
||||
async api(path, options = {}) {
|
||||
return apiRequest(path, this.token, options);
|
||||
},
|
||||
// Load the company's books to populate the book picker, then show the selected book's ledger/entries.
|
||||
async loadBooks() {
|
||||
try {
|
||||
const data = await (await this.api('/api/books')).json();
|
||||
this.books = data.books;
|
||||
if (this.books.length) {
|
||||
if (!this.books.some((book) => book.code === this.ledgerBook)) this.ledgerBook = this.books[0].code;
|
||||
this.loadLedger();
|
||||
}
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
}
|
||||
},
|
||||
async loadLedger() {
|
||||
if (!this.ledgerBook) return;
|
||||
this.error = '';
|
||||
try {
|
||||
const data = await (await this.api(`/api/books/${this.ledgerBook}/ledger?year=${this.ledgerYear}`)).json();
|
||||
this.ledger = data.ledger;
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
}
|
||||
this.loadEntries();
|
||||
},
|
||||
async exportLedger(format) {
|
||||
const blob = await (await this.api(`/api/books/${this.ledgerBook}/ledger/export`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ year: this.ledgerYear, format })
|
||||
})).blob();
|
||||
saveBlob(blob, `deprecore-${this.ledgerBook}-ledger-${this.ledgerYear}.${format}`);
|
||||
},
|
||||
// ---- GL journal entries -------------------------------------------------
|
||||
blankEntry() {
|
||||
return { id: null, entry_date: new Date().toISOString().slice(0, 10), account: '', account_type: '', description: '', reference: '', debit: 0, credit: 0, external_id: '' };
|
||||
},
|
||||
async loadEntries() {
|
||||
if (!this.ledgerBook) return;
|
||||
this.entryError = '';
|
||||
try {
|
||||
const data = await (await this.api(`/api/books/${this.ledgerBook}/gl-entries?year=${this.ledgerYear}`)).json();
|
||||
this.entries = data.entries;
|
||||
this.entrySummary = data.summary;
|
||||
} catch (error) {
|
||||
this.entryError = error.message;
|
||||
}
|
||||
},
|
||||
openEntry(row) {
|
||||
this.entryForm = row ? { ...this.blankEntry(), ...row } : this.blankEntry();
|
||||
this.entryDialogError = '';
|
||||
this.entryDialog = true;
|
||||
},
|
||||
async saveEntry() {
|
||||
this.savingEntry = true;
|
||||
this.entryDialogError = '';
|
||||
this.message = '';
|
||||
try {
|
||||
const method = this.entryForm.id ? 'PUT' : 'POST';
|
||||
const url = this.entryForm.id
|
||||
? `/api/books/${this.ledgerBook}/gl-entries/${this.entryForm.id}`
|
||||
: `/api/books/${this.ledgerBook}/gl-entries`;
|
||||
await this.api(url, { method, body: JSON.stringify(this.entryForm) });
|
||||
this.entryDialog = false;
|
||||
await this.loadEntries();
|
||||
} catch (error) {
|
||||
this.entryDialogError = error.message;
|
||||
} finally {
|
||||
this.savingEntry = false;
|
||||
}
|
||||
},
|
||||
async removeEntry(row) {
|
||||
if (!window.confirm('Delete this GL entry? The change is recorded in the audit trail.')) return;
|
||||
this.entryError = '';
|
||||
try {
|
||||
await this.api(`/api/books/${this.ledgerBook}/gl-entries/${row.id}`, { method: 'DELETE' });
|
||||
await this.loadEntries();
|
||||
} catch (error) {
|
||||
this.entryError = error.message;
|
||||
}
|
||||
},
|
||||
// Import is a stateless two-step: upload the file for a dry-run preview (rows classified
|
||||
// new/update/duplicate/error), then commit the returned classified rows with the chosen strategy —
|
||||
// so the file is never re-uploaded and the server keeps no session between the two calls.
|
||||
async onImportFile(event) {
|
||||
const file = event.target.files[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
this.entryError = '';
|
||||
this.message = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const data = await (await this.api(`/api/books/${this.ledgerBook}/gl-entries/import/preview`, { method: 'POST', body: form })).json();
|
||||
this.importPreview = data;
|
||||
this.importStrategy = 'merge';
|
||||
this.importDialog = true;
|
||||
} catch (error) {
|
||||
this.entryError = `Import failed: ${error.message}`;
|
||||
}
|
||||
},
|
||||
async commitImport() {
|
||||
this.importing = true;
|
||||
try {
|
||||
const result = await (await this.api(`/api/books/${this.ledgerBook}/gl-entries/import/commit`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rows: this.importPreview.rows, strategy: this.importStrategy })
|
||||
})).json();
|
||||
this.importDialog = false;
|
||||
this.message = `Imported: ${result.inserted} new, ${result.updated} updated, ${result.skipped} skipped${result.errors ? `, ${result.errors} error(s)` : ''}.`;
|
||||
await this.loadEntries();
|
||||
} catch (error) {
|
||||
this.entryError = error.message;
|
||||
} finally {
|
||||
this.importing = false;
|
||||
}
|
||||
},
|
||||
async exportEntries(format) {
|
||||
const blob = await (await this.api(`/api/books/${this.ledgerBook}/gl-entries/export?year=${this.ledgerYear}&format=${format}`)).blob();
|
||||
saveBlob(blob, `deprecore-${this.ledgerBook}-gl-entries.${format}`);
|
||||
},
|
||||
statusColor(status) {
|
||||
return { new: 'success', update: 'info', duplicate: 'warning', error: 'error' }[status] || undefined;
|
||||
},
|
||||
async openHistory(row) {
|
||||
this.historyRows = [];
|
||||
this.historyLoading = true;
|
||||
this.historyDialog = true;
|
||||
try {
|
||||
const data = await (await this.api(`/api/books/${this.ledgerBook}/gl-entries/${row.id}/history`)).json();
|
||||
this.historyRows = data.history;
|
||||
} catch (error) {
|
||||
this.entryError = error.message;
|
||||
this.historyDialog = false;
|
||||
} finally {
|
||||
this.historyLoading = false;
|
||||
}
|
||||
},
|
||||
// Field-level diff between an audit event's before/after snapshots.
|
||||
changedFields(before, after) {
|
||||
const norm = (v) => (v === null || v === undefined || v === '' ? '' : String(v));
|
||||
const b = before || {};
|
||||
const a = after || {};
|
||||
const out = [];
|
||||
for (const [key, label] of Object.entries(this.glFieldLabels)) {
|
||||
const from = norm(b[key]);
|
||||
const to = norm(a[key]);
|
||||
if (from !== to) out.push({ label, from: from || '—', to: to || '—' });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
historyLabel(action) {
|
||||
return { create: 'Created', update: 'Updated', delete: 'Deleted' }[action] || action;
|
||||
},
|
||||
historyColor(action) {
|
||||
return { create: 'success', update: 'info', delete: 'error' }[action] || undefined;
|
||||
},
|
||||
// ---- Chart of accounts + posting defaults -------------------------------
|
||||
blankAccount() {
|
||||
return { id: null, code: '', name: '', type: 'asset', normal_balance: 'debit', description: '', active: true };
|
||||
},
|
||||
typeLabel(type) {
|
||||
return { asset: 'Asset', contra_asset: 'Contra-asset', liability: 'Liability', equity: 'Equity', revenue: 'Revenue', expense: 'Expense' }[type] || type;
|
||||
},
|
||||
async loadAccounts() {
|
||||
this.accountError = '';
|
||||
try {
|
||||
const data = await (await this.api('/api/gl-accounts')).json();
|
||||
this.accounts = data.accounts;
|
||||
this.defaults = { ...this.defaults, ...data.defaults };
|
||||
} catch (error) {
|
||||
this.accountError = error.message;
|
||||
}
|
||||
},
|
||||
openAccount(row) {
|
||||
this.accountForm = row ? { ...this.blankAccount(), ...row } : this.blankAccount();
|
||||
this.accountDialogError = '';
|
||||
this.accountDialog = true;
|
||||
},
|
||||
async saveAccount() {
|
||||
this.savingAccount = true;
|
||||
this.accountDialogError = '';
|
||||
this.accountMessage = '';
|
||||
try {
|
||||
const method = this.accountForm.id ? 'PUT' : 'POST';
|
||||
const url = this.accountForm.id ? `/api/gl-accounts/${this.accountForm.id}` : '/api/gl-accounts';
|
||||
await this.api(url, { method, body: JSON.stringify(this.accountForm) });
|
||||
this.accountDialog = false;
|
||||
await this.loadAccounts();
|
||||
} catch (error) {
|
||||
this.accountDialogError = error.message;
|
||||
} finally {
|
||||
this.savingAccount = false;
|
||||
}
|
||||
},
|
||||
async removeAccount(row) {
|
||||
if (!window.confirm(`Delete account ${row.code} — ${row.name}? The change is recorded in the audit trail.`)) return;
|
||||
this.accountError = '';
|
||||
try {
|
||||
await this.api(`/api/gl-accounts/${row.id}`, { method: 'DELETE' });
|
||||
await this.loadAccounts();
|
||||
} catch (error) {
|
||||
this.accountError = error.message;
|
||||
}
|
||||
},
|
||||
async saveDefaults() {
|
||||
this.savingDefaults = true;
|
||||
this.accountError = '';
|
||||
this.accountMessage = '';
|
||||
try {
|
||||
const data = await (await this.api('/api/gl-account-defaults', { method: 'PUT', body: JSON.stringify(this.defaults) })).json();
|
||||
this.defaults = { ...this.defaults, ...data.defaults };
|
||||
this.accountMessage = 'Posting defaults saved.';
|
||||
this.loadLedger(); // the ledger's fallback accounts changed — refresh it
|
||||
} catch (error) {
|
||||
this.accountError = error.message;
|
||||
} finally {
|
||||
this.savingDefaults = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ledger-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.ledger-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 140px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.ledger-chip strong {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.gl-history-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.2);
|
||||
}
|
||||
.gl-history-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.gl-diff {
|
||||
font-size: 0.85rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.gl-diff td {
|
||||
padding: 2px 6px;
|
||||
vertical-align: top;
|
||||
}
|
||||
.gl-from {
|
||||
color: rgb(var(--v-theme-error));
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.gl-to {
|
||||
color: rgb(var(--v-theme-success));
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user