Updated GL system allows changing account defaults and add/updating journal entries

This commit is contained in:
2026-06-08 11:43:02 -05:00
parent 582720bf01
commit 15e93c1e45
16 changed files with 980 additions and 495 deletions

View File

@@ -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'],

View File

@@ -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 {

View File

@@ -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 },

View File

@@ -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 };
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -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' }
]
},

View File

@@ -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 books 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 &amp; imported GL postings for <strong>{{ ledgerBook }}</strong> ({{ ledgerYear }}). All changes are
recorded in Admin Activity &amp; 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>

View 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 &amp; maintenance ledgers post to when an asset has none of its own. Every change is recorded in
Admin → Activity &amp; 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 &amp; imported GL postings for <strong>{{ ledgerBook }}</strong> ({{ ledgerYear }}). All changes are
recorded in Admin → Activity &amp; 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>