Updated GL System , Updated user manual

This commit is contained in:
2026-06-08 10:55:42 -05:00
parent 63d767c6b0
commit 2a254489a3
6 changed files with 964 additions and 10 deletions

View File

@@ -416,6 +416,28 @@ function initialize() {
UNIQUE(entity_id, code) 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 ( CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL, type TEXT NOT NULL,

View File

@@ -1,9 +1,15 @@
const express = require('express'); const express = require('express');
const multer = require('multer');
const { requireCapability } = require('../middleware/auth'); const { requireCapability } = require('../middleware/auth');
const { bookLedger, createBook, deleteBook, ledgerReport, listBooks, updateBook } = require('../services/books'); const { bookLedger, createBook, deleteBook, ledgerReport, listBooks, updateBook } = require('../services/books');
const { exportReport } = require('../services/reportExport'); 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 router = express.Router();
const ledgerReader = requireCapability('finance.view');
const ledgerEditor = requireCapability('finance.manage');
router.get('/books', (req, res) => { router.get('/books', (req, res) => {
res.json(listBooks(req.companyId)); res.json(listBooks(req.companyId));
@@ -43,4 +49,73 @@ router.post('/books/:code/ledger/export', async (req, res) => {
return res.send(output.body); 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; module.exports = router;

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

View File

@@ -1784,6 +1784,61 @@ async function main() {
})).assignment; })).assignment;
if (crossEmpAssign.employee_id) throw new Error('A company A assignment accepted a company B employee'); 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'); console.log('Smoke test passed');
} }

View File

@@ -266,8 +266,34 @@
<p> <p>
Select a book to view its <strong>GL ledger</strong> in debit/credit format asset cost, accumulated depreciation, and the Select a book to view its <strong>GL ledger</strong> in debit/credit format asset cost, accumulated depreciation, and the
periods depreciation expense rolled to GL accounts, with per-asset detail and a summary. You can export the ledger. periods 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> </p>
<h3>GL journal entries add, edit, import &amp; 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&nbsp;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 entrys timeline who changed it, when, and a field-level beforeafter diff. The same
events are searchable system-wide in <strong>Admin Activity &amp; Audit Trail</strong>.</li>
</ul>
<h3>Depreciation methods &amp; critical fields</h3> <h3>Depreciation methods &amp; critical fields</h3>
<p> <p>
Each books depreciation is computed from the depreciation-critical fields: property type, placed-in-service date, Each books 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 assets PM tab and the Completed forms with signature, ratings, notes, and photos are viewable from both the assets PM tab and the
<strong>Completed PM forms</strong> list on the Maintenance screen. <strong>Completed PM forms</strong> list on the Maintenance screen.
</v-alert> </v-alert>
<h3>Dynamic scheduling usage meters &amp; the lifespan wear curve</h3>
<p>
Beyond a fixed calendar interval, a schedules 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 assets 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 assets 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 assets 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 isnt serviced for a while; admins can also run it on demand with <strong>Run
now</strong>.
</v-alert>
</section> </section>
<!-- PARTS --> <!-- 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>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>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>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 its shown automatically.</li>
<li>Keep a per-contact <strong>notes log</strong> for running history.</li> <li>Keep a per-contact <strong>notes log</strong> for running history.</li>
</ul> </ul>
<h3>How contacts connect to the rest of the app</h3> <h3>How contacts connect to the rest of the app</h3>
@@ -605,6 +662,7 @@
<ul> <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>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>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> <li><strong>ServiceNow incident</strong> raise a ticket from any alert (see below).</li>
</ul> </ul>
@@ -655,21 +713,65 @@
</v-alert> </v-alert>
</section> </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 &amp;
Teams</strong>, which groups your DepreCore users.
</v-alert>
</section>
<!-- ADMIN --> <!-- ADMIN -->
<section v-show="isShown('admin')" class="manual-section"> <section v-show="isShown('admin')" class="manual-section">
<h2>Administration</h2> <h2>Administration</h2>
<p> <p>
<strong>Admin</strong> (admin role) is where the application is configured. It is split into three sub-screens in the <strong>Admin</strong> (admin role) is where the application is configured. It is split into sub-screens in the
navigation rail: <strong>Users &amp; Teams</strong>, <strong>Application Configuration</strong> (application settings, asset classes, depreciation zones, Section 179 limits, asset ID templates, asset categories, navigation rail: <strong>Users &amp; Teams</strong>, <strong>Password &amp; Security</strong>,
asset departments, PM categories, parts categories, maintenance defaults, tax rule sets), and <strong>Integrations</strong> (email, webhook, ServiceNow, Workday). Each is described below. <strong>Activity &amp; 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> </p>
<h3>Users &amp; roles</h3> <h3>Users &amp; roles</h3>
<ul> <ul>
<li>Create and edit users name, email, team, role, status, and password.</li> <li>Create and edit users name, email, team, role, status, and password.</li>
<li>The list paginates and is filterable. A users role controls what they can see and do.</li> <li>The list paginates and is filterable. A users 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 &amp; security</a>.</li>
</ul> </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 &amp; security</h3>
<p>Password complexity/expiry/history rules and account lockout. Covered under
<a href="#" @click.prevent="select('security')">Password &amp; security</a>.</p>
<h3>Activity &amp; audit trail</h3>
<p>A searchable, exportable log of every change made in the system. Covered under
<a href="#" @click.prevent="select('security')">Password &amp; security</a>.</p>
<h3>Teams</h3> <h3>Teams</h3>
<p>Group users into teams. Select a team to edit or delete it (a team in use by members cant be deleted until theyre reassigned).</p> <p>Group users into teams. Select a team to edit or delete it (a team in use by members cant be deleted until theyre reassigned).</p>
@@ -747,11 +849,20 @@
payload is shown for whoever builds the receiving side. payload is shown for whoever builds the receiving side.
</p> </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> <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> <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> <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> <h3>Workday connector</h3>
<ul> <ul>
@@ -799,6 +910,83 @@
</v-alert> </v-alert>
</section> </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 its hidden from the switcher and cant be selected for new work. The last active company cant 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 users edit dialog see
<a href="#" @click.prevent="select('admin')">Administration</a>); the switcher only lists the companies they may use, and a
user cant reach another companys 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 &amp; security · audit trail</h2>
<h3>Password &amp; account security policy</h3>
<p>
Administrators set the rules for locally managed passwords in <strong>Admin Password &amp; 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 users name or email.</li>
<li><strong>Rotation &amp; 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 whats 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) theyre prompted at sign-in.
</p>
<h3>Activity &amp; Audit Trail</h3>
<p>
<strong>Admin Activity &amp; 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 entrys
<strong>history</strong> icon (see <a href="#" @click.prevent="select('books')">Books, depreciation &amp; 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 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>
</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: '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: '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: '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: '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: '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: '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: '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: '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: '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' } { id: 'roles', title: 'Roles & permissions', icon: 'mdi-shield-account-outline', keywords: 'roles permissions admin finance operations viewer access' }
] ]
}; };

View File

@@ -149,6 +149,159 @@
<div v-else class="quiet text-body-2">Select a book to view its general ledger.</div> <div v-else class="quiet text-body-2">Select a book to view its general ledger.</div>
</v-card> </v-card>
<!-- GL journal entries (stored, editable, fully audited) -->
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Journal entries</h2>
<div class="quiet text-body-2">
Manual &amp; imported GL postings for <strong>{{ ledgerBook }}</strong> ({{ ledgerYear }}). All changes are
recorded in Admin Activity &amp; Audit Trail.
</div>
</div>
<v-spacer />
<v-btn size="small" variant="tonal" prepend-icon="mdi-upload" @click="$refs.glFile.click()">Import</v-btn>
<input ref="glFile" hidden type="file" accept=".csv,.xlsx,.xls" @change="onImportFile" />
<v-menu>
<template #activator="{ props }">
<v-btn v-bind="props" size="small" variant="tonal" prepend-icon="mdi-download">Export</v-btn>
</template>
<v-list density="compact">
<v-list-item title="CSV" @click="exportEntries('csv')" />
<v-list-item title="Excel (XLSX)" @click="exportEntries('xlsx')" />
</v-list>
</v-menu>
<v-btn size="small" color="primary" prepend-icon="mdi-plus" @click="openEntry()">Add entry</v-btn>
</div>
<div class="ledger-summary mb-3">
<div class="ledger-chip"><span class="quiet">Entries</span><strong>{{ entrySummary.count }}</strong></div>
<div class="ledger-chip"><span class="quiet">Debit</span><strong>{{ currency(entrySummary.debit) }}</strong></div>
<div class="ledger-chip"><span class="quiet">Credit</span><strong>{{ currency(entrySummary.credit) }}</strong></div>
<div class="ledger-chip"><span class="quiet">Balance</span><strong>{{ currency(entrySummary.balance) }}</strong></div>
</div>
<DataTable
:columns="entryColumns"
:rows="entries"
row-key="id"
:page-size="15"
has-actions
searchable
search-placeholder="Filter entries"
empty-text="No journal entries for this book and year."
>
<template #cell-debit="{ row }">{{ row.debit ? currency(row.debit) : '' }}</template>
<template #cell-credit="{ row }">{{ row.credit ? currency(row.credit) : '' }}</template>
<template #cell-source="{ row }"><v-chip size="x-small" variant="tonal">{{ row.source }}</v-chip></template>
<template #actions="{ row }">
<v-btn icon="mdi-history" size="small" variant="text" title="Change history" @click="openHistory(row)" />
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEntry(row)" />
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="removeEntry(row)" />
</template>
</DataTable>
<v-alert v-if="entryError" type="error" density="compact" class="mt-2">{{ entryError }}</v-alert>
<v-alert v-if="message" type="success" density="compact" class="mt-2">{{ message }}</v-alert>
</v-card>
<!-- Add / edit GL entry -->
<v-dialog v-model="entryDialog" max-width="640">
<v-card>
<v-card-title>{{ entryForm.id ? 'Edit GL entry' : 'New GL entry' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<v-text-field v-model="entryForm.entry_date" type="date" label="Date *" />
<v-text-field v-model="entryForm.account" label="GL account *" />
<v-text-field v-model="entryForm.account_type" label="Account type" />
<v-text-field v-model="entryForm.reference" label="Reference" />
<v-text-field v-model.number="entryForm.debit" type="number" prefix="$" label="Debit" />
<v-text-field v-model.number="entryForm.credit" type="number" prefix="$" label="Credit" />
<v-text-field v-model="entryForm.external_id" class="full" label="External ID (used to match on re-import)" />
<v-textarea v-model="entryForm.description" class="full" label="Description" rows="2" />
</div>
<v-alert v-if="entryDialogError" type="error" density="compact" class="mt-2">{{ entryDialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="entryDialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="savingEntry" @click="saveEntry">Save entry</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Import preview / conflict resolution -->
<v-dialog v-model="importDialog" max-width="760" scrollable>
<v-card v-if="importPreview">
<v-card-title>Import preview {{ ledgerBook }}</v-card-title>
<v-card-text>
<div class="ledger-summary mb-3">
<div class="ledger-chip"><span class="quiet">New</span><strong>{{ importPreview.summary.new }}</strong></div>
<div class="ledger-chip"><span class="quiet">Update</span><strong>{{ importPreview.summary.update }}</strong></div>
<div class="ledger-chip"><span class="quiet">Duplicate</span><strong>{{ importPreview.summary.duplicate }}</strong></div>
<div class="ledger-chip"><span class="quiet">Errors</span><strong>{{ importPreview.summary.error }}</strong></div>
</div>
<div class="text-body-2 quiet mb-1">Conflict strategy</div>
<v-radio-group v-model="importStrategy" inline hide-details density="compact" class="mb-2">
<v-radio label="Skip conflicts" value="skip" />
<v-radio label="Merge (update existing)" value="merge" />
<v-radio label="Insert as new" value="insert" />
</v-radio-group>
<div style="overflow-x:auto; max-height:340px">
<table class="asset-table">
<thead><tr><th>Status</th><th>Date</th><th>Account</th><th class="text-right">Debit</th><th class="text-right">Credit</th><th>Reference</th></tr></thead>
<tbody>
<tr v-for="r in importPreview.rows" :key="r.index">
<td><v-chip size="x-small" :color="statusColor(r.status)" variant="tonal">{{ r.status }}</v-chip><span v-if="r.error" class="quiet text-caption"> · {{ r.error }}</span></td>
<td>{{ r.entry.entry_date }}</td>
<td>{{ r.entry.account }}</td>
<td class="text-right">{{ r.entry.debit ? currency(r.entry.debit) : '' }}</td>
<td class="text-right">{{ r.entry.credit ? currency(r.entry.credit) : '' }}</td>
<td>{{ r.entry.reference }}</td>
</tr>
</tbody>
</table>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="importDialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-database-import" :loading="importing" @click="commitImport">Import</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Per-entry change history (from the audit log) -->
<v-dialog v-model="historyDialog" max-width="640" scrollable>
<v-card>
<v-card-title>Change history</v-card-title>
<v-card-text>
<div v-if="historyLoading" class="quiet text-body-2">Loading</div>
<div v-else-if="!historyRows.length" class="quiet text-body-2">No recorded changes.</div>
<div v-for="ev in historyRows" :key="ev.id" class="gl-history-item">
<div class="toolbar-row mb-1" style="gap:8px">
<v-chip size="x-small" :color="historyColor(ev.action)" variant="tonal">{{ historyLabel(ev.action) }}</v-chip>
<span class="text-body-2 font-weight-medium">{{ ev.actor_name || 'System' }}</span>
<v-spacer />
<span class="quiet text-caption">{{ shortDate(ev.created_at) }}</span>
</div>
<table v-if="changedFields(ev.before, ev.after).length" class="gl-diff">
<tr v-for="(c, i) in changedFields(ev.before, ev.after)" :key="i">
<td class="quiet">{{ c.label }}</td>
<td class="gl-from">{{ c.from }}</td>
<td class="px-2"></td>
<td class="gl-to">{{ c.to }}</td>
</tr>
</table>
<div v-else class="quiet text-caption">No field changes.</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="historyDialog = false">Close</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="newBookDialog" max-width="560"> <v-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>
@@ -178,7 +331,7 @@
<script> <script>
import DataTable from '../components/DataTable.vue'; import DataTable from '../components/DataTable.vue';
import { apiRequest, saveBlob } from '../services/api'; import { apiRequest, saveBlob } from '../services/api';
import { currency } from '../utils/format'; import { currency, shortDate } from '../utils/format';
import { conventionItems } from '../constants'; import { conventionItems } from '../constants';
export default { export default {
@@ -207,7 +360,22 @@ export default {
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: {
@@ -238,6 +406,18 @@ export default {
{ key: 'accumulated', label: 'Accum.', format: 'currency' }, { key: 'accumulated', label: 'Accum.', format: 'currency' },
{ key: 'nbv', label: 'NBV', 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() {
@@ -324,6 +504,7 @@ export default {
} catch (error) { } catch (error) {
this.error = error.message; this.error = error.message;
} }
this.loadEntries();
}, },
async exportLedger(format) { async exportLedger(format) {
const blob = await (await this.api(`/api/books/${this.ledgerBook}/ledger/export`, { 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 }) body: JSON.stringify({ year: this.ledgerYear, format })
})).blob(); })).blob();
saveBlob(blob, `deprecore-${this.ledgerBook}-ledger-${this.ledgerYear}.${format}`); 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 { .ledger-chip strong {
font-size: 1.05rem; 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> </style>