Updated GL system allows changing account defaults and add/updating journal entries
This commit is contained in:
60
server/db.js
60
server/db.js
@@ -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_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 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 (
|
CREATE TABLE IF NOT EXISTS alerts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
@@ -957,6 +982,8 @@ function backfillCompanyScope() {
|
|||||||
if (!one('SELECT 1 AS x FROM user_companies LIMIT 1')) {
|
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');
|
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.
|
// 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() {
|
function seed() {
|
||||||
const createdAt = now();
|
const createdAt = now();
|
||||||
|
|
||||||
@@ -1170,7 +1224,10 @@ function seed() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const refEntity = one('SELECT id FROM entities ORDER BY id LIMIT 1');
|
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(
|
run(
|
||||||
`INSERT OR IGNORE INTO employees (
|
`INSERT OR IGNORE INTO employees (
|
||||||
@@ -1301,6 +1358,7 @@ module.exports = {
|
|||||||
parseJson,
|
parseJson,
|
||||||
run,
|
run,
|
||||||
seedBooksForCompany,
|
seedBooksForCompany,
|
||||||
|
seedGlAccountsForCompany,
|
||||||
seedReferenceValuesForCompany,
|
seedReferenceValuesForCompany,
|
||||||
tx
|
tx
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const { bookLedger, createBook, deleteBook, ledgerReport, listBooks, updateBook
|
|||||||
const { exportReport } = require('../services/reportExport');
|
const { exportReport } = require('../services/reportExport');
|
||||||
const { rowsFromImport, extensionForUpload } = require('../services/importExport');
|
const { rowsFromImport, extensionForUpload } = require('../services/importExport');
|
||||||
const glEntries = require('../services/glEntries');
|
const glEntries = require('../services/glEntries');
|
||||||
|
const glAccounts = require('../services/glAccounts');
|
||||||
|
|
||||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -49,6 +50,44 @@ router.post('/books/:code/ledger/export', async (req, res) => {
|
|||||||
return res.send(output.body);
|
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) ----------------------
|
// ---- GL journal entries (stored, editable; full audit) ----------------------
|
||||||
|
|
||||||
function glFilters(query, code) {
|
function glFilters(query, code) {
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ const { ruleSetForBook } = require('./ruleSets');
|
|||||||
const { computeYear } = require('./reportEngine');
|
const { computeYear } = require('./reportEngine');
|
||||||
const { assetFromRow } = require('./assets');
|
const { assetFromRow } = require('./assets');
|
||||||
const { pmBookLedger } = require('./pm');
|
const { pmBookLedger } = require('./pm');
|
||||||
|
const { getDefaults } = require('./glAccounts');
|
||||||
const DEFAULT_ACCOUNTS = {
|
|
||||||
asset: '1500',
|
|
||||||
accumulated: '1590',
|
|
||||||
expense: '6400'
|
|
||||||
};
|
|
||||||
|
|
||||||
function bookFromRow(row) {
|
function bookFromRow(row) {
|
||||||
return {
|
return {
|
||||||
@@ -20,6 +15,9 @@ function bookFromRow(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function listBooks(companyId) {
|
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(
|
const books = all(
|
||||||
`SELECT b.*, t.name AS tax_rule_set_name, t.version AS tax_rule_set_version,
|
`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
|
(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 };
|
return { ...pmBookLedger(year, companyId), book };
|
||||||
}
|
}
|
||||||
const rules = ruleSetForBook(code);
|
const rules = ruleSetForBook(code);
|
||||||
|
const defaults = getDefaults(companyId); // per-company fallback accounts (admin-configurable)
|
||||||
const rows = all(
|
const rows = all(
|
||||||
`SELECT a.*, b.cost AS book_cost, b.depreciation_method, b.convention,
|
`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,
|
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
|
manual_depreciation: row.manual_depreciation
|
||||||
};
|
};
|
||||||
const c = computeYear(asset, bk, rules, year);
|
const c = computeYear(asset, bk, rules, year);
|
||||||
const assetAccount = asset.gl_asset_account || DEFAULT_ACCOUNTS.asset;
|
const assetAccount = asset.gl_asset_account || defaults.asset_account;
|
||||||
const accumAccount = asset.gl_accumulated_account || DEFAULT_ACCOUNTS.accumulated;
|
const accumAccount = asset.gl_accumulated_account || defaults.accumulated_account;
|
||||||
const expenseAccount = asset.gl_expense_account || DEFAULT_ACCOUNTS.expense;
|
const expenseAccount = asset.gl_expense_account || defaults.expense_account;
|
||||||
|
|
||||||
addLine(assetAccount, 'Asset cost', c.cost, 0);
|
addLine(assetAccount, 'Asset cost', c.cost, 0);
|
||||||
addLine(accumAccount, 'Accumulated depreciation', 0, c.accumulated_end);
|
addLine(accumAccount, 'Accumulated depreciation', 0, c.accumulated_end);
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// (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
|
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);
|
seedBooksForCompany(result.lastInsertRowid);
|
||||||
seedReferenceValuesForCompany(result.lastInsertRowid);
|
seedReferenceValuesForCompany(result.lastInsertRowid);
|
||||||
|
seedGlAccountsForCompany(result.lastInsertRowid);
|
||||||
audit(userId, 'create', 'entity', result.lastInsertRowid, null, { name });
|
audit(userId, 'create', 'entity', result.lastInsertRowid, null, { name });
|
||||||
return getCompany(result.lastInsertRowid);
|
return getCompany(result.lastInsertRowid);
|
||||||
}
|
}
|
||||||
|
|||||||
105
server/services/glAccounts.js
Normal file
105
server/services/glAccounts.js
Normal 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 };
|
||||||
@@ -635,9 +635,11 @@ function pmBookLedger(year, companyId) {
|
|||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
const assets = Object.values(byAsset).map((a) => ({ ...a, cost: round2(a.cost) })).sort((a, b) => b.cost - a.cost);
|
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 = [
|
const accounts = [
|
||||||
{ account: '6450', type: 'PM / maintenance expense', debit: round2(total), credit: 0 },
|
{ account: defaults.pm_expense_account || '6450', type: 'PM / maintenance expense', debit: round2(total), credit: 0 },
|
||||||
{ account: '2000', type: 'Maintenance clearing', debit: 0, credit: round2(total) }
|
{ account: defaults.pm_clearing_account || '2000', type: 'Maintenance clearing', debit: 0, credit: round2(total) }
|
||||||
];
|
];
|
||||||
return {
|
return {
|
||||||
year: Number(year),
|
year: Number(year),
|
||||||
|
|||||||
@@ -1839,6 +1839,36 @@ async function main() {
|
|||||||
const glB = (await request('/api/books/GAAP/gl-entries', { headers: companyHeaders(companyB) })).entries;
|
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');
|
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');
|
console.log('Smoke test passed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
src/App.vue
13
src/App.vue
@@ -86,6 +86,8 @@
|
|||||||
</template>
|
</template>
|
||||||
</TopNav>
|
</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">
|
<v-main id="main-content" :key="`company-${activeCompanyId}`" tabindex="-1">
|
||||||
<DashboardView
|
<DashboardView
|
||||||
v-if="activeView === 'dashboard'"
|
v-if="activeView === 'dashboard'"
|
||||||
@@ -165,6 +167,10 @@
|
|||||||
:method-items="methodItems"
|
:method-items="methodItems"
|
||||||
@updated="loadBookCodes"
|
@updated="loadBookCodes"
|
||||||
/>
|
/>
|
||||||
|
<GlJournalsView
|
||||||
|
v-if="activeView === 'gl-journals'"
|
||||||
|
:token="token"
|
||||||
|
/>
|
||||||
<TemplatesView
|
<TemplatesView
|
||||||
v-if="activeView === 'templates'"
|
v-if="activeView === 'templates'"
|
||||||
:category-items="categoryItems"
|
:category-items="categoryItems"
|
||||||
@@ -306,6 +312,7 @@ import AdminView from './views/AdminView.vue';
|
|||||||
import AlertsView from './views/AlertsView.vue';
|
import AlertsView from './views/AlertsView.vue';
|
||||||
import AssignmentsView from './views/AssignmentsView.vue';
|
import AssignmentsView from './views/AssignmentsView.vue';
|
||||||
import BooksView from './views/BooksView.vue';
|
import BooksView from './views/BooksView.vue';
|
||||||
|
import GlJournalsView from './views/GlJournalsView.vue';
|
||||||
import ContactsView from './views/ContactsView.vue';
|
import ContactsView from './views/ContactsView.vue';
|
||||||
import AssetsView from './views/AssetsView.vue';
|
import AssetsView from './views/AssetsView.vue';
|
||||||
import DashboardView from './views/DashboardView.vue';
|
import DashboardView from './views/DashboardView.vue';
|
||||||
@@ -343,6 +350,7 @@ export default {
|
|||||||
AssetDrawer,
|
AssetDrawer,
|
||||||
AssetsView,
|
AssetsView,
|
||||||
BooksView,
|
BooksView,
|
||||||
|
GlJournalsView,
|
||||||
ChangePasswordCard,
|
ChangePasswordCard,
|
||||||
ContactsView,
|
ContactsView,
|
||||||
DashboardView,
|
DashboardView,
|
||||||
@@ -524,7 +532,8 @@ export default {
|
|||||||
assignments: 'Assign assets to people, departments, and locations with full custody history',
|
assignments: 'Assign assets to people, departments, and locations with full custody history',
|
||||||
contacts: 'Employees, vendors, service providers, contractors, and work locations',
|
contacts: 'Employees, vendors, service providers, contractors, and work locations',
|
||||||
reports: 'Depreciation schedules, monthly expense, and book values',
|
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',
|
templates: 'JSON-driven entry defaults and custom fields',
|
||||||
'tax-rules': 'Upload, edit, export, and activate depreciation rule sets',
|
'tax-rules': 'Upload, edit, export, and activate depreciation rule sets',
|
||||||
admin: 'Users, configuration, and integrations',
|
admin: 'Users, configuration, and integrations',
|
||||||
@@ -547,6 +556,7 @@ export default {
|
|||||||
contacts: 'Contacts',
|
contacts: 'Contacts',
|
||||||
reports: 'Reports',
|
reports: 'Reports',
|
||||||
books: 'Books',
|
books: 'Books',
|
||||||
|
'gl-journals': 'GL and Journals',
|
||||||
templates: 'Templates',
|
templates: 'Templates',
|
||||||
'tax-rules': 'Tax rules',
|
'tax-rules': 'Tax rules',
|
||||||
admin: 'Configuration',
|
admin: 'Configuration',
|
||||||
@@ -612,6 +622,7 @@ export default {
|
|||||||
contacts: ['contacts.manage'],
|
contacts: ['contacts.manage'],
|
||||||
reports: ['finance.view'],
|
reports: ['finance.view'],
|
||||||
books: ['finance.manage'],
|
books: ['finance.manage'],
|
||||||
|
'gl-journals': ['finance.view', 'finance.manage'],
|
||||||
'tax-rules': ['finance.manage'],
|
'tax-rules': ['finance.manage'],
|
||||||
'admin-users': ['admin.users'],
|
'admin-users': ['admin.users'],
|
||||||
'admin-security': ['admin.security'],
|
'admin-security': ['admin.security'],
|
||||||
|
|||||||
@@ -121,6 +121,10 @@
|
|||||||
import { apiRequest, saveBlob } from '../services/api';
|
import { apiRequest, saveBlob } from '../services/api';
|
||||||
import { shortDate } from '../utils/format';
|
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: '' };
|
const BLANK_FILTERS = { actor: null, action: null, entity_type: null, from: null, to: null, q: '' };
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -59,6 +59,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import { apiRequest } from '../services/api';
|
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 {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
token: { type: String, required: true },
|
token: { type: String, required: true },
|
||||||
|
|||||||
@@ -91,6 +91,10 @@
|
|||||||
import DataTable from './DataTable.vue';
|
import DataTable from './DataTable.vue';
|
||||||
import { apiRequest } from '../services/api';
|
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() {
|
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 };
|
return { id: null, name: '', legal_name: '', federal_tax_id: '', state_tax_id: '', local_tax_id: '', base_currency: 'USD', fiscal_year_end_month: 12 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import { apiRequest } from '../services/api';
|
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 {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
token: { type: String, required: true }
|
token: { type: String, required: true }
|
||||||
|
|||||||
@@ -49,6 +49,9 @@
|
|||||||
<script>
|
<script>
|
||||||
import { apiRequest } from '../services/api';
|
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 {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
token: { type: String, required: true }
|
token: { type: String, required: true }
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export const navItems = [
|
|||||||
group: true,
|
group: true,
|
||||||
children: [
|
children: [
|
||||||
{ key: 'books', label: 'Books', icon: 'mdi-book-open-variant' },
|
{ 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' }
|
{ key: 'tax-rules', label: 'Tax rules', icon: 'mdi-scale-balance' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,11 @@
|
|||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn color="primary" size="small" prepend-icon="mdi-plus" @click="openNewBook">New book</v-btn>
|
<v-btn color="primary" size="small" prepend-icon="mdi-plus" @click="openNewBook">New book</v-btn>
|
||||||
</div>
|
</div>
|
||||||
<p class="quiet text-body-2 mb-3">Configure each set of books, assign the depreciation rule set it should use, and set entry defaults. Add user-defined books for additional reporting scenarios.</p>
|
<p class="quiet text-body-2 mb-3">
|
||||||
|
Configure each set of books, assign the depreciation rule set it should use, and set entry defaults. Add user-defined
|
||||||
|
books for additional reporting scenarios. View each book’s general ledger and journal entries under
|
||||||
|
<strong>Financial → GL and Journals</strong>.
|
||||||
|
</p>
|
||||||
<div style="overflow-x:auto">
|
<div style="overflow-x:auto">
|
||||||
<table class="asset-table">
|
<table class="asset-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -71,237 +75,6 @@
|
|||||||
<v-alert v-if="message" type="success" density="compact" class="mt-3">{{ message }}</v-alert>
|
<v-alert v-if="message" type="success" density="compact" class="mt-3">{{ message }}</v-alert>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<!-- General ledger -->
|
|
||||||
<v-card class="span-12 panel-pad">
|
|
||||||
<div class="toolbar-row mb-3">
|
|
||||||
<h2 class="section-title">General ledger</h2>
|
|
||||||
<v-spacer />
|
|
||||||
<v-select v-model="ledgerBook" :items="bookCodeItems" label="Book" density="compact" hide-details style="max-width:180px" @update:model-value="loadLedger" />
|
|
||||||
<v-text-field v-model.number="ledgerYear" type="number" label="Year" density="compact" hide-details style="max-width:120px" @update:model-value="loadLedger" />
|
|
||||||
<v-menu>
|
|
||||||
<template #activator="{ props }">
|
|
||||||
<v-btn v-bind="props" color="secondary" size="small" prepend-icon="mdi-download">Export</v-btn>
|
|
||||||
</template>
|
|
||||||
<v-list density="compact">
|
|
||||||
<v-list-item v-for="format in ['pdf', 'xlsx', 'csv']" :key="format" :title="format.toUpperCase()" @click="exportLedger(format)" />
|
|
||||||
</v-list>
|
|
||||||
</v-menu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="ledger">
|
|
||||||
<div v-if="ledger.kind === 'maintenance'" class="ledger-summary mb-4">
|
|
||||||
<div class="ledger-chip"><span class="quiet">Assets serviced</span><strong>{{ ledger.summary.assets }}</strong></div>
|
|
||||||
<div class="ledger-chip"><span class="quiet">Services ({{ ledger.year }})</span><strong>{{ ledger.summary.completions }}</strong></div>
|
|
||||||
<div class="ledger-chip"><span class="quiet">Total PM spend</span><strong>{{ currency(ledger.summary.cost) }}</strong></div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="ledger-summary mb-4">
|
|
||||||
<div class="ledger-chip"><span class="quiet">Assets</span><strong>{{ ledger.summary.assets }}</strong></div>
|
|
||||||
<div class="ledger-chip"><span class="quiet">Cost</span><strong>{{ currency(ledger.summary.cost) }}</strong></div>
|
|
||||||
<div class="ledger-chip"><span class="quiet">Accumulated depr.</span><strong>{{ currency(ledger.summary.accumulated) }}</strong></div>
|
|
||||||
<div class="ledger-chip"><span class="quiet">Net book value</span><strong>{{ currency(ledger.summary.nbv) }}</strong></div>
|
|
||||||
<div class="ledger-chip"><span class="quiet">{{ ledger.year }} depreciation</span><strong>{{ currency(ledger.summary.depreciation) }}</strong></div>
|
|
||||||
<div class="ledger-chip"><span class="quiet">Rule set</span><strong>{{ ledger.rule_set.name || 'Active set' }}</strong></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="overflow-x:auto">
|
|
||||||
<table class="asset-table">
|
|
||||||
<thead>
|
|
||||||
<tr><th>GL account</th><th>Account type</th><th class="text-right">Debit</th><th class="text-right">Credit</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="(line, index) in ledger.accounts" :key="index">
|
|
||||||
<td class="font-weight-bold">{{ line.account }}</td>
|
|
||||||
<td>{{ line.type }}</td>
|
|
||||||
<td class="text-right">{{ line.debit ? currency(line.debit) : '' }}</td>
|
|
||||||
<td class="text-right">{{ line.credit ? currency(line.credit) : '' }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-if="!ledger.accounts.length"><td colspan="4" class="quiet">No postings for this book and year.</td></tr>
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td class="font-weight-bold" colspan="2">Totals</td>
|
|
||||||
<td class="text-right font-weight-bold">{{ currency(ledger.accountTotals.debit) }}</td>
|
|
||||||
<td class="text-right font-weight-bold">{{ currency(ledger.accountTotals.credit) }}</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-expansion-panels class="mt-4" variant="accordion">
|
|
||||||
<v-expansion-panel title="Asset detail">
|
|
||||||
<v-expansion-panel-text>
|
|
||||||
<DataTable
|
|
||||||
:columns="detailColumns"
|
|
||||||
:rows="ledger.assets"
|
|
||||||
row-key="asset_id"
|
|
||||||
searchable
|
|
||||||
search-placeholder="Filter assets"
|
|
||||||
empty-text="No assets in this book."
|
|
||||||
>
|
|
||||||
<template #cell-asset_id="{ row }">
|
|
||||||
<span class="font-weight-bold">{{ row.asset_id }}</span>
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
|
||||||
</v-expansion-panel-text>
|
|
||||||
</v-expansion-panel>
|
|
||||||
</v-expansion-panels>
|
|
||||||
</div>
|
|
||||||
<div v-else class="quiet text-body-2">Select a book to view its general ledger.</div>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<!-- GL journal entries (stored, editable, fully audited) -->
|
|
||||||
<v-card class="span-12 panel-pad">
|
|
||||||
<div class="toolbar-row mb-3">
|
|
||||||
<div>
|
|
||||||
<h2 class="section-title">Journal entries</h2>
|
|
||||||
<div class="quiet text-body-2">
|
|
||||||
Manual & imported GL postings for <strong>{{ ledgerBook }}</strong> ({{ ledgerYear }}). All changes are
|
|
||||||
recorded in Admin → Activity & Audit Trail.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<v-spacer />
|
|
||||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-upload" @click="$refs.glFile.click()">Import</v-btn>
|
|
||||||
<input ref="glFile" hidden type="file" accept=".csv,.xlsx,.xls" @change="onImportFile" />
|
|
||||||
<v-menu>
|
|
||||||
<template #activator="{ props }">
|
|
||||||
<v-btn v-bind="props" size="small" variant="tonal" prepend-icon="mdi-download">Export</v-btn>
|
|
||||||
</template>
|
|
||||||
<v-list density="compact">
|
|
||||||
<v-list-item title="CSV" @click="exportEntries('csv')" />
|
|
||||||
<v-list-item title="Excel (XLSX)" @click="exportEntries('xlsx')" />
|
|
||||||
</v-list>
|
|
||||||
</v-menu>
|
|
||||||
<v-btn size="small" color="primary" prepend-icon="mdi-plus" @click="openEntry()">Add entry</v-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ledger-summary mb-3">
|
|
||||||
<div class="ledger-chip"><span class="quiet">Entries</span><strong>{{ entrySummary.count }}</strong></div>
|
|
||||||
<div class="ledger-chip"><span class="quiet">Debit</span><strong>{{ currency(entrySummary.debit) }}</strong></div>
|
|
||||||
<div class="ledger-chip"><span class="quiet">Credit</span><strong>{{ currency(entrySummary.credit) }}</strong></div>
|
|
||||||
<div class="ledger-chip"><span class="quiet">Balance</span><strong>{{ currency(entrySummary.balance) }}</strong></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DataTable
|
|
||||||
:columns="entryColumns"
|
|
||||||
:rows="entries"
|
|
||||||
row-key="id"
|
|
||||||
:page-size="15"
|
|
||||||
has-actions
|
|
||||||
searchable
|
|
||||||
search-placeholder="Filter entries"
|
|
||||||
empty-text="No journal entries for this book and year."
|
|
||||||
>
|
|
||||||
<template #cell-debit="{ row }">{{ row.debit ? currency(row.debit) : '' }}</template>
|
|
||||||
<template #cell-credit="{ row }">{{ row.credit ? currency(row.credit) : '' }}</template>
|
|
||||||
<template #cell-source="{ row }"><v-chip size="x-small" variant="tonal">{{ row.source }}</v-chip></template>
|
|
||||||
<template #actions="{ row }">
|
|
||||||
<v-btn icon="mdi-history" size="small" variant="text" title="Change history" @click="openHistory(row)" />
|
|
||||||
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEntry(row)" />
|
|
||||||
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="removeEntry(row)" />
|
|
||||||
</template>
|
|
||||||
</DataTable>
|
|
||||||
<v-alert v-if="entryError" type="error" density="compact" class="mt-2">{{ entryError }}</v-alert>
|
|
||||||
<v-alert v-if="message" type="success" density="compact" class="mt-2">{{ message }}</v-alert>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<!-- Add / edit GL entry -->
|
|
||||||
<v-dialog v-model="entryDialog" max-width="640">
|
|
||||||
<v-card>
|
|
||||||
<v-card-title>{{ entryForm.id ? 'Edit GL entry' : 'New GL entry' }}</v-card-title>
|
|
||||||
<v-card-text>
|
|
||||||
<div class="form-grid">
|
|
||||||
<v-text-field v-model="entryForm.entry_date" type="date" label="Date *" />
|
|
||||||
<v-text-field v-model="entryForm.account" label="GL account *" />
|
|
||||||
<v-text-field v-model="entryForm.account_type" label="Account type" />
|
|
||||||
<v-text-field v-model="entryForm.reference" label="Reference" />
|
|
||||||
<v-text-field v-model.number="entryForm.debit" type="number" prefix="$" label="Debit" />
|
|
||||||
<v-text-field v-model.number="entryForm.credit" type="number" prefix="$" label="Credit" />
|
|
||||||
<v-text-field v-model="entryForm.external_id" class="full" label="External ID (used to match on re-import)" />
|
|
||||||
<v-textarea v-model="entryForm.description" class="full" label="Description" rows="2" />
|
|
||||||
</div>
|
|
||||||
<v-alert v-if="entryDialogError" type="error" density="compact" class="mt-2">{{ entryDialogError }}</v-alert>
|
|
||||||
</v-card-text>
|
|
||||||
<v-card-actions>
|
|
||||||
<v-spacer />
|
|
||||||
<v-btn variant="text" @click="entryDialog = false">Cancel</v-btn>
|
|
||||||
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="savingEntry" @click="saveEntry">Save entry</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
|
|
||||||
<!-- Import preview / conflict resolution -->
|
|
||||||
<v-dialog v-model="importDialog" max-width="760" scrollable>
|
|
||||||
<v-card v-if="importPreview">
|
|
||||||
<v-card-title>Import preview — {{ ledgerBook }}</v-card-title>
|
|
||||||
<v-card-text>
|
|
||||||
<div class="ledger-summary mb-3">
|
|
||||||
<div class="ledger-chip"><span class="quiet">New</span><strong>{{ importPreview.summary.new }}</strong></div>
|
|
||||||
<div class="ledger-chip"><span class="quiet">Update</span><strong>{{ importPreview.summary.update }}</strong></div>
|
|
||||||
<div class="ledger-chip"><span class="quiet">Duplicate</span><strong>{{ importPreview.summary.duplicate }}</strong></div>
|
|
||||||
<div class="ledger-chip"><span class="quiet">Errors</span><strong>{{ importPreview.summary.error }}</strong></div>
|
|
||||||
</div>
|
|
||||||
<div class="text-body-2 quiet mb-1">Conflict strategy</div>
|
|
||||||
<v-radio-group v-model="importStrategy" inline hide-details density="compact" class="mb-2">
|
|
||||||
<v-radio label="Skip conflicts" value="skip" />
|
|
||||||
<v-radio label="Merge (update existing)" value="merge" />
|
|
||||||
<v-radio label="Insert as new" value="insert" />
|
|
||||||
</v-radio-group>
|
|
||||||
<div style="overflow-x:auto; max-height:340px">
|
|
||||||
<table class="asset-table">
|
|
||||||
<thead><tr><th>Status</th><th>Date</th><th>Account</th><th class="text-right">Debit</th><th class="text-right">Credit</th><th>Reference</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="r in importPreview.rows" :key="r.index">
|
|
||||||
<td><v-chip size="x-small" :color="statusColor(r.status)" variant="tonal">{{ r.status }}</v-chip><span v-if="r.error" class="quiet text-caption"> · {{ r.error }}</span></td>
|
|
||||||
<td>{{ r.entry.entry_date }}</td>
|
|
||||||
<td>{{ r.entry.account }}</td>
|
|
||||||
<td class="text-right">{{ r.entry.debit ? currency(r.entry.debit) : '' }}</td>
|
|
||||||
<td class="text-right">{{ r.entry.credit ? currency(r.entry.credit) : '' }}</td>
|
|
||||||
<td>{{ r.entry.reference }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</v-card-text>
|
|
||||||
<v-card-actions>
|
|
||||||
<v-spacer />
|
|
||||||
<v-btn variant="text" @click="importDialog = false">Cancel</v-btn>
|
|
||||||
<v-btn color="primary" prepend-icon="mdi-database-import" :loading="importing" @click="commitImport">Import</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
|
|
||||||
<!-- Per-entry change history (from the audit log) -->
|
|
||||||
<v-dialog v-model="historyDialog" max-width="640" scrollable>
|
|
||||||
<v-card>
|
|
||||||
<v-card-title>Change history</v-card-title>
|
|
||||||
<v-card-text>
|
|
||||||
<div v-if="historyLoading" class="quiet text-body-2">Loading…</div>
|
|
||||||
<div v-else-if="!historyRows.length" class="quiet text-body-2">No recorded changes.</div>
|
|
||||||
<div v-for="ev in historyRows" :key="ev.id" class="gl-history-item">
|
|
||||||
<div class="toolbar-row mb-1" style="gap:8px">
|
|
||||||
<v-chip size="x-small" :color="historyColor(ev.action)" variant="tonal">{{ historyLabel(ev.action) }}</v-chip>
|
|
||||||
<span class="text-body-2 font-weight-medium">{{ ev.actor_name || 'System' }}</span>
|
|
||||||
<v-spacer />
|
|
||||||
<span class="quiet text-caption">{{ shortDate(ev.created_at) }}</span>
|
|
||||||
</div>
|
|
||||||
<table v-if="changedFields(ev.before, ev.after).length" class="gl-diff">
|
|
||||||
<tr v-for="(c, i) in changedFields(ev.before, ev.after)" :key="i">
|
|
||||||
<td class="quiet">{{ c.label }}</td>
|
|
||||||
<td class="gl-from">{{ c.from }}</td>
|
|
||||||
<td class="px-2">→</td>
|
|
||||||
<td class="gl-to">{{ c.to }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<div v-else class="quiet text-caption">No field changes.</div>
|
|
||||||
</div>
|
|
||||||
</v-card-text>
|
|
||||||
<v-card-actions>
|
|
||||||
<v-spacer />
|
|
||||||
<v-btn variant="text" @click="historyDialog = false">Close</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
|
|
||||||
<v-dialog v-model="newBookDialog" max-width="560">
|
<v-dialog v-model="newBookDialog" max-width="560">
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title>New book</v-card-title>
|
<v-card-title>New book</v-card-title>
|
||||||
@@ -329,13 +102,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import DataTable from '../components/DataTable.vue';
|
import { apiRequest } from '../services/api';
|
||||||
import { apiRequest, saveBlob } from '../services/api';
|
|
||||||
import { currency, shortDate } from '../utils/format';
|
|
||||||
import { conventionItems } from '../constants';
|
import { conventionItems } from '../constants';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { DataTable },
|
|
||||||
props: {
|
props: {
|
||||||
token: { type: String, required: true },
|
token: { type: String, required: true },
|
||||||
methodItems: { type: Array, default: () => [] }
|
methodItems: { type: Array, default: () => [] }
|
||||||
@@ -351,80 +121,24 @@ export default {
|
|||||||
],
|
],
|
||||||
books: [],
|
books: [],
|
||||||
ruleSets: [],
|
ruleSets: [],
|
||||||
ledger: null,
|
|
||||||
ledgerBook: 'GAAP',
|
|
||||||
ledgerYear: new Date().getFullYear(),
|
|
||||||
savingId: null,
|
savingId: null,
|
||||||
error: '',
|
error: '',
|
||||||
message: '',
|
message: '',
|
||||||
newBookDialog: false,
|
newBookDialog: false,
|
||||||
newBookError: '',
|
newBookError: '',
|
||||||
creating: false,
|
creating: false,
|
||||||
newBook: this.blankBook(),
|
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' }
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
ruleSetItems() {
|
ruleSetItems() {
|
||||||
return this.ruleSets.map((set) => ({ title: `${set.name} · ${set.version}`, value: set.id }));
|
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() {
|
mounted() {
|
||||||
this.load();
|
this.load();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
currency,
|
|
||||||
blankBook() {
|
blankBook() {
|
||||||
return { code: '', name: '', description: '', book_type: 'internal', tax_rule_set_id: null, default_method: 'straight_line', default_convention: 'half_year', enabled: true };
|
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();
|
const data = await (await this.api('/api/books')).json();
|
||||||
this.books = data.books;
|
this.books = data.books;
|
||||||
this.ruleSets = data.ruleSets;
|
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) {
|
async save(book) {
|
||||||
this.savingId = book.id;
|
this.savingId = book.id;
|
||||||
@@ -488,195 +198,12 @@ export default {
|
|||||||
});
|
});
|
||||||
this.message = `${book.code} saved.`;
|
this.message = `${book.code} saved.`;
|
||||||
this.$emit('updated');
|
this.$emit('updated');
|
||||||
if (book.code === this.ledgerBook) this.loadLedger();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error.message;
|
this.error = error.message;
|
||||||
} finally {
|
} finally {
|
||||||
this.savingId = null;
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ledger-summary {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.ledger-chip {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
min-width: 140px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border: 1px solid rgba(128, 128, 128, 0.25);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
.ledger-chip strong {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
|
||||||
.gl-history-item {
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid rgba(128, 128, 128, 0.2);
|
|
||||||
}
|
|
||||||
.gl-history-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
.gl-diff {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
.gl-diff td {
|
|
||||||
padding: 2px 6px;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
.gl-from {
|
|
||||||
color: rgb(var(--v-theme-error));
|
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
|
||||||
.gl-to {
|
|
||||||
color: rgb(var(--v-theme-success));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
695
src/views/GlJournalsView.vue
Normal file
695
src/views/GlJournalsView.vue
Normal file
@@ -0,0 +1,695 @@
|
|||||||
|
<template>
|
||||||
|
<section class="content-grid">
|
||||||
|
<!-- General ledger (computed depreciation rollup) -->
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<h2 class="section-title">General ledger</h2>
|
||||||
|
<v-spacer />
|
||||||
|
<v-select v-model="ledgerBook" :items="bookCodeItems" label="Book" density="compact" hide-details style="max-width:180px" @update:model-value="loadLedger" />
|
||||||
|
<v-text-field v-model.number="ledgerYear" type="number" label="Year" density="compact" hide-details style="max-width:120px" @update:model-value="loadLedger" />
|
||||||
|
<v-menu>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn v-bind="props" color="secondary" size="small" prepend-icon="mdi-download">Export</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item v-for="format in ['pdf', 'xlsx', 'csv']" :key="format" :title="format.toUpperCase()" @click="exportLedger(format)" />
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ledger">
|
||||||
|
<div v-if="ledger.kind === 'maintenance'" class="ledger-summary mb-4">
|
||||||
|
<div class="ledger-chip"><span class="quiet">Assets serviced</span><strong>{{ ledger.summary.assets }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Services ({{ ledger.year }})</span><strong>{{ ledger.summary.completions }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Total PM spend</span><strong>{{ currency(ledger.summary.cost) }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="ledger-summary mb-4">
|
||||||
|
<div class="ledger-chip"><span class="quiet">Assets</span><strong>{{ ledger.summary.assets }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Cost</span><strong>{{ currency(ledger.summary.cost) }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Accumulated depr.</span><strong>{{ currency(ledger.summary.accumulated) }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Net book value</span><strong>{{ currency(ledger.summary.nbv) }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">{{ ledger.year }} depreciation</span><strong>{{ currency(ledger.summary.depreciation) }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Rule set</span><strong>{{ ledger.rule_set.name || 'Active set' }}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table class="asset-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>GL account</th><th>Account type</th><th class="text-right">Debit</th><th class="text-right">Credit</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(line, index) in ledger.accounts" :key="index">
|
||||||
|
<td class="font-weight-bold">{{ line.account }}</td>
|
||||||
|
<td>{{ line.type }}</td>
|
||||||
|
<td class="text-right">{{ line.debit ? currency(line.debit) : '' }}</td>
|
||||||
|
<td class="text-right">{{ line.credit ? currency(line.credit) : '' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!ledger.accounts.length"><td colspan="4" class="quiet">No postings for this book and year.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td class="font-weight-bold" colspan="2">Totals</td>
|
||||||
|
<td class="text-right font-weight-bold">{{ currency(ledger.accountTotals.debit) }}</td>
|
||||||
|
<td class="text-right font-weight-bold">{{ currency(ledger.accountTotals.credit) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-expansion-panels class="mt-4" variant="accordion">
|
||||||
|
<v-expansion-panel title="Asset detail">
|
||||||
|
<v-expansion-panel-text>
|
||||||
|
<DataTable
|
||||||
|
:columns="detailColumns"
|
||||||
|
:rows="ledger.assets"
|
||||||
|
row-key="asset_id"
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter assets"
|
||||||
|
empty-text="No assets in this book."
|
||||||
|
>
|
||||||
|
<template #cell-asset_id="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.asset_id }}</span>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</v-expansion-panel-text>
|
||||||
|
</v-expansion-panel>
|
||||||
|
</v-expansion-panels>
|
||||||
|
</div>
|
||||||
|
<div v-else class="quiet text-body-2">Select a book to view its general ledger.</div>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- Chart of accounts + posting defaults (fully audited) -->
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-2">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Chart of accounts</h2>
|
||||||
|
<div class="quiet text-body-2">
|
||||||
|
The company's GL accounts. The <strong>posting defaults</strong> below are the fallback accounts the
|
||||||
|
depreciation & maintenance ledgers post to when an asset has none of its own. Every change is recorded in
|
||||||
|
Admin → Activity & Audit Trail.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="small" color="primary" prepend-icon="mdi-plus" @click="openAccount()">Add account</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="accountColumns"
|
||||||
|
:rows="accounts"
|
||||||
|
row-key="id"
|
||||||
|
:page-size="10"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter accounts"
|
||||||
|
empty-text="No GL accounts yet."
|
||||||
|
>
|
||||||
|
<template #cell-type="{ row }">{{ typeLabel(row.type) }}</template>
|
||||||
|
<template #cell-active="{ row }"><v-chip size="x-small" :color="row.active ? 'success' : 'error'" variant="tonal">{{ row.active ? 'active' : 'inactive' }}</v-chip></template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openAccount(row)" />
|
||||||
|
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="removeAccount(row)" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<div class="toolbar-row mb-2">
|
||||||
|
<h3 class="section-title">Posting defaults</h3>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="small" color="primary" variant="tonal" prepend-icon="mdi-content-save" :loading="savingDefaults" @click="saveDefaults">Save defaults</v-btn>
|
||||||
|
</div>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-select v-model="defaults.asset_account" :items="accountSelectItems" item-title="title" item-value="value" label="Asset cost account" density="compact" clearable />
|
||||||
|
<v-select v-model="defaults.accumulated_account" :items="accountSelectItems" item-title="title" item-value="value" label="Accumulated depreciation account" density="compact" clearable />
|
||||||
|
<v-select v-model="defaults.expense_account" :items="accountSelectItems" item-title="title" item-value="value" label="Depreciation expense account" density="compact" clearable />
|
||||||
|
<v-select v-model="defaults.pm_expense_account" :items="accountSelectItems" item-title="title" item-value="value" label="Maintenance expense account" density="compact" clearable />
|
||||||
|
<v-select v-model="defaults.pm_clearing_account" :items="accountSelectItems" item-title="title" item-value="value" label="Maintenance clearing account" density="compact" clearable />
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="accountError" type="error" density="compact" class="mt-2">{{ accountError }}</v-alert>
|
||||||
|
<v-alert v-if="accountMessage" type="success" density="compact" class="mt-2">{{ accountMessage }}</v-alert>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- GL journal entries (stored, editable, fully audited) -->
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Journal entries</h2>
|
||||||
|
<div class="quiet text-body-2">
|
||||||
|
Manual & imported GL postings for <strong>{{ ledgerBook }}</strong> ({{ ledgerYear }}). All changes are
|
||||||
|
recorded in Admin → Activity & Audit Trail.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="small" variant="tonal" prepend-icon="mdi-upload" @click="$refs.glFile.click()">Import</v-btn>
|
||||||
|
<input ref="glFile" hidden type="file" accept=".csv,.xlsx,.xls" @change="onImportFile" />
|
||||||
|
<v-menu>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn v-bind="props" size="small" variant="tonal" prepend-icon="mdi-download">Export</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item title="CSV" @click="exportEntries('csv')" />
|
||||||
|
<v-list-item title="Excel (XLSX)" @click="exportEntries('xlsx')" />
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
<v-btn size="small" color="primary" prepend-icon="mdi-plus" @click="openEntry()">Add entry</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ledger-summary mb-3">
|
||||||
|
<div class="ledger-chip"><span class="quiet">Entries</span><strong>{{ entrySummary.count }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Debit</span><strong>{{ currency(entrySummary.debit) }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Credit</span><strong>{{ currency(entrySummary.credit) }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Balance</span><strong>{{ currency(entrySummary.balance) }}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="entryColumns"
|
||||||
|
:rows="entries"
|
||||||
|
row-key="id"
|
||||||
|
:page-size="15"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter entries"
|
||||||
|
empty-text="No journal entries for this book and year."
|
||||||
|
>
|
||||||
|
<template #cell-debit="{ row }">{{ row.debit ? currency(row.debit) : '' }}</template>
|
||||||
|
<template #cell-credit="{ row }">{{ row.credit ? currency(row.credit) : '' }}</template>
|
||||||
|
<template #cell-source="{ row }"><v-chip size="x-small" variant="tonal">{{ row.source }}</v-chip></template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn icon="mdi-history" size="small" variant="text" title="Change history" @click="openHistory(row)" />
|
||||||
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEntry(row)" />
|
||||||
|
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="removeEntry(row)" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<v-alert v-if="entryError" type="error" density="compact" class="mt-2">{{ entryError }}</v-alert>
|
||||||
|
<v-alert v-if="message" type="success" density="compact" class="mt-2">{{ message }}</v-alert>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- Add / edit GL entry -->
|
||||||
|
<v-dialog v-model="entryDialog" max-width="640">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ entryForm.id ? 'Edit GL entry' : 'New GL entry' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field v-model="entryForm.entry_date" type="date" label="Date *" />
|
||||||
|
<v-combobox v-model="entryForm.account" :items="accountCodes" label="GL account *" hint="Pick from the chart of accounts or type a code" />
|
||||||
|
|
||||||
|
<v-text-field v-model="entryForm.account_type" label="Account type" />
|
||||||
|
<v-text-field v-model="entryForm.reference" label="Reference" />
|
||||||
|
<v-text-field v-model.number="entryForm.debit" type="number" prefix="$" label="Debit" />
|
||||||
|
<v-text-field v-model.number="entryForm.credit" type="number" prefix="$" label="Credit" />
|
||||||
|
<v-text-field v-model="entryForm.external_id" class="full" label="External ID (used to match on re-import)" />
|
||||||
|
<v-textarea v-model="entryForm.description" class="full" label="Description" rows="2" />
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="entryDialogError" type="error" density="compact" class="mt-2">{{ entryDialogError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="entryDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="savingEntry" @click="saveEntry">Save entry</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Import preview / conflict resolution -->
|
||||||
|
<v-dialog v-model="importDialog" max-width="760" scrollable>
|
||||||
|
<v-card v-if="importPreview">
|
||||||
|
<v-card-title>Import preview — {{ ledgerBook }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="ledger-summary mb-3">
|
||||||
|
<div class="ledger-chip"><span class="quiet">New</span><strong>{{ importPreview.summary.new }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Update</span><strong>{{ importPreview.summary.update }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Duplicate</span><strong>{{ importPreview.summary.duplicate }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Errors</span><strong>{{ importPreview.summary.error }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 quiet mb-1">Conflict strategy</div>
|
||||||
|
<v-radio-group v-model="importStrategy" inline hide-details density="compact" class="mb-2">
|
||||||
|
<v-radio label="Skip conflicts" value="skip" />
|
||||||
|
<v-radio label="Merge (update existing)" value="merge" />
|
||||||
|
<v-radio label="Insert as new" value="insert" />
|
||||||
|
</v-radio-group>
|
||||||
|
<div style="overflow-x:auto; max-height:340px">
|
||||||
|
<table class="asset-table">
|
||||||
|
<thead><tr><th>Status</th><th>Date</th><th>Account</th><th class="text-right">Debit</th><th class="text-right">Credit</th><th>Reference</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in importPreview.rows" :key="r.index">
|
||||||
|
<td><v-chip size="x-small" :color="statusColor(r.status)" variant="tonal">{{ r.status }}</v-chip><span v-if="r.error" class="quiet text-caption"> · {{ r.error }}</span></td>
|
||||||
|
<td>{{ r.entry.entry_date }}</td>
|
||||||
|
<td>{{ r.entry.account }}</td>
|
||||||
|
<td class="text-right">{{ r.entry.debit ? currency(r.entry.debit) : '' }}</td>
|
||||||
|
<td class="text-right">{{ r.entry.credit ? currency(r.entry.credit) : '' }}</td>
|
||||||
|
<td>{{ r.entry.reference }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="importDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-database-import" :loading="importing" @click="commitImport">Import</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Per-entry change history (from the audit log) -->
|
||||||
|
<v-dialog v-model="historyDialog" max-width="640" scrollable>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Change history</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div v-if="historyLoading" class="quiet text-body-2">Loading…</div>
|
||||||
|
<div v-else-if="!historyRows.length" class="quiet text-body-2">No recorded changes.</div>
|
||||||
|
<div v-for="ev in historyRows" :key="ev.id" class="gl-history-item">
|
||||||
|
<div class="toolbar-row mb-1" style="gap:8px">
|
||||||
|
<v-chip size="x-small" :color="historyColor(ev.action)" variant="tonal">{{ historyLabel(ev.action) }}</v-chip>
|
||||||
|
<span class="text-body-2 font-weight-medium">{{ ev.actor_name || 'System' }}</span>
|
||||||
|
<v-spacer />
|
||||||
|
<span class="quiet text-caption">{{ shortDate(ev.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<table v-if="changedFields(ev.before, ev.after).length" class="gl-diff">
|
||||||
|
<tr v-for="(c, i) in changedFields(ev.before, ev.after)" :key="i">
|
||||||
|
<td class="quiet">{{ c.label }}</td>
|
||||||
|
<td class="gl-from">{{ c.from }}</td>
|
||||||
|
<td class="px-2">→</td>
|
||||||
|
<td class="gl-to">{{ c.to }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<div v-else class="quiet text-caption">No field changes.</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="historyDialog = false">Close</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Add / edit GL account -->
|
||||||
|
<v-dialog v-model="accountDialog" max-width="560">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ accountForm.id ? 'Edit GL account' : 'New GL account' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field v-model="accountForm.code" label="Account code *" />
|
||||||
|
<v-text-field v-model="accountForm.name" label="Account name *" />
|
||||||
|
<v-select v-model="accountForm.type" :items="accountTypeItems" item-title="title" item-value="value" label="Type" />
|
||||||
|
<v-select v-model="accountForm.normal_balance" :items="balanceItems" item-title="title" item-value="value" label="Normal balance" />
|
||||||
|
<v-textarea v-model="accountForm.description" class="full" label="Description" rows="2" />
|
||||||
|
<v-switch v-model="accountForm.active" label="Active" color="primary" density="compact" hide-details />
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="accountDialogError" type="error" density="compact" class="mt-2">{{ accountDialogError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="accountDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :disabled="!accountForm.code || !accountForm.name" :loading="savingAccount" @click="saveAccount">Save account</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DataTable from '../components/DataTable.vue';
|
||||||
|
import { apiRequest, saveBlob } from '../services/api';
|
||||||
|
import { currency, shortDate } from '../utils/format';
|
||||||
|
|
||||||
|
// GL and Journals (Financial → GL and Journals). Combines the computed depreciation GL rollup with the
|
||||||
|
// stored, editable journal-entries layer (CRUD, CSV/Excel import with conflict-merge preview, export,
|
||||||
|
// and per-entry change history) for the selected book + year. Loads its own book list to populate the
|
||||||
|
// book picker. Previously these two panels lived on the Books screen.
|
||||||
|
export default {
|
||||||
|
components: { DataTable },
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
books: [],
|
||||||
|
ledger: null,
|
||||||
|
ledgerBook: 'GAAP',
|
||||||
|
ledgerYear: new Date().getFullYear(),
|
||||||
|
error: '',
|
||||||
|
message: '',
|
||||||
|
entries: [],
|
||||||
|
entrySummary: { count: 0, debit: 0, credit: 0, balance: 0 },
|
||||||
|
entryDialog: false,
|
||||||
|
entryForm: this.blankEntry(),
|
||||||
|
entryDialogError: '',
|
||||||
|
savingEntry: false,
|
||||||
|
entryError: '',
|
||||||
|
importDialog: false,
|
||||||
|
importPreview: null,
|
||||||
|
importStrategy: 'merge',
|
||||||
|
importing: false,
|
||||||
|
historyDialog: false,
|
||||||
|
historyRows: [],
|
||||||
|
historyLoading: false,
|
||||||
|
glFieldLabels: { entry_date: 'Date', account: 'Account', account_type: 'Type', description: 'Description', reference: 'Reference', debit: 'Debit', credit: 'Credit', external_id: 'External ID', asset_id: 'Asset' },
|
||||||
|
accounts: [],
|
||||||
|
defaults: { asset_account: '', accumulated_account: '', expense_account: '', pm_expense_account: '', pm_clearing_account: '' },
|
||||||
|
accountDialog: false,
|
||||||
|
accountForm: this.blankAccount(),
|
||||||
|
accountDialogError: '',
|
||||||
|
savingAccount: false,
|
||||||
|
savingDefaults: false,
|
||||||
|
accountError: '',
|
||||||
|
accountMessage: '',
|
||||||
|
accountTypeItems: [
|
||||||
|
{ title: 'Asset', value: 'asset' },
|
||||||
|
{ title: 'Contra-asset', value: 'contra_asset' },
|
||||||
|
{ title: 'Liability', value: 'liability' },
|
||||||
|
{ title: 'Equity', value: 'equity' },
|
||||||
|
{ title: 'Revenue', value: 'revenue' },
|
||||||
|
{ title: 'Expense', value: 'expense' }
|
||||||
|
],
|
||||||
|
balanceItems: [{ title: 'Debit', value: 'debit' }, { title: 'Credit', value: 'credit' }]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
bookCodeItems() {
|
||||||
|
return this.books.map((book) => book.code);
|
||||||
|
},
|
||||||
|
detailColumns() {
|
||||||
|
const year = this.ledger?.year || this.ledgerYear;
|
||||||
|
if (this.ledger?.kind === 'maintenance') {
|
||||||
|
return [
|
||||||
|
{ key: 'asset_id', label: 'Asset ID' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{ key: 'completions', label: 'Services', format: 'number' },
|
||||||
|
{ key: 'last_service', label: 'Last service', format: 'date' },
|
||||||
|
{ key: 'cost', label: `${year} PM cost`, format: 'currency' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ key: 'asset_id', label: 'Asset ID' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{ key: 'asset_account', label: 'Asset acct' },
|
||||||
|
{ key: 'expense_account', label: 'Expense acct' },
|
||||||
|
{ key: 'cost', label: 'Cost', format: 'currency' },
|
||||||
|
{ key: 'depreciation', label: `${year} depr.`, format: 'currency' },
|
||||||
|
{ key: 'accumulated', label: 'Accum.', format: 'currency' },
|
||||||
|
{ key: 'nbv', label: 'NBV', format: 'currency' }
|
||||||
|
];
|
||||||
|
},
|
||||||
|
entryColumns() {
|
||||||
|
return [
|
||||||
|
{ key: 'entry_date', label: 'Date', format: 'date' },
|
||||||
|
{ key: 'account', label: 'Account' },
|
||||||
|
{ key: 'account_type', label: 'Type' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{ key: 'reference', label: 'Reference' },
|
||||||
|
{ key: 'debit', label: 'Debit', format: 'currency' },
|
||||||
|
{ key: 'credit', label: 'Credit', format: 'currency' },
|
||||||
|
{ key: 'source', label: 'Source', sortable: false }
|
||||||
|
];
|
||||||
|
},
|
||||||
|
accountColumns() {
|
||||||
|
return [
|
||||||
|
{ key: 'code', label: 'Code' },
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'type', label: 'Type' },
|
||||||
|
{ key: 'normal_balance', label: 'Normal balance' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{ key: 'active', label: 'Status', sortable: false }
|
||||||
|
];
|
||||||
|
},
|
||||||
|
accountSelectItems() {
|
||||||
|
return this.accounts.map((account) => ({ title: `${account.code} — ${account.name}`, value: account.code }));
|
||||||
|
},
|
||||||
|
accountCodes() {
|
||||||
|
return this.accounts.map((account) => account.code);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadBooks();
|
||||||
|
this.loadAccounts();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
currency,
|
||||||
|
shortDate,
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
// Load the company's books to populate the book picker, then show the selected book's ledger/entries.
|
||||||
|
async loadBooks() {
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/books')).json();
|
||||||
|
this.books = data.books;
|
||||||
|
if (this.books.length) {
|
||||||
|
if (!this.books.some((book) => book.code === this.ledgerBook)) this.ledgerBook = this.books[0].code;
|
||||||
|
this.loadLedger();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadLedger() {
|
||||||
|
if (!this.ledgerBook) return;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api(`/api/books/${this.ledgerBook}/ledger?year=${this.ledgerYear}`)).json();
|
||||||
|
this.ledger = data.ledger;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
this.loadEntries();
|
||||||
|
},
|
||||||
|
async exportLedger(format) {
|
||||||
|
const blob = await (await this.api(`/api/books/${this.ledgerBook}/ledger/export`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ year: this.ledgerYear, format })
|
||||||
|
})).blob();
|
||||||
|
saveBlob(blob, `deprecore-${this.ledgerBook}-ledger-${this.ledgerYear}.${format}`);
|
||||||
|
},
|
||||||
|
// ---- GL journal entries -------------------------------------------------
|
||||||
|
blankEntry() {
|
||||||
|
return { id: null, entry_date: new Date().toISOString().slice(0, 10), account: '', account_type: '', description: '', reference: '', debit: 0, credit: 0, external_id: '' };
|
||||||
|
},
|
||||||
|
async loadEntries() {
|
||||||
|
if (!this.ledgerBook) return;
|
||||||
|
this.entryError = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api(`/api/books/${this.ledgerBook}/gl-entries?year=${this.ledgerYear}`)).json();
|
||||||
|
this.entries = data.entries;
|
||||||
|
this.entrySummary = data.summary;
|
||||||
|
} catch (error) {
|
||||||
|
this.entryError = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openEntry(row) {
|
||||||
|
this.entryForm = row ? { ...this.blankEntry(), ...row } : this.blankEntry();
|
||||||
|
this.entryDialogError = '';
|
||||||
|
this.entryDialog = true;
|
||||||
|
},
|
||||||
|
async saveEntry() {
|
||||||
|
this.savingEntry = true;
|
||||||
|
this.entryDialogError = '';
|
||||||
|
this.message = '';
|
||||||
|
try {
|
||||||
|
const method = this.entryForm.id ? 'PUT' : 'POST';
|
||||||
|
const url = this.entryForm.id
|
||||||
|
? `/api/books/${this.ledgerBook}/gl-entries/${this.entryForm.id}`
|
||||||
|
: `/api/books/${this.ledgerBook}/gl-entries`;
|
||||||
|
await this.api(url, { method, body: JSON.stringify(this.entryForm) });
|
||||||
|
this.entryDialog = false;
|
||||||
|
await this.loadEntries();
|
||||||
|
} catch (error) {
|
||||||
|
this.entryDialogError = error.message;
|
||||||
|
} finally {
|
||||||
|
this.savingEntry = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async removeEntry(row) {
|
||||||
|
if (!window.confirm('Delete this GL entry? The change is recorded in the audit trail.')) return;
|
||||||
|
this.entryError = '';
|
||||||
|
try {
|
||||||
|
await this.api(`/api/books/${this.ledgerBook}/gl-entries/${row.id}`, { method: 'DELETE' });
|
||||||
|
await this.loadEntries();
|
||||||
|
} catch (error) {
|
||||||
|
this.entryError = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Import is a stateless two-step: upload the file for a dry-run preview (rows classified
|
||||||
|
// new/update/duplicate/error), then commit the returned classified rows with the chosen strategy —
|
||||||
|
// so the file is never re-uploaded and the server keeps no session between the two calls.
|
||||||
|
async onImportFile(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
event.target.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
this.entryError = '';
|
||||||
|
this.message = '';
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const data = await (await this.api(`/api/books/${this.ledgerBook}/gl-entries/import/preview`, { method: 'POST', body: form })).json();
|
||||||
|
this.importPreview = data;
|
||||||
|
this.importStrategy = 'merge';
|
||||||
|
this.importDialog = true;
|
||||||
|
} catch (error) {
|
||||||
|
this.entryError = `Import failed: ${error.message}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async commitImport() {
|
||||||
|
this.importing = true;
|
||||||
|
try {
|
||||||
|
const result = await (await this.api(`/api/books/${this.ledgerBook}/gl-entries/import/commit`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ rows: this.importPreview.rows, strategy: this.importStrategy })
|
||||||
|
})).json();
|
||||||
|
this.importDialog = false;
|
||||||
|
this.message = `Imported: ${result.inserted} new, ${result.updated} updated, ${result.skipped} skipped${result.errors ? `, ${result.errors} error(s)` : ''}.`;
|
||||||
|
await this.loadEntries();
|
||||||
|
} catch (error) {
|
||||||
|
this.entryError = error.message;
|
||||||
|
} finally {
|
||||||
|
this.importing = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async exportEntries(format) {
|
||||||
|
const blob = await (await this.api(`/api/books/${this.ledgerBook}/gl-entries/export?year=${this.ledgerYear}&format=${format}`)).blob();
|
||||||
|
saveBlob(blob, `deprecore-${this.ledgerBook}-gl-entries.${format}`);
|
||||||
|
},
|
||||||
|
statusColor(status) {
|
||||||
|
return { new: 'success', update: 'info', duplicate: 'warning', error: 'error' }[status] || undefined;
|
||||||
|
},
|
||||||
|
async openHistory(row) {
|
||||||
|
this.historyRows = [];
|
||||||
|
this.historyLoading = true;
|
||||||
|
this.historyDialog = true;
|
||||||
|
try {
|
||||||
|
const data = await (await this.api(`/api/books/${this.ledgerBook}/gl-entries/${row.id}/history`)).json();
|
||||||
|
this.historyRows = data.history;
|
||||||
|
} catch (error) {
|
||||||
|
this.entryError = error.message;
|
||||||
|
this.historyDialog = false;
|
||||||
|
} finally {
|
||||||
|
this.historyLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Field-level diff between an audit event's before/after snapshots.
|
||||||
|
changedFields(before, after) {
|
||||||
|
const norm = (v) => (v === null || v === undefined || v === '' ? '' : String(v));
|
||||||
|
const b = before || {};
|
||||||
|
const a = after || {};
|
||||||
|
const out = [];
|
||||||
|
for (const [key, label] of Object.entries(this.glFieldLabels)) {
|
||||||
|
const from = norm(b[key]);
|
||||||
|
const to = norm(a[key]);
|
||||||
|
if (from !== to) out.push({ label, from: from || '—', to: to || '—' });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
historyLabel(action) {
|
||||||
|
return { create: 'Created', update: 'Updated', delete: 'Deleted' }[action] || action;
|
||||||
|
},
|
||||||
|
historyColor(action) {
|
||||||
|
return { create: 'success', update: 'info', delete: 'error' }[action] || undefined;
|
||||||
|
},
|
||||||
|
// ---- Chart of accounts + posting defaults -------------------------------
|
||||||
|
blankAccount() {
|
||||||
|
return { id: null, code: '', name: '', type: 'asset', normal_balance: 'debit', description: '', active: true };
|
||||||
|
},
|
||||||
|
typeLabel(type) {
|
||||||
|
return { asset: 'Asset', contra_asset: 'Contra-asset', liability: 'Liability', equity: 'Equity', revenue: 'Revenue', expense: 'Expense' }[type] || type;
|
||||||
|
},
|
||||||
|
async loadAccounts() {
|
||||||
|
this.accountError = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/gl-accounts')).json();
|
||||||
|
this.accounts = data.accounts;
|
||||||
|
this.defaults = { ...this.defaults, ...data.defaults };
|
||||||
|
} catch (error) {
|
||||||
|
this.accountError = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openAccount(row) {
|
||||||
|
this.accountForm = row ? { ...this.blankAccount(), ...row } : this.blankAccount();
|
||||||
|
this.accountDialogError = '';
|
||||||
|
this.accountDialog = true;
|
||||||
|
},
|
||||||
|
async saveAccount() {
|
||||||
|
this.savingAccount = true;
|
||||||
|
this.accountDialogError = '';
|
||||||
|
this.accountMessage = '';
|
||||||
|
try {
|
||||||
|
const method = this.accountForm.id ? 'PUT' : 'POST';
|
||||||
|
const url = this.accountForm.id ? `/api/gl-accounts/${this.accountForm.id}` : '/api/gl-accounts';
|
||||||
|
await this.api(url, { method, body: JSON.stringify(this.accountForm) });
|
||||||
|
this.accountDialog = false;
|
||||||
|
await this.loadAccounts();
|
||||||
|
} catch (error) {
|
||||||
|
this.accountDialogError = error.message;
|
||||||
|
} finally {
|
||||||
|
this.savingAccount = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async removeAccount(row) {
|
||||||
|
if (!window.confirm(`Delete account ${row.code} — ${row.name}? The change is recorded in the audit trail.`)) return;
|
||||||
|
this.accountError = '';
|
||||||
|
try {
|
||||||
|
await this.api(`/api/gl-accounts/${row.id}`, { method: 'DELETE' });
|
||||||
|
await this.loadAccounts();
|
||||||
|
} catch (error) {
|
||||||
|
this.accountError = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async saveDefaults() {
|
||||||
|
this.savingDefaults = true;
|
||||||
|
this.accountError = '';
|
||||||
|
this.accountMessage = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/gl-account-defaults', { method: 'PUT', body: JSON.stringify(this.defaults) })).json();
|
||||||
|
this.defaults = { ...this.defaults, ...data.defaults };
|
||||||
|
this.accountMessage = 'Posting defaults saved.';
|
||||||
|
this.loadLedger(); // the ledger's fallback accounts changed — refresh it
|
||||||
|
} catch (error) {
|
||||||
|
this.accountError = error.message;
|
||||||
|
} finally {
|
||||||
|
this.savingDefaults = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ledger-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.ledger-chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.ledger-chip strong {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.gl-history-item {
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid rgba(128, 128, 128, 0.2);
|
||||||
|
}
|
||||||
|
.gl-history-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.gl-diff {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.gl-diff td {
|
||||||
|
padding: 2px 6px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.gl-from {
|
||||||
|
color: rgb(var(--v-theme-error));
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
.gl-to {
|
||||||
|
color: rgb(var(--v-theme-success));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user