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

@@ -438,6 +438,31 @@ function initialize() {
CREATE INDEX IF NOT EXISTS idx_gl_entries_scope ON gl_entries (entity_id, book_code, fiscal_year);
CREATE INDEX IF NOT EXISTS idx_gl_entries_extid ON gl_entries (entity_id, book_code, external_id);
CREATE TABLE IF NOT EXISTS gl_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_id INTEGER REFERENCES entities(id),
code TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'asset',
normal_balance TEXT NOT NULL DEFAULT 'debit',
description TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(entity_id, code)
);
CREATE TABLE IF NOT EXISTS gl_account_defaults (
entity_id INTEGER PRIMARY KEY REFERENCES entities(id) ON DELETE CASCADE,
asset_account TEXT,
accumulated_account TEXT,
expense_account TEXT,
pm_expense_account TEXT,
pm_clearing_account TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
@@ -957,6 +982,8 @@ function backfillCompanyScope() {
if (!one('SELECT 1 AS x FROM user_companies LIMIT 1')) {
run('INSERT OR IGNORE INTO user_companies (user_id, entity_id) SELECT u.id, e.id FROM users u CROSS JOIN entities e');
}
// Seed a starter chart of accounts + GL posting defaults for any existing company that lacks one.
for (const company of all('SELECT id FROM entities')) seedGlAccountsForCompany(company.id);
}
// Custom roles require the legacy CHECK(role IN (...)) constraint on `users` to be removed.
@@ -1041,6 +1068,33 @@ function seedReferenceValuesForCompany(entityId) {
}
}
// Seed a starter chart of accounts and the GL posting defaults for a company (idempotent per company).
// The defaults are the fallback accounts the depreciation/PM ledgers post to when an asset doesn't
// carry its own. Called during initial seed and when a new company is created.
function seedGlAccountsForCompany(entityId) {
if (!entityId || one('SELECT id FROM gl_accounts WHERE entity_id = ? LIMIT 1', [entityId])) return;
const ts = now();
const accounts = [
['1500', 'Fixed Assets', 'asset', 'debit', 'Asset cost / basis'],
['1590', 'Accumulated Depreciation', 'contra_asset', 'credit', 'Accumulated depreciation (contra-asset)'],
['2000', 'Maintenance Clearing', 'liability', 'credit', 'PM/maintenance clearing'],
['6400', 'Depreciation Expense', 'expense', 'debit', 'Periodic depreciation expense'],
['6450', 'Maintenance Expense', 'expense', 'debit', 'Preventative-maintenance spend']
];
for (const [code, name, type, normal, description] of accounts) {
run(
`INSERT OR IGNORE INTO gl_accounts (entity_id, code, name, type, normal_balance, description, active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`,
[entityId, code, name, type, normal, description, ts, ts]
);
}
run(
`INSERT OR IGNORE INTO gl_account_defaults (entity_id, asset_account, accumulated_account, expense_account, pm_expense_account, pm_clearing_account, updated_at)
VALUES (?, '1500', '1590', '6400', '6450', '2000', ?)`,
[entityId, ts]
);
}
function seed() {
const createdAt = now();
@@ -1170,7 +1224,10 @@ function seed() {
}
const refEntity = one('SELECT id FROM entities ORDER BY id LIMIT 1');
if (refEntity) seedReferenceValuesForCompany(refEntity.id);
if (refEntity) {
seedReferenceValuesForCompany(refEntity.id);
seedGlAccountsForCompany(refEntity.id);
}
run(
`INSERT OR IGNORE INTO employees (
@@ -1301,6 +1358,7 @@ module.exports = {
parseJson,
run,
seedBooksForCompany,
seedGlAccountsForCompany,
seedReferenceValuesForCompany,
tx
};

View File

@@ -5,6 +5,7 @@ const { bookLedger, createBook, deleteBook, ledgerReport, listBooks, updateBook
const { exportReport } = require('../services/reportExport');
const { rowsFromImport, extensionForUpload } = require('../services/importExport');
const glEntries = require('../services/glEntries');
const glAccounts = require('../services/glAccounts');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
const router = express.Router();
@@ -49,6 +50,44 @@ router.post('/books/:code/ledger/export', async (req, res) => {
return res.send(output.body);
});
// ---- GL chart of accounts + posting defaults (full audit) -------------------
router.get('/gl-accounts', ledgerReader, (req, res) => {
res.json({ accounts: glAccounts.listAccounts(req.companyId), defaults: glAccounts.getDefaults(req.companyId) });
});
router.post('/gl-accounts', ledgerEditor, (req, res, next) => {
try {
res.status(201).json({ account: glAccounts.createAccount(req.body, req.user.id, req.companyId) });
} catch (error) {
next(error);
}
});
router.put('/gl-accounts/:id', ledgerEditor, (req, res, next) => {
try {
const account = glAccounts.updateAccount(req.params.id, req.body, req.user.id, req.companyId);
if (!account) return res.status(404).json({ error: 'GL account not found' });
return res.json({ account });
} catch (error) {
return next(error);
}
});
router.delete('/gl-accounts/:id', ledgerEditor, (req, res) => {
if (!glAccounts.deleteAccount(req.params.id, req.user.id, req.companyId)) return res.status(404).json({ error: 'GL account not found' });
return res.status(204).end();
});
// GL posting defaults (the fallback accounts the ledgers post to).
router.get('/gl-account-defaults', ledgerReader, (req, res) => {
res.json({ defaults: glAccounts.getDefaults(req.companyId) });
});
router.put('/gl-account-defaults', ledgerEditor, (req, res) => {
res.json({ defaults: glAccounts.saveDefaults(req.body, req.user.id, req.companyId) });
});
// ---- GL journal entries (stored, editable; full audit) ----------------------
function glFilters(query, code) {

View File

@@ -4,12 +4,7 @@ const { ruleSetForBook } = require('./ruleSets');
const { computeYear } = require('./reportEngine');
const { assetFromRow } = require('./assets');
const { pmBookLedger } = require('./pm');
const DEFAULT_ACCOUNTS = {
asset: '1500',
accumulated: '1590',
expense: '6400'
};
const { getDefaults } = require('./glAccounts');
function bookFromRow(row) {
return {
@@ -20,6 +15,9 @@ function bookFromRow(row) {
}
function listBooks(companyId) {
// book.code is unique only per company, and asset_books link by code string — so the asset_count
// subquery joins through to the asset and matches a.entity_id = b.entity_id to count only this
// company's assets on this company's book.
const books = all(
`SELECT b.*, t.name AS tax_rule_set_name, t.version AS tax_rule_set_version,
(SELECT COUNT(*) FROM asset_books ab JOIN assets a ON a.id = ab.asset_id
@@ -115,6 +113,7 @@ function bookLedger(code, year, companyId) {
return { ...pmBookLedger(year, companyId), book };
}
const rules = ruleSetForBook(code);
const defaults = getDefaults(companyId); // per-company fallback accounts (admin-configurable)
const rows = all(
`SELECT a.*, b.cost AS book_cost, b.depreciation_method, b.convention,
b.useful_life_months AS book_life, b.section_179_amount, b.bonus_percent,
@@ -152,9 +151,9 @@ function bookLedger(code, year, companyId) {
manual_depreciation: row.manual_depreciation
};
const c = computeYear(asset, bk, rules, year);
const assetAccount = asset.gl_asset_account || DEFAULT_ACCOUNTS.asset;
const accumAccount = asset.gl_accumulated_account || DEFAULT_ACCOUNTS.accumulated;
const expenseAccount = asset.gl_expense_account || DEFAULT_ACCOUNTS.expense;
const assetAccount = asset.gl_asset_account || defaults.asset_account;
const accumAccount = asset.gl_accumulated_account || defaults.accumulated_account;
const expenseAccount = asset.gl_expense_account || defaults.expense_account;
addLine(assetAccount, 'Asset cost', c.cost, 0);
addLine(accumAccount, 'Accumulated depreciation', 0, c.accumulated_end);

View File

@@ -1,4 +1,4 @@
const { all, audit, now, one, run, seedBooksForCompany, seedReferenceValuesForCompany } = require('../db');
const { all, audit, now, one, run, seedBooksForCompany, seedGlAccountsForCompany, seedReferenceValuesForCompany } = require('../db');
// Companies are stored in the `entities` table. Each gets its own books/GL, PM plans, alerts, and
// (in phase 2) reference data. Disposing a company is a soft status change that preserves all history.
@@ -29,9 +29,10 @@ function createCompany(body, userId) {
Number(body.fiscal_year_end_month || 12), body.base_currency || 'USD', ts, ts
]
);
// A new company starts with the standard book set + reference data (categories/locations/depts).
// A new company starts with the standard book set, reference data, and chart of accounts + defaults.
seedBooksForCompany(result.lastInsertRowid);
seedReferenceValuesForCompany(result.lastInsertRowid);
seedGlAccountsForCompany(result.lastInsertRowid);
audit(userId, 'create', 'entity', result.lastInsertRowid, null, { name });
return getCompany(result.lastInsertRowid);
}

View File

@@ -0,0 +1,105 @@
const { all, audit, now, one, run } = require('../db');
// Per-company chart of accounts plus the GL "posting defaults" — the fallback accounts the
// depreciation and PM ledgers post to when an asset doesn't carry its own. Every change (account
// create/update/delete and a defaults change) is logged via audit() for the Activity & Audit Trail.
const TYPES = ['asset', 'contra_asset', 'liability', 'equity', 'revenue', 'expense'];
const BALANCES = ['debit', 'credit'];
// Used as a last-resort fallback if a company somehow has no defaults row (seed normally creates one).
const FALLBACK_DEFAULTS = { asset_account: '1500', accumulated_account: '1590', expense_account: '6400', pm_expense_account: '6450', pm_clearing_account: '2000' };
function fromRow(row) {
if (!row) return null;
return { ...row, active: Boolean(row.active) };
}
function listAccounts(companyId) {
return all('SELECT * FROM gl_accounts WHERE entity_id = ? ORDER BY code', [companyId]).map(fromRow);
}
function getAccount(id, companyId) {
return fromRow(one('SELECT * FROM gl_accounts WHERE id = ? AND entity_id = ?', [id, companyId]));
}
function normalize(body) {
return {
code: String(body.code || '').trim(),
name: String(body.name || '').trim(),
type: TYPES.includes(body.type) ? body.type : 'asset',
normal_balance: BALANCES.includes(body.normal_balance) ? body.normal_balance : 'debit',
description: body.description ? String(body.description).trim() : null,
active: body.active === false ? 0 : 1
};
}
function createAccount(body, userId, companyId) {
const input = normalize(body);
if (!input.code) throw Object.assign(new Error('An account code is required'), { status: 400 });
if (!input.name) throw Object.assign(new Error('An account name is required'), { status: 400 });
if (one('SELECT id FROM gl_accounts WHERE entity_id = ? AND code = ?', [companyId, input.code])) {
throw Object.assign(new Error(`Account ${input.code} already exists`), { status: 400 });
}
const ts = now();
const result = run(
`INSERT INTO gl_accounts (entity_id, code, name, type, normal_balance, description, active, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[companyId, input.code, input.name, input.type, input.normal_balance, input.description, input.active, userId, ts, ts]
);
const account = getAccount(result.lastInsertRowid, companyId);
audit(userId, 'create', 'gl_account', result.lastInsertRowid, null, account);
return account;
}
function updateAccount(id, body, userId, companyId) {
const before = getAccount(id, companyId);
if (!before) return null;
const input = normalize({ ...before, ...body });
if (input.code !== before.code && one('SELECT id FROM gl_accounts WHERE entity_id = ? AND code = ? AND id <> ?', [companyId, input.code, id])) {
throw Object.assign(new Error(`Account ${input.code} already exists`), { status: 400 });
}
run(
'UPDATE gl_accounts SET code = ?, name = ?, type = ?, normal_balance = ?, description = ?, active = ?, updated_at = ? WHERE id = ?',
[input.code, input.name, input.type, input.normal_balance, input.description, input.active, now(), id]
);
const after = getAccount(id, companyId);
audit(userId, 'update', 'gl_account', id, before, after);
return after;
}
function deleteAccount(id, userId, companyId) {
const before = getAccount(id, companyId);
if (!before) return false;
run('DELETE FROM gl_accounts WHERE id = ?', [id]);
audit(userId, 'delete', 'gl_account', id, before, null);
return true;
}
// ---- Posting defaults -------------------------------------------------------
function getDefaults(companyId) {
const row = one('SELECT * FROM gl_account_defaults WHERE entity_id = ?', [companyId]);
if (!row) return { entity_id: companyId, ...FALLBACK_DEFAULTS };
return row;
}
function saveDefaults(body, userId, companyId) {
const before = getDefaults(companyId);
const keys = ['asset_account', 'accumulated_account', 'expense_account', 'pm_expense_account', 'pm_clearing_account'];
const next = {};
for (const key of keys) next[key] = body[key] !== undefined ? (String(body[key] || '').trim() || null) : (before[key] || null);
run(
`INSERT INTO gl_account_defaults (entity_id, asset_account, accumulated_account, expense_account, pm_expense_account, pm_clearing_account, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(entity_id) DO UPDATE SET
asset_account = excluded.asset_account, accumulated_account = excluded.accumulated_account,
expense_account = excluded.expense_account, pm_expense_account = excluded.pm_expense_account,
pm_clearing_account = excluded.pm_clearing_account, updated_at = excluded.updated_at`,
[companyId, next.asset_account, next.accumulated_account, next.expense_account, next.pm_expense_account, next.pm_clearing_account, now()]
);
const after = getDefaults(companyId);
audit(userId, 'update', 'gl_account_defaults', companyId, before, after);
return after;
}
module.exports = { TYPES, createAccount, deleteAccount, getAccount, getDefaults, listAccounts, saveDefaults, updateAccount };

View File

@@ -635,9 +635,11 @@ function pmBookLedger(year, companyId) {
count += 1;
}
const assets = Object.values(byAsset).map((a) => ({ ...a, cost: round2(a.cost) })).sort((a, b) => b.cost - a.cost);
// Post PM spend to the company's configured maintenance expense / clearing accounts (admin-set defaults).
const defaults = require('./glAccounts').getDefaults(companyId);
const accounts = [
{ account: '6450', type: 'PM / maintenance expense', debit: round2(total), credit: 0 },
{ account: '2000', type: 'Maintenance clearing', debit: 0, credit: round2(total) }
{ account: defaults.pm_expense_account || '6450', type: 'PM / maintenance expense', debit: round2(total), credit: 0 },
{ account: defaults.pm_clearing_account || '2000', type: 'Maintenance clearing', debit: 0, credit: round2(total) }
];
return {
year: Number(year),

View File

@@ -1839,6 +1839,36 @@ async function main() {
const glB = (await request('/api/books/GAAP/gl-entries', { headers: companyHeaders(companyB) })).entries;
if (glB.some((e) => e.id === glEntry.id)) throw new Error('Company B sees company A GL entries');
// ---- GL chart of accounts + posting defaults -------------------------------
const seededChart = await request('/api/gl-accounts', { headers: companyHeaders(companyA) });
if (seededChart.accounts.length < 5 || !seededChart.defaults.asset_account) throw new Error('Chart of accounts / posting defaults were not seeded');
const newAcct = (await request('/api/gl-accounts', {
method: 'POST', headers: companyHeaders(companyA),
body: JSON.stringify({ code: `1501-${suffix}`, name: 'Equipment — Co A', type: 'asset', normal_balance: 'debit' })
})).account;
if (!newAcct || newAcct.entity_id !== companyA) throw new Error('GL account was not created/scoped');
await request(`/api/gl-accounts/${newAcct.id}`, { method: 'PUT', headers: companyHeaders(companyA), body: JSON.stringify({ name: 'Equipment — revised' }) });
// Point the asset-cost posting default at the new account and confirm the ledger posts to it.
await request('/api/gl-account-defaults', { method: 'PUT', headers: companyHeaders(companyA), body: JSON.stringify({ asset_account: `1501-${suffix}` }) });
const ledgerA = (await request('/api/books/GAAP/ledger?year=2026', { headers: companyHeaders(companyA) })).ledger;
if (!ledgerA.accounts.some((a) => a.account === `1501-${suffix}` && a.type === 'Asset cost')) {
throw new Error('Ledger did not use the configured default asset account');
}
// GL account + defaults changes are audited.
const acctAudit = await request('/api/audit-logs?entity_type=gl_account', { headers: auth });
if (acctAudit.total < 2) throw new Error('GL account changes were not audited');
if ((await request('/api/audit-logs?entity_type=gl_account_defaults', { headers: auth })).total < 1) {
throw new Error('GL account-default change was not audited');
}
// Isolation: company B has its own seeded chart, not company A's custom account.
const acctsB = (await request('/api/gl-accounts', { headers: companyHeaders(companyB) })).accounts;
if (!acctsB.length) throw new Error('Company B chart of accounts was not seeded');
if (acctsB.some((a) => a.code === `1501-${suffix}`)) throw new Error('Company B sees company A GL account');
console.log('Smoke test passed');
}

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>