Updated GL System , Updated user manual
This commit is contained in:
22
server/db.js
22
server/db.js
@@ -416,6 +416,28 @@ function initialize() {
|
||||
UNIQUE(entity_id, code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gl_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entity_id INTEGER REFERENCES entities(id),
|
||||
book_code TEXT NOT NULL,
|
||||
entry_date TEXT NOT NULL,
|
||||
fiscal_year INTEGER,
|
||||
account TEXT NOT NULL,
|
||||
account_type TEXT,
|
||||
description TEXT,
|
||||
reference TEXT,
|
||||
debit REAL NOT NULL DEFAULT 0,
|
||||
credit REAL NOT NULL DEFAULT 0,
|
||||
asset_id INTEGER REFERENCES assets(id) ON DELETE SET NULL,
|
||||
external_id TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_gl_entries_scope ON gl_entries (entity_id, book_code, fiscal_year);
|
||||
CREATE INDEX IF NOT EXISTS idx_gl_entries_extid ON gl_entries (entity_id, book_code, external_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS alerts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const { requireCapability } = require('../middleware/auth');
|
||||
const { bookLedger, createBook, deleteBook, ledgerReport, listBooks, updateBook } = require('../services/books');
|
||||
const { exportReport } = require('../services/reportExport');
|
||||
const { rowsFromImport, extensionForUpload } = require('../services/importExport');
|
||||
const glEntries = require('../services/glEntries');
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
|
||||
const router = express.Router();
|
||||
const ledgerReader = requireCapability('finance.view');
|
||||
const ledgerEditor = requireCapability('finance.manage');
|
||||
|
||||
router.get('/books', (req, res) => {
|
||||
res.json(listBooks(req.companyId));
|
||||
@@ -43,4 +49,73 @@ router.post('/books/:code/ledger/export', async (req, res) => {
|
||||
return res.send(output.body);
|
||||
});
|
||||
|
||||
// ---- GL journal entries (stored, editable; full audit) ----------------------
|
||||
|
||||
function glFilters(query, code) {
|
||||
return { book: code, year: query.year ? Number(query.year) : null, account: query.account || null, search: query.search || null };
|
||||
}
|
||||
|
||||
router.get('/books/:code/gl-entries', ledgerReader, (req, res) => {
|
||||
res.json(glEntries.listEntries(glFilters(req.query, req.params.code), req.companyId));
|
||||
});
|
||||
|
||||
router.post('/books/:code/gl-entries', ledgerEditor, (req, res, next) => {
|
||||
try {
|
||||
res.status(201).json({ entry: glEntries.createEntry(req.params.code, req.body, req.user.id, req.companyId) });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/books/:code/gl-entries/:id', ledgerEditor, (req, res, next) => {
|
||||
try {
|
||||
const entry = glEntries.updateEntry(req.params.id, req.body, req.user.id, req.companyId);
|
||||
if (!entry) return res.status(404).json({ error: 'GL entry not found' });
|
||||
return res.json({ entry });
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/books/:code/gl-entries/:id', ledgerEditor, (req, res) => {
|
||||
if (!glEntries.deleteEntry(req.params.id, req.user.id, req.companyId)) return res.status(404).json({ error: 'GL entry not found' });
|
||||
return res.status(204).end();
|
||||
});
|
||||
|
||||
// Per-entry change timeline (from the audit log).
|
||||
router.get('/books/:code/gl-entries/:id/history', ledgerReader, (req, res) => {
|
||||
const history = glEntries.entryHistory(req.params.id, req.companyId);
|
||||
if (!history) return res.status(404).json({ error: 'GL entry not found' });
|
||||
return res.json({ history });
|
||||
});
|
||||
|
||||
// Dry-run: parse the uploaded CSV/Excel and classify each row (new/update/duplicate/error).
|
||||
router.post('/books/:code/gl-entries/import/preview', ledgerEditor, upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'File is required' });
|
||||
const rows = await rowsFromImport(extensionForUpload(req.file.originalname), req.file.buffer);
|
||||
res.json(glEntries.previewImport(rows, req.params.code, req.companyId));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Commit the classified rows from the preview using the chosen conflict strategy.
|
||||
router.post('/books/:code/gl-entries/import/commit', ledgerEditor, (req, res, next) => {
|
||||
try {
|
||||
res.json(glEntries.commitImport(req.body.rows || [], req.body.strategy, req.params.code, req.user.id, req.companyId));
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/books/:code/gl-entries/export', ledgerReader, async (req, res) => {
|
||||
const report = glEntries.entriesReport(glFilters(req.query, req.params.code), req.companyId);
|
||||
const format = ['csv', 'xlsx'].includes(String(req.query.format).toLowerCase()) ? String(req.query.format).toLowerCase() : 'csv';
|
||||
const output = await exportReport(report, format);
|
||||
res.setHeader('Content-Type', output.contentType);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="deprecore-${req.params.code}-gl-entries.${format}"`);
|
||||
return res.send(output.body);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
286
server/services/glEntries.js
Normal file
286
server/services/glEntries.js
Normal file
@@ -0,0 +1,286 @@
|
||||
const { all, audit, now, one, parseJson, run, tx } = require('../db');
|
||||
|
||||
// Stored, editable general-ledger journal entries per book/company. Independent of the computed
|
||||
// depreciation rollup (bookLedger) — this is a manual/imported journal layer. Every change is logged
|
||||
// through audit() with full before/after JSON for the Activity & Audit Trail.
|
||||
|
||||
function round2(value) {
|
||||
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
function num(value) {
|
||||
if (value === '' || value === null || value === undefined) return 0;
|
||||
const n = Number(String(value).replace(/[$,]/g, ''));
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function strOrNull(value) {
|
||||
const s = value === null || value === undefined ? '' : String(value).trim();
|
||||
return s || null;
|
||||
}
|
||||
|
||||
function yearOf(dateStr) {
|
||||
return dateStr ? Number(String(dateStr).slice(0, 4)) || null : null;
|
||||
}
|
||||
|
||||
function entryFromRow(row) {
|
||||
return { ...row };
|
||||
}
|
||||
|
||||
// ---- Read -------------------------------------------------------------------
|
||||
|
||||
function listEntries(filters = {}, companyId) {
|
||||
const entries = all(
|
||||
`SELECT g.*, a.asset_id AS asset_code
|
||||
FROM gl_entries g
|
||||
LEFT JOIN assets a ON a.id = g.asset_id
|
||||
WHERE g.entity_id = ? AND g.book_code = ?
|
||||
AND (? IS NULL OR g.fiscal_year = ?)
|
||||
AND (? IS NULL OR g.account = ?)
|
||||
AND (? IS NULL OR (g.account LIKE '%' || ? || '%' OR g.description LIKE '%' || ? || '%' OR g.reference LIKE '%' || ? || '%'))
|
||||
ORDER BY g.entry_date DESC, g.id DESC
|
||||
LIMIT 2000`,
|
||||
[
|
||||
companyId, filters.book,
|
||||
filters.year || null, filters.year || null,
|
||||
filters.account || null, filters.account || null,
|
||||
filters.search || null, filters.search || null, filters.search || null, filters.search || null
|
||||
]
|
||||
).map(entryFromRow);
|
||||
const debit = round2(entries.reduce((s, e) => s + Number(e.debit || 0), 0));
|
||||
const credit = round2(entries.reduce((s, e) => s + Number(e.credit || 0), 0));
|
||||
return { entries, summary: { count: entries.length, debit, credit, balance: round2(debit - credit) } };
|
||||
}
|
||||
|
||||
function getEntry(id, companyId) {
|
||||
return one('SELECT * FROM gl_entries WHERE id = ? AND entity_id = ?', [id, companyId]) || null;
|
||||
}
|
||||
|
||||
// ---- Normalize / validate ---------------------------------------------------
|
||||
|
||||
// Tolerant field picker: matches a parsed row's keys case/space/underscore-insensitively.
|
||||
function pick(raw, ...aliases) {
|
||||
const map = {};
|
||||
for (const key of Object.keys(raw || {})) map[key.toLowerCase().replace(/[\s_]+/g, '')] = raw[key];
|
||||
for (const alias of aliases) {
|
||||
const value = map[alias.toLowerCase().replace(/[\s_]+/g, '')];
|
||||
if (value !== undefined && value !== null && value !== '') return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Map a raw import row (tolerant headers) to the normalized GL entry shape.
|
||||
function normalizeRow(raw) {
|
||||
const entry_date = String(pick(raw, 'entry_date', 'date', 'posting_date', 'posted') || '').slice(0, 10);
|
||||
return {
|
||||
entry_date,
|
||||
fiscal_year: yearOf(entry_date),
|
||||
account: String(pick(raw, 'account', 'gl_account', 'account_number', 'acct') || '').trim(),
|
||||
account_type: strOrNull(pick(raw, 'account_type', 'type', 'account_name')),
|
||||
description: strOrNull(pick(raw, 'description', 'memo', 'desc', 'narrative')),
|
||||
reference: strOrNull(pick(raw, 'reference', 'ref', 'document', 'doc')),
|
||||
debit: round2(num(pick(raw, 'debit', 'dr'))),
|
||||
credit: round2(num(pick(raw, 'credit', 'cr'))),
|
||||
external_id: strOrNull(pick(raw, 'external_id', 'id', 'gl_id', 'entry_id'))
|
||||
};
|
||||
}
|
||||
|
||||
function entryError(entry) {
|
||||
if (!entry.account) return 'Missing account';
|
||||
if (!entry.entry_date) return 'Missing entry date';
|
||||
if (round2(entry.debit) === 0 && round2(entry.credit) === 0) return 'Debit or credit is required';
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate + coerce a normalized entry coming from the CRUD API.
|
||||
function validateEntry(body) {
|
||||
const entry = {
|
||||
entry_date: String(body.entry_date || '').slice(0, 10),
|
||||
account: String(body.account || '').trim(),
|
||||
account_type: strOrNull(body.account_type),
|
||||
description: strOrNull(body.description),
|
||||
reference: strOrNull(body.reference),
|
||||
debit: round2(num(body.debit)),
|
||||
credit: round2(num(body.credit)),
|
||||
external_id: strOrNull(body.external_id),
|
||||
asset_id: body.asset_id ? Number(body.asset_id) : null
|
||||
};
|
||||
entry.fiscal_year = yearOf(entry.entry_date);
|
||||
const err = entryError(entry);
|
||||
if (err) throw Object.assign(new Error(err), { status: 400 });
|
||||
return entry;
|
||||
}
|
||||
|
||||
// ---- Write (CRUD) -----------------------------------------------------------
|
||||
|
||||
function insertEntry(entry, book, userId, companyId, source) {
|
||||
const ts = now();
|
||||
const result = run(
|
||||
`INSERT INTO gl_entries (entity_id, book_code, entry_date, fiscal_year, account, account_type, description, reference, debit, credit, asset_id, external_id, source, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
companyId, book, entry.entry_date, entry.fiscal_year, entry.account, entry.account_type,
|
||||
entry.description, entry.reference, entry.debit, entry.credit, entry.asset_id || null,
|
||||
entry.external_id, source || 'manual', userId, ts, ts
|
||||
]
|
||||
);
|
||||
const created = getEntry(result.lastInsertRowid, companyId);
|
||||
audit(userId, 'create', 'gl_entry', result.lastInsertRowid, null, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
function createEntry(book, body, userId, companyId) {
|
||||
return insertEntry(validateEntry(body), book, userId, companyId, 'manual');
|
||||
}
|
||||
|
||||
function updateEntry(id, body, userId, companyId) {
|
||||
const before = getEntry(id, companyId);
|
||||
if (!before) return null;
|
||||
const entry = validateEntry({ ...before, ...body });
|
||||
run(
|
||||
`UPDATE gl_entries SET entry_date = ?, fiscal_year = ?, account = ?, account_type = ?, description = ?,
|
||||
reference = ?, debit = ?, credit = ?, asset_id = ?, external_id = ?, updated_at = ? WHERE id = ?`,
|
||||
[
|
||||
entry.entry_date, entry.fiscal_year, entry.account, entry.account_type, entry.description,
|
||||
entry.reference, entry.debit, entry.credit, entry.asset_id || null, entry.external_id, now(), id
|
||||
]
|
||||
);
|
||||
const after = getEntry(id, companyId);
|
||||
audit(userId, 'update', 'gl_entry', id, before, after);
|
||||
return after;
|
||||
}
|
||||
|
||||
function deleteEntry(id, userId, companyId) {
|
||||
const before = getEntry(id, companyId);
|
||||
if (!before) return false;
|
||||
run('DELETE FROM gl_entries WHERE id = ?', [id]);
|
||||
audit(userId, 'delete', 'gl_entry', id, before, null);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Import (preview + commit) ----------------------------------------------
|
||||
|
||||
// Find an existing entry an import row should reconcile with: by external_id first, else the natural
|
||||
// key (date + account + debit + credit + reference) within the same company + book.
|
||||
function matchExisting(entry, companyId, book) {
|
||||
if (entry.external_id) {
|
||||
const byId = one('SELECT * FROM gl_entries WHERE entity_id = ? AND book_code = ? AND external_id = ?', [companyId, book, entry.external_id]);
|
||||
if (byId) return byId;
|
||||
}
|
||||
return one(
|
||||
`SELECT * FROM gl_entries
|
||||
WHERE entity_id = ? AND book_code = ? AND entry_date = ? AND account = ?
|
||||
AND ROUND(debit, 2) = ? AND ROUND(credit, 2) = ? AND IFNULL(reference, '') = IFNULL(?, '')`,
|
||||
[companyId, book, entry.entry_date, entry.account, entry.debit, entry.credit, entry.reference]
|
||||
);
|
||||
}
|
||||
|
||||
function differs(existing, entry) {
|
||||
return existing.entry_date !== entry.entry_date
|
||||
|| existing.account !== entry.account
|
||||
|| (existing.account_type || '') !== (entry.account_type || '')
|
||||
|| (existing.description || '') !== (entry.description || '')
|
||||
|| (existing.reference || '') !== (entry.reference || '')
|
||||
|| round2(existing.debit) !== entry.debit
|
||||
|| round2(existing.credit) !== entry.credit;
|
||||
}
|
||||
|
||||
// Classify each raw import row as new / update / duplicate / error (no DB writes).
|
||||
function previewImport(rawRows, book, companyId) {
|
||||
const summary = { new: 0, update: 0, duplicate: 0, error: 0 };
|
||||
const rows = (rawRows || []).map((raw, index) => {
|
||||
const entry = normalizeRow(raw);
|
||||
const err = entryError(entry);
|
||||
if (err) { summary.error += 1; return { index, status: 'error', entry, error: err }; }
|
||||
const existing = matchExisting(entry, companyId, book);
|
||||
if (!existing) { summary.new += 1; return { index, status: 'new', entry, existing_id: null }; }
|
||||
if (differs(existing, entry)) { summary.update += 1; return { index, status: 'update', entry, existing_id: existing.id }; }
|
||||
summary.duplicate += 1;
|
||||
return { index, status: 'duplicate', entry, existing_id: existing.id };
|
||||
});
|
||||
return { summary, rows };
|
||||
}
|
||||
|
||||
// Apply classified rows by strategy. Re-validates company/book server-side.
|
||||
// skip — insert only 'new'; conflicts skipped
|
||||
// merge — insert 'new', update 'update'; identical duplicates skipped
|
||||
// insert — insert every non-error row as a brand-new entry (ignore matches)
|
||||
function commitImport(rows, strategy, book, userId, companyId) {
|
||||
const result = { inserted: 0, updated: 0, skipped: 0, errors: 0 };
|
||||
const mode = ['skip', 'merge', 'insert'].includes(strategy) ? strategy : 'merge';
|
||||
tx(() => {
|
||||
for (const row of rows || []) {
|
||||
const entry = normalizeRow(row.entry || {});
|
||||
if (entryError(entry)) { result.errors += 1; continue; }
|
||||
const status = row.status;
|
||||
if (mode === 'insert') { insertEntry(entry, book, userId, companyId, 'import'); result.inserted += 1; continue; }
|
||||
if (status === 'new') { insertEntry(entry, book, userId, companyId, 'import'); result.inserted += 1; continue; }
|
||||
if (mode === 'merge' && status === 'update') {
|
||||
const existing = row.existing_id ? getEntry(row.existing_id, companyId) : matchExisting(entry, companyId, book);
|
||||
if (existing) { updateEntry(existing.id, entry, userId, companyId); result.updated += 1; }
|
||||
else { insertEntry(entry, book, userId, companyId, 'import'); result.inserted += 1; }
|
||||
continue;
|
||||
}
|
||||
result.skipped += 1; // duplicates, or conflicts under 'skip'
|
||||
}
|
||||
});
|
||||
audit(userId, 'import', 'gl_entries', book, null, { strategy: mode, ...result });
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Export -----------------------------------------------------------------
|
||||
|
||||
function entriesReport(filters, companyId) {
|
||||
const { entries, summary } = listEntries(filters, companyId);
|
||||
return {
|
||||
title: `${filters.book} general ledger entries${filters.year ? ` — ${filters.year}` : ''}`,
|
||||
generatedAt: now(),
|
||||
columns: [
|
||||
{ key: 'entry_date', label: 'Date', format: 'text' },
|
||||
{ key: 'account', label: 'Account', format: 'text' },
|
||||
{ key: 'account_type', label: 'Account type', format: 'text' },
|
||||
{ key: 'description', label: 'Description', format: 'text' },
|
||||
{ key: 'reference', label: 'Reference', format: 'text' },
|
||||
{ key: 'debit', label: 'Debit', format: 'currency' },
|
||||
{ key: 'credit', label: 'Credit', format: 'currency' },
|
||||
{ key: 'external_id', label: 'External ID', format: 'text' },
|
||||
{ key: 'source', label: 'Source', format: 'text' }
|
||||
],
|
||||
rows: entries,
|
||||
totals: { debit: summary.debit, credit: summary.credit },
|
||||
meta: { note: `${summary.count} entr${summary.count === 1 ? 'y' : 'ies'} · debit ${summary.debit} · credit ${summary.credit} · balance ${summary.balance}` }
|
||||
};
|
||||
}
|
||||
|
||||
// Full change timeline for one entry, drawn from the audit log (create/update events, and any
|
||||
// import-driven changes — all share entity_type 'gl_entry'). Returns null if the entry isn't in
|
||||
// this company (so it can't be probed cross-company). Newest first.
|
||||
function entryHistory(id, companyId) {
|
||||
if (!getEntry(id, companyId)) return null;
|
||||
return all(
|
||||
`SELECT al.id, al.action, al.before_json, al.after_json, al.created_at, u.name AS actor_name
|
||||
FROM audit_logs al LEFT JOIN users u ON u.id = al.actor_id
|
||||
WHERE al.entity_type = 'gl_entry' AND al.entity_id = ?
|
||||
ORDER BY al.id DESC`,
|
||||
[String(id)]
|
||||
).map((row) => ({
|
||||
id: row.id,
|
||||
action: row.action,
|
||||
actor_name: row.actor_name,
|
||||
created_at: row.created_at,
|
||||
before: parseJson(row.before_json, null),
|
||||
after: parseJson(row.after_json, null)
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
commitImport,
|
||||
createEntry,
|
||||
deleteEntry,
|
||||
entriesReport,
|
||||
entryHistory,
|
||||
getEntry,
|
||||
listEntries,
|
||||
previewImport,
|
||||
updateEntry
|
||||
};
|
||||
@@ -1784,6 +1784,61 @@ async function main() {
|
||||
})).assignment;
|
||||
if (crossEmpAssign.employee_id) throw new Error('A company A assignment accepted a company B employee');
|
||||
|
||||
// ---- GL journal entries: CRUD + audit + import (conflict/merge) + export ----
|
||||
const glEntry = (await request('/api/books/GAAP/gl-entries', {
|
||||
method: 'POST', headers: companyHeaders(companyA),
|
||||
body: JSON.stringify({ entry_date: '2026-03-01', account: '1500', account_type: 'Asset', description: 'Equipment', reference: 'INV-1', debit: 1000, credit: 0, external_id: `GL-${suffix}-100` })
|
||||
})).entry;
|
||||
if (!glEntry || glEntry.entity_id !== companyA) throw new Error('GL entry was not created/scoped');
|
||||
const glListed = (await request('/api/books/GAAP/gl-entries?year=2026', { headers: companyHeaders(companyA) }));
|
||||
if (!glListed.entries.some((e) => e.id === glEntry.id) || glListed.summary.debit < 1000) throw new Error('GL entry did not list with totals');
|
||||
|
||||
// Update it (full before/after must be audited).
|
||||
await request(`/api/books/GAAP/gl-entries/${glEntry.id}`, {
|
||||
method: 'PUT', headers: companyHeaders(companyA), body: JSON.stringify({ debit: 1200, description: 'Equipment (revised)' })
|
||||
});
|
||||
const glAudit = await request('/api/audit-logs?entity_type=gl_entry', { headers: auth });
|
||||
if (glAudit.total < 2) throw new Error('GL changes were not audited');
|
||||
const updateLog = glAudit.rows.find((r) => r.action === 'update' && r.entity_id === String(glEntry.id));
|
||||
if (!updateLog || !updateLog.before_json || !updateLog.after_json) throw new Error('GL update did not capture full before/after');
|
||||
|
||||
// Import preview: one row matches the existing entry's external id (update), one is brand new.
|
||||
const csv = `Date,Account,Account Type,Description,Reference,Debit,Credit,External Id\n` +
|
||||
`2026-03-01,1500,Asset,Equipment changed,INV-1,2000,0,GL-${suffix}-100\n` +
|
||||
`2026-03-05,6400,Expense,Depreciation,INV-2,0,250,GL-${suffix}-200\n`;
|
||||
const previewForm = new FormData();
|
||||
previewForm.append('file', new Blob([csv], { type: 'text/csv' }), 'gl.csv');
|
||||
const glPreview = await (await fetch(`${baseUrl}/api/books/GAAP/gl-entries/import/preview`, { method: 'POST', headers: companyHeaders(companyA), body: previewForm })).json();
|
||||
if (glPreview.summary.new !== 1 || glPreview.summary.update !== 1) {
|
||||
throw new Error(`Import preview misclassified rows: ${JSON.stringify(glPreview.summary)}`);
|
||||
}
|
||||
|
||||
// Commit with merge: insert the new row, update the matched one.
|
||||
const glCommit = await request('/api/books/GAAP/gl-entries/import/commit', {
|
||||
method: 'POST', headers: companyHeaders(companyA), body: JSON.stringify({ rows: glPreview.rows, strategy: 'merge' })
|
||||
});
|
||||
if (glCommit.inserted !== 1 || glCommit.updated !== 1) throw new Error(`Import commit applied wrong counts: ${JSON.stringify(glCommit)}`);
|
||||
|
||||
// Export CSV + Excel.
|
||||
const glCsv = await fetch(`${baseUrl}/api/books/GAAP/gl-entries/export?year=2026&format=csv`, { headers: companyHeaders(companyA) });
|
||||
if (!(glCsv.headers.get('content-type') || '').includes('text/csv')) throw new Error('GL CSV export was not text/csv');
|
||||
if (!(await glCsv.text()).startsWith('Date,Account')) throw new Error('GL CSV header was malformed');
|
||||
const glXlsx = await fetch(`${baseUrl}/api/books/GAAP/gl-entries/export?year=2026&format=xlsx`, { headers: companyHeaders(companyA) });
|
||||
if (!(glXlsx.headers.get('content-type') || '').includes('spreadsheet')) throw new Error('GL Excel export had the wrong content-type');
|
||||
|
||||
// Per-entry change history (create + update events with before/after).
|
||||
const glHistory = (await request(`/api/books/GAAP/gl-entries/${glEntry.id}/history`, { headers: companyHeaders(companyA) })).history;
|
||||
if (glHistory.length < 2 || !glHistory.some((h) => h.action === 'update' && h.before && h.after)) {
|
||||
throw new Error('GL entry history did not return the create + update timeline');
|
||||
}
|
||||
// History is company-scoped (company B can't read company A's entry history).
|
||||
const crossHistory = await fetch(`${baseUrl}/api/books/GAAP/gl-entries/${glEntry.id}/history`, { headers: companyHeaders(companyB) });
|
||||
if (crossHistory.status !== 404) throw new Error('Cross-company GL history should 404');
|
||||
|
||||
// Isolation: company B's GAAP book sees none of company A's 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');
|
||||
|
||||
console.log('Smoke test passed');
|
||||
}
|
||||
|
||||
|
||||
@@ -266,8 +266,34 @@
|
||||
<p>
|
||||
Select a book to view its <strong>GL ledger</strong> in debit/credit format — asset cost, accumulated depreciation, and the
|
||||
period’s depreciation expense rolled to GL accounts, with per-asset detail and a summary. You can export the ledger.
|
||||
This rollup is computed automatically from depreciation; the <strong>Journal entries</strong> panel below it is a separate,
|
||||
editable layer (the two do not double-count).
|
||||
</p>
|
||||
|
||||
<h3>GL journal entries — add, edit, import & track changes</h3>
|
||||
<p>
|
||||
Below the computed ledger, the <strong>Journal entries</strong> panel holds stored, editable GL postings for the selected
|
||||
book and year. Use it to record manual journal lines or to load entries from another system.
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Add / edit / delete.</strong> Click <strong>Add entry</strong> (or the pencil/trash on a row) to capture a
|
||||
date, GL account, account type, description, reference, <strong>debit</strong> and/or <strong>credit</strong>, and an
|
||||
optional <strong>external ID</strong>. The panel shows running debit, credit, and balance totals.</li>
|
||||
<li><strong>Import (CSV or Excel/XLS).</strong> Click <strong>Import</strong> and choose a <code>.csv</code>,
|
||||
<code>.xlsx</code>, or <code>.xls</code> file. Column headers are matched flexibly (Date, Account, Debit, Credit,
|
||||
Reference, Description, External Id, …).</li>
|
||||
<li><strong>Conflict / merge resolution.</strong> The import first shows a <strong>preview</strong> that classifies every
|
||||
row as <em>New</em>, <em>Update</em> (matches an existing entry but differs), <em>Duplicate</em> (identical), or
|
||||
<em>Error</em>, with counts. Rows are matched to existing entries by <strong>external ID</strong> first, then by a
|
||||
natural key (date + account + debit + credit + reference). You then pick a strategy — <strong>Skip conflicts</strong>,
|
||||
<strong>Merge</strong> (update existing + insert new), or <strong>Insert as new</strong> — and confirm. Set an external ID
|
||||
on your entries for the most reliable re-import matching.</li>
|
||||
<li><strong>Export.</strong> The <strong>Export</strong> menu downloads the current entries as CSV or Excel (XLSX).</li>
|
||||
<li><strong>Change history.</strong> Every add, edit, delete, and import is fully logged. Click the <strong>history</strong>
|
||||
(clock) icon on a row to see that entry’s timeline — who changed it, when, and a field-level before→after diff. The same
|
||||
events are searchable system-wide in <strong>Admin → Activity & Audit Trail</strong>.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Depreciation methods & critical fields</h3>
|
||||
<p>
|
||||
Each book’s depreciation is computed from the depreciation-critical fields: property type, placed-in-service date,
|
||||
@@ -523,6 +549,36 @@
|
||||
Completed forms — with signature, ratings, notes, and photos — are viewable from both the asset’s PM tab and the
|
||||
<strong>Completed PM forms</strong> list on the Maintenance screen.
|
||||
</v-alert>
|
||||
|
||||
<h3>Dynamic scheduling — usage meters & the lifespan wear curve</h3>
|
||||
<p>
|
||||
Beyond a fixed calendar interval, a schedule’s next-due date can adjust to real-world use. When usage and
|
||||
age signals are enabled, the next service is due at the <strong>earliest</strong> of three triggers — the
|
||||
calendar interval, a projected usage threshold, or an age-compressed date — and the asset’s PM list shows a
|
||||
small badge (📅 date, ⏱ usage, or 📉 age) indicating which one is driving the date.
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Usage meters.</strong> Add one or more meters to a PM plan (e.g. <em>Operating Hours</em>, kWh,
|
||||
gallons, cycles) with a “due every” threshold. Record the current reading when completing a service, or
|
||||
anytime via <strong>Log reading</strong> on the asset’s PM tab. DepreCore infers the usage <em>rate</em>
|
||||
from successive readings and pulls the due-date in when a machine is running hard; crossing a threshold
|
||||
raises a usage alert (e.g. “Operating Hours 512/500”).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Lifespan wear curve.</strong> Turn on “Tighten the interval as the asset ages” on a plan to shorten
|
||||
the service interval as an asset approaches its estimated life (from the asset’s useful-life months). New
|
||||
equipment is serviced on the base cadence; near end-of-life it is serviced more often, along a curve you
|
||||
tune in PM settings.
|
||||
</li>
|
||||
</ul>
|
||||
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||
Both signals are <strong>opt-in</strong>: an administrator enables “Usage-based scheduling” and the “Lifespan
|
||||
wear curve” (with its end-of-life factor and aggressiveness) under <strong>Admin → Application Configuration →
|
||||
Preventative maintenance defaults</strong>. A nightly job re-projects due-dates so usage/age schedules stay
|
||||
current even when an asset isn’t serviced for a while; admins can also run it on demand with <strong>Run
|
||||
now</strong>.
|
||||
</v-alert>
|
||||
</section>
|
||||
|
||||
<!-- PARTS -->
|
||||
@@ -561,6 +617,7 @@
|
||||
<li>Use the type selector and filter box to find records; create a contact with <strong>New contact</strong>.</li>
|
||||
<li>Capture name/organization, international-friendly address and phone, and email.</li>
|
||||
<li><strong>Type-specific fields</strong>: employee ID/department/manager/start date; contractor tax ID or business license; vendor and service-provider star rating and credit term.</li>
|
||||
<li><strong>Location map</strong> — once an address is entered, the contact card can show an embedded <strong>Google map</strong> of the location, with an <em>Open in Google Maps</em> link. For a new contact the map appears on a <em>Show map</em> click; when you open an existing contact with an address it’s shown automatically.</li>
|
||||
<li>Keep a per-contact <strong>notes log</strong> for running history.</li>
|
||||
</ul>
|
||||
<h3>How contacts connect to the rest of the app</h3>
|
||||
@@ -605,6 +662,7 @@
|
||||
<ul>
|
||||
<li><strong>Email digest</strong> — when email is enabled and a mail server is configured, new alerts are emailed to the recipient list. You can also trigger <strong>Email digest now</strong> from the Alerts screen.</li>
|
||||
<li><strong>Webhook</strong> — each new alert can be posted to an external URL as a JSON object, optionally signed for authenticity. Useful for chat tools or custom automation.</li>
|
||||
<li><strong>Microsoft Teams</strong> — post a digest card of new alerts to a Teams channel or chat (see <a href="#" @click.prevent="select('teams')">Microsoft Teams integration</a>).</li>
|
||||
<li><strong>ServiceNow incident</strong> — raise a ticket from any alert (see below).</li>
|
||||
</ul>
|
||||
|
||||
@@ -655,21 +713,65 @@
|
||||
</v-alert>
|
||||
</section>
|
||||
|
||||
<!-- MICROSOFT TEAMS -->
|
||||
<section v-show="isShown('teams')" class="manual-section">
|
||||
<h2>Microsoft Teams integration</h2>
|
||||
<p>
|
||||
DepreCore can post newly raised alerts to a <strong>Microsoft Teams</strong> channel or chat. One digest card is sent per
|
||||
alert cycle, alongside (or instead of) email, webhook, and ServiceNow delivery. Configure it in
|
||||
<strong>Administration → Integrations → Microsoft Teams</strong>.
|
||||
</p>
|
||||
<h3>Connecting</h3>
|
||||
<ol>
|
||||
<li>In Teams, create an incoming webhook for the target channel — a <strong>Workflows</strong> “post to a channel when a
|
||||
webhook request is received” flow (recommended), or a legacy <strong>Incoming Webhook</strong> connector.</li>
|
||||
<li>Paste the webhook URL into DepreCore and pick the matching <strong>card format</strong>: <em>Adaptive Card (Workflows)</em>
|
||||
for the modern flow, or <em>MessageCard (legacy connector)</em>.</li>
|
||||
<li>Choose a <strong>minimum severity</strong> to post (e.g. Warning and above) so the channel only gets what matters, turn on
|
||||
<strong>Enable Teams delivery</strong>, and click <strong>Send test card</strong> to confirm it lands in the channel.</li>
|
||||
</ol>
|
||||
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||
Cards are colour-coded by severity and summarize the pending alerts (title, detail, due date, asset). Each alert is delivered
|
||||
once across all channels. <em>Note:</em> this “Teams” (Microsoft Teams chat) is unrelated to <strong>Admin → Users &
|
||||
Teams</strong>, which groups your DepreCore users.
|
||||
</v-alert>
|
||||
</section>
|
||||
|
||||
<!-- ADMIN -->
|
||||
<section v-show="isShown('admin')" class="manual-section">
|
||||
<h2>Administration</h2>
|
||||
<p>
|
||||
<strong>Admin</strong> (admin role) is where the application is configured. It is split into three sub-screens in the
|
||||
navigation rail: <strong>Users & Teams</strong>, <strong>Application Configuration</strong> (application settings, asset classes, depreciation zones, Section 179 limits, asset ID templates, asset categories,
|
||||
asset departments, PM categories, parts categories, maintenance defaults, tax rule sets), and <strong>Integrations</strong> (email, webhook, ServiceNow, Workday). Each is described below.
|
||||
<strong>Admin</strong> (admin role) is where the application is configured. It is split into sub-screens in the
|
||||
navigation rail: <strong>Users & Teams</strong>, <strong>Password & Security</strong>,
|
||||
<strong>Activity & Audit Trail</strong>, <strong>Application Configuration</strong> (companies, application
|
||||
settings, asset classes, depreciation zones, Section 179 limits, asset ID templates, asset categories,
|
||||
asset departments, PM categories, parts categories, maintenance defaults, tax rule sets), and
|
||||
<strong>Integrations</strong> (email, webhook, Microsoft Teams, ServiceNow, Workday). Each is described below.
|
||||
</p>
|
||||
|
||||
<h3>Users & roles</h3>
|
||||
<ul>
|
||||
<li>Create and edit users — name, email, team, role, status, and password.</li>
|
||||
<li>The list paginates and is filterable. A user’s role controls what they can see and do.</li>
|
||||
<li><strong>Company access</strong> — assign which companies a user may see and switch between (administrators
|
||||
always see every company). See <a href="#" @click.prevent="select('companies')">Companies</a>.</li>
|
||||
<li>You can require a <strong>password change at next sign-in</strong>, and a locked account (after too many
|
||||
failed sign-ins) shows an <strong>Unlock</strong> action — see
|
||||
<a href="#" @click.prevent="select('security')">Password & security</a>.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Companies</h3>
|
||||
<p>Add, edit, and dispose/restore companies, each with its own books, GL, and data. Covered under
|
||||
<a href="#" @click.prevent="select('companies')">Companies</a>.</p>
|
||||
|
||||
<h3>Password & security</h3>
|
||||
<p>Password complexity/expiry/history rules and account lockout. Covered under
|
||||
<a href="#" @click.prevent="select('security')">Password & security</a>.</p>
|
||||
|
||||
<h3>Activity & audit trail</h3>
|
||||
<p>A searchable, exportable log of every change made in the system. Covered under
|
||||
<a href="#" @click.prevent="select('security')">Password & security</a>.</p>
|
||||
|
||||
<h3>Teams</h3>
|
||||
<p>Group users into teams. Select a team to edit or delete it (a team in use by members can’t be deleted until they’re reassigned).</p>
|
||||
|
||||
@@ -747,11 +849,20 @@
|
||||
payload is shown for whoever builds the receiving side.
|
||||
</p>
|
||||
|
||||
<h3>Microsoft Teams</h3>
|
||||
<p>Post alert digests to a Teams channel — webhook URL, card format, and minimum severity. Covered under <a href="#" @click.prevent="select('teams')">Microsoft Teams integration</a>.</p>
|
||||
|
||||
<h3>ServiceNow integration</h3>
|
||||
<p>Connection, incident push, and CMDB pull settings — covered in detail under <a href="#" @click.prevent="select('servicenow')">ServiceNow integration</a>.</p>
|
||||
|
||||
<h3>Maintenance (PM) defaults</h3>
|
||||
<p>Set the default maintenance frequency for newly attached plans and how many days ahead PM-due alerts should appear.</p>
|
||||
<p>
|
||||
Set the default maintenance frequency for newly attached plans and how many days ahead PM-due alerts should appear.
|
||||
This is also where you enable <strong>dynamic scheduling</strong>: <strong>usage-based scheduling</strong> (meters) and the
|
||||
<strong>lifespan wear curve</strong> (with its end-of-life interval factor and curve aggressiveness), plus the
|
||||
<strong>nightly recompute</strong> job (on/off, hour of day, and a <em>Run now</em> button). See
|
||||
<a href="#" @click.prevent="select('pm')">Preventative maintenance</a> for how these signals drive due-dates.
|
||||
</p>
|
||||
|
||||
<h3>Workday connector</h3>
|
||||
<ul>
|
||||
@@ -799,6 +910,83 @@
|
||||
</v-alert>
|
||||
</section>
|
||||
|
||||
<!-- COMPANIES -->
|
||||
<section v-show="isShown('companies')" class="manual-section">
|
||||
<h2>Companies (multi-company)</h2>
|
||||
<p>
|
||||
DepreCore can manage <strong>multiple companies</strong>, each with its own assets, books and general ledger, PM plans,
|
||||
alerts, reports, reference data (categories/locations/departments), contacts, templates, saved reports, parts, and
|
||||
employee directory. You work in one <strong>active company</strong> at a time.
|
||||
</p>
|
||||
<h3>Switching companies</h3>
|
||||
<p>
|
||||
When you have access to more than one company, a <strong>company switcher</strong> appears in the top bar. Choosing a
|
||||
company instantly re-scopes every screen — dashboard, assets, books, maintenance, alerts, reports, and so on — to that
|
||||
company. Your selection is remembered between visits.
|
||||
</p>
|
||||
<h3>Managing companies</h3>
|
||||
<p>Administrators manage companies in <strong>Admin → Application Configuration → Companies</strong>:</p>
|
||||
<ul>
|
||||
<li><strong>Add / edit</strong> a company — name, legal name, base currency, fiscal year-end month, and separate
|
||||
<strong>Federal</strong>, <strong>State</strong>, and (optional) <strong>Local</strong> tax IDs. A new company is
|
||||
created with the standard book set and the standard reference data so its ledger works immediately.</li>
|
||||
<li><strong>Dispose (archive)</strong> a company to retire it — all of its assets, books, GL, and history are
|
||||
<em>preserved</em>, but it’s hidden from the switcher and can’t be selected for new work. The last active company can’t be
|
||||
disposed. Disposed companies can be <strong>restored</strong> at any time.</li>
|
||||
</ul>
|
||||
<h3>Who can see which company</h3>
|
||||
<p>
|
||||
Each user is granted access to one or more companies (set in the user’s edit dialog — see
|
||||
<a href="#" @click.prevent="select('admin')">Administration</a>); the switcher only lists the companies they may use, and a
|
||||
user can’t reach another company’s data. <strong>Administrators see every company.</strong>
|
||||
</p>
|
||||
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||
A few things are deliberately <strong>shared across all companies</strong>: the tax-rule-set catalog (each company still
|
||||
selects which set each of its books uses), IRS reference data (depreciation zones and §179 limits), the integrations
|
||||
(email/Teams/webhook/ServiceNow/Workday), and the users/roles and audit/security layer.
|
||||
</v-alert>
|
||||
</section>
|
||||
|
||||
<!-- SECURITY -->
|
||||
<section v-show="isShown('security')" class="manual-section">
|
||||
<h2>Password & security · audit trail</h2>
|
||||
|
||||
<h3>Password & account security policy</h3>
|
||||
<p>
|
||||
Administrators set the rules for locally managed passwords in <strong>Admin → Password & Security</strong>. They are
|
||||
enforced whenever a password is set or changed and at sign-in:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Complexity</strong> — minimum length and required character classes (uppercase, lowercase, number, symbol),
|
||||
and a rule that a password may not contain the user’s name or email.</li>
|
||||
<li><strong>Rotation & reuse</strong> — expire passwords after N days, block reuse of the last N passwords, and an
|
||||
optional minimum password age.</li>
|
||||
<li><strong>Account lockout</strong> — lock an account for a set number of minutes after too many failed sign-ins. A locked
|
||||
user is refused until the window passes or an administrator clicks <strong>Unlock</strong> on the user.</li>
|
||||
</ul>
|
||||
<p>
|
||||
The live rule preview shows users exactly what’s required. Users change their own password from the account menu
|
||||
(<strong>Change password</strong>); when a change is required (expired, or set by an admin) they’re prompted at sign-in.
|
||||
</p>
|
||||
|
||||
<h3>Activity & Audit Trail</h3>
|
||||
<p>
|
||||
<strong>Admin → Activity & Audit Trail</strong> is a complete, searchable record of user activity — sign-ins and
|
||||
lockouts, password and permission changes, GL and data edits, imports, and every create/update/delete across the app.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Filter by <strong>user</strong>, <strong>action</strong>, <strong>entity type</strong>, <strong>date range</strong>, or
|
||||
free-text search, with pagination.</li>
|
||||
<li>Each entry can expand to show the full <strong>before → after</strong> detail of what changed.</li>
|
||||
<li><strong>Export CSV</strong> for external review or retention.</li>
|
||||
</ul>
|
||||
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||
Because every change is captured here with full before/after detail, the audit trail is the system-of-record for change
|
||||
tracking — for example, each GL journal-entry edit is logged here and also surfaced inline via the entry’s
|
||||
<strong>history</strong> icon (see <a href="#" @click.prevent="select('books')">Books, depreciation & tax</a>).
|
||||
</v-alert>
|
||||
</section>
|
||||
|
||||
<div class="manual-footer quiet">DepreCore User Guide · select a topic on the left, or use Print / Save PDF for the full manual.</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -878,16 +1066,19 @@ export default {
|
||||
{ id: 'assets', title: 'Assets — creating & managing', icon: 'mdi-archive-search-outline', keywords: 'asset register create edit import export mass edit workbench files notes tasks warranty' },
|
||||
{ id: 'lifecycle', title: 'Asset lifecycle', icon: 'mdi-cash-remove', keywords: 'dispose disposal sale retire gain loss lease amortization impairment adjustment write down' },
|
||||
{ id: 'barcodes', title: 'Barcodes & scanning', icon: 'mdi-barcode-scan', keywords: 'barcode label print scan camera tag' },
|
||||
{ id: 'books', title: 'Books, depreciation & tax', icon: 'mdi-book-open-variant', keywords: 'book gaap federal depreciation general ledger gl method convention 179 bonus reports' },
|
||||
{ id: 'books', title: 'Books, depreciation & tax', icon: 'mdi-book-open-variant', keywords: 'book gaap federal depreciation general ledger gl method convention 179 bonus reports journal entry entries import export csv excel conflict merge history zone section 179' },
|
||||
{ id: 'taxrules', title: 'Tax rule sets', icon: 'mdi-scale-balance', keywords: 'tax rules jurisdiction method limits activate version' },
|
||||
{ id: 'templates', title: 'Templates', icon: 'mdi-form-select', keywords: 'template defaults custom fields data entry' },
|
||||
{ id: 'pm', title: 'Preventative maintenance', icon: 'mdi-wrench-clock', keywords: 'pm maintenance plan steps complete close signature ratings guidance photos schedule' },
|
||||
{ id: 'pm', title: 'Preventative maintenance', icon: 'mdi-wrench-clock', keywords: 'pm maintenance plan steps complete close signature ratings guidance photos schedule meter usage reading hours lifespan wear curve dynamic nightly recompute' },
|
||||
{ id: 'parts', title: 'Parts inventory', icon: 'mdi-package-variant-closed', keywords: 'parts stock aisle bin reserve reorder supplier inventory' },
|
||||
{ id: 'contacts', title: 'Contacts & CRM', icon: 'mdi-card-account-details-outline', keywords: 'contacts crm vendor employee contractor work location supplier notes' },
|
||||
{ id: 'assignments', title: 'Assignments (custody)', icon: 'mdi-account-switch-outline', keywords: 'assignment custody employee release traceability' },
|
||||
{ id: 'alerts', title: 'Alerts & reminders', icon: 'mdi-bell-alert-outline', keywords: 'alerts reminders severity scan acknowledge dismiss email digest webhook servicenow' },
|
||||
{ id: 'alerts', title: 'Alerts & reminders', icon: 'mdi-bell-alert-outline', keywords: 'alerts reminders severity scan acknowledge dismiss email digest webhook teams microsoft servicenow' },
|
||||
{ id: 'servicenow', title: 'ServiceNow integration', icon: 'mdi-cloud-sync-outline', keywords: 'servicenow incident ticket cmdb import sync connection' },
|
||||
{ id: 'admin', title: 'Administration', icon: 'mdi-cog-outline', keywords: 'admin users roles teams settings smtp email webhook workday configuration' },
|
||||
{ id: 'teams', title: 'Microsoft Teams', icon: 'mdi-microsoft-teams', keywords: 'teams microsoft chat channel webhook adaptive card messagecard alert digest notification workflow connector' },
|
||||
{ id: 'admin', title: 'Administration', icon: 'mdi-cog-outline', keywords: 'admin users roles teams settings smtp email webhook workday configuration company companies password security lockout audit activity trail' },
|
||||
{ id: 'companies', title: 'Companies (multi-company)', icon: 'mdi-domain', keywords: 'company companies multi tenant switch active dispose archive restore tax id federal state local entity access' },
|
||||
{ id: 'security', title: 'Password & security · audit', icon: 'mdi-shield-key-outline', keywords: 'password policy complexity expiry lockout reuse history audit trail activity log change tracking export forced change' },
|
||||
{ id: 'roles', title: 'Roles & permissions', icon: 'mdi-shield-account-outline', keywords: 'roles permissions admin finance operations viewer access' }
|
||||
]
|
||||
};
|
||||
|
||||
@@ -149,6 +149,159 @@
|
||||
<div v-else class="quiet text-body-2">Select a book to view its general ledger.</div>
|
||||
</v-card>
|
||||
|
||||
<!-- GL journal entries (stored, editable, fully audited) -->
|
||||
<v-card class="span-12 panel-pad">
|
||||
<div class="toolbar-row mb-3">
|
||||
<div>
|
||||
<h2 class="section-title">Journal entries</h2>
|
||||
<div class="quiet text-body-2">
|
||||
Manual & imported GL postings for <strong>{{ ledgerBook }}</strong> ({{ ledgerYear }}). All changes are
|
||||
recorded in Admin → Activity & Audit Trail.
|
||||
</div>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-upload" @click="$refs.glFile.click()">Import</v-btn>
|
||||
<input ref="glFile" hidden type="file" accept=".csv,.xlsx,.xls" @change="onImportFile" />
|
||||
<v-menu>
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" size="small" variant="tonal" prepend-icon="mdi-download">Export</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item title="CSV" @click="exportEntries('csv')" />
|
||||
<v-list-item title="Excel (XLSX)" @click="exportEntries('xlsx')" />
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-btn size="small" color="primary" prepend-icon="mdi-plus" @click="openEntry()">Add entry</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="ledger-summary mb-3">
|
||||
<div class="ledger-chip"><span class="quiet">Entries</span><strong>{{ entrySummary.count }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Debit</span><strong>{{ currency(entrySummary.debit) }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Credit</span><strong>{{ currency(entrySummary.credit) }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Balance</span><strong>{{ currency(entrySummary.balance) }}</strong></div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:columns="entryColumns"
|
||||
:rows="entries"
|
||||
row-key="id"
|
||||
:page-size="15"
|
||||
has-actions
|
||||
searchable
|
||||
search-placeholder="Filter entries"
|
||||
empty-text="No journal entries for this book and year."
|
||||
>
|
||||
<template #cell-debit="{ row }">{{ row.debit ? currency(row.debit) : '' }}</template>
|
||||
<template #cell-credit="{ row }">{{ row.credit ? currency(row.credit) : '' }}</template>
|
||||
<template #cell-source="{ row }"><v-chip size="x-small" variant="tonal">{{ row.source }}</v-chip></template>
|
||||
<template #actions="{ row }">
|
||||
<v-btn icon="mdi-history" size="small" variant="text" title="Change history" @click="openHistory(row)" />
|
||||
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEntry(row)" />
|
||||
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="removeEntry(row)" />
|
||||
</template>
|
||||
</DataTable>
|
||||
<v-alert v-if="entryError" type="error" density="compact" class="mt-2">{{ entryError }}</v-alert>
|
||||
<v-alert v-if="message" type="success" density="compact" class="mt-2">{{ message }}</v-alert>
|
||||
</v-card>
|
||||
|
||||
<!-- Add / edit GL entry -->
|
||||
<v-dialog v-model="entryDialog" max-width="640">
|
||||
<v-card>
|
||||
<v-card-title>{{ entryForm.id ? 'Edit GL entry' : 'New GL entry' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="form-grid">
|
||||
<v-text-field v-model="entryForm.entry_date" type="date" label="Date *" />
|
||||
<v-text-field v-model="entryForm.account" label="GL account *" />
|
||||
<v-text-field v-model="entryForm.account_type" label="Account type" />
|
||||
<v-text-field v-model="entryForm.reference" label="Reference" />
|
||||
<v-text-field v-model.number="entryForm.debit" type="number" prefix="$" label="Debit" />
|
||||
<v-text-field v-model.number="entryForm.credit" type="number" prefix="$" label="Credit" />
|
||||
<v-text-field v-model="entryForm.external_id" class="full" label="External ID (used to match on re-import)" />
|
||||
<v-textarea v-model="entryForm.description" class="full" label="Description" rows="2" />
|
||||
</div>
|
||||
<v-alert v-if="entryDialogError" type="error" density="compact" class="mt-2">{{ entryDialogError }}</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="entryDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="savingEntry" @click="saveEntry">Save entry</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Import preview / conflict resolution -->
|
||||
<v-dialog v-model="importDialog" max-width="760" scrollable>
|
||||
<v-card v-if="importPreview">
|
||||
<v-card-title>Import preview — {{ ledgerBook }}</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="ledger-summary mb-3">
|
||||
<div class="ledger-chip"><span class="quiet">New</span><strong>{{ importPreview.summary.new }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Update</span><strong>{{ importPreview.summary.update }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Duplicate</span><strong>{{ importPreview.summary.duplicate }}</strong></div>
|
||||
<div class="ledger-chip"><span class="quiet">Errors</span><strong>{{ importPreview.summary.error }}</strong></div>
|
||||
</div>
|
||||
<div class="text-body-2 quiet mb-1">Conflict strategy</div>
|
||||
<v-radio-group v-model="importStrategy" inline hide-details density="compact" class="mb-2">
|
||||
<v-radio label="Skip conflicts" value="skip" />
|
||||
<v-radio label="Merge (update existing)" value="merge" />
|
||||
<v-radio label="Insert as new" value="insert" />
|
||||
</v-radio-group>
|
||||
<div style="overflow-x:auto; max-height:340px">
|
||||
<table class="asset-table">
|
||||
<thead><tr><th>Status</th><th>Date</th><th>Account</th><th class="text-right">Debit</th><th class="text-right">Credit</th><th>Reference</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in importPreview.rows" :key="r.index">
|
||||
<td><v-chip size="x-small" :color="statusColor(r.status)" variant="tonal">{{ r.status }}</v-chip><span v-if="r.error" class="quiet text-caption"> · {{ r.error }}</span></td>
|
||||
<td>{{ r.entry.entry_date }}</td>
|
||||
<td>{{ r.entry.account }}</td>
|
||||
<td class="text-right">{{ r.entry.debit ? currency(r.entry.debit) : '' }}</td>
|
||||
<td class="text-right">{{ r.entry.credit ? currency(r.entry.credit) : '' }}</td>
|
||||
<td>{{ r.entry.reference }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="importDialog = false">Cancel</v-btn>
|
||||
<v-btn color="primary" prepend-icon="mdi-database-import" :loading="importing" @click="commitImport">Import</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Per-entry change history (from the audit log) -->
|
||||
<v-dialog v-model="historyDialog" max-width="640" scrollable>
|
||||
<v-card>
|
||||
<v-card-title>Change history</v-card-title>
|
||||
<v-card-text>
|
||||
<div v-if="historyLoading" class="quiet text-body-2">Loading…</div>
|
||||
<div v-else-if="!historyRows.length" class="quiet text-body-2">No recorded changes.</div>
|
||||
<div v-for="ev in historyRows" :key="ev.id" class="gl-history-item">
|
||||
<div class="toolbar-row mb-1" style="gap:8px">
|
||||
<v-chip size="x-small" :color="historyColor(ev.action)" variant="tonal">{{ historyLabel(ev.action) }}</v-chip>
|
||||
<span class="text-body-2 font-weight-medium">{{ ev.actor_name || 'System' }}</span>
|
||||
<v-spacer />
|
||||
<span class="quiet text-caption">{{ shortDate(ev.created_at) }}</span>
|
||||
</div>
|
||||
<table v-if="changedFields(ev.before, ev.after).length" class="gl-diff">
|
||||
<tr v-for="(c, i) in changedFields(ev.before, ev.after)" :key="i">
|
||||
<td class="quiet">{{ c.label }}</td>
|
||||
<td class="gl-from">{{ c.from }}</td>
|
||||
<td class="px-2">→</td>
|
||||
<td class="gl-to">{{ c.to }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div v-else class="quiet text-caption">No field changes.</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="historyDialog = false">Close</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="newBookDialog" max-width="560">
|
||||
<v-card>
|
||||
<v-card-title>New book</v-card-title>
|
||||
@@ -178,7 +331,7 @@
|
||||
<script>
|
||||
import DataTable from '../components/DataTable.vue';
|
||||
import { apiRequest, saveBlob } from '../services/api';
|
||||
import { currency } from '../utils/format';
|
||||
import { currency, shortDate } from '../utils/format';
|
||||
import { conventionItems } from '../constants';
|
||||
|
||||
export default {
|
||||
@@ -207,7 +360,22 @@ export default {
|
||||
newBookDialog: false,
|
||||
newBookError: '',
|
||||
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: {
|
||||
@@ -238,6 +406,18 @@ export default {
|
||||
{ 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() {
|
||||
@@ -324,6 +504,7 @@ export default {
|
||||
} catch (error) {
|
||||
this.error = error.message;
|
||||
}
|
||||
this.loadEntries();
|
||||
},
|
||||
async exportLedger(format) {
|
||||
const blob = await (await this.api(`/api/books/${this.ledgerBook}/ledger/export`, {
|
||||
@@ -331,6 +512,128 @@ export default {
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -354,4 +657,26 @@ export default {
|
||||
.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