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)
);
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,

View File

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

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;
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');
}

View File

@@ -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
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>
<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>
<p>
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
<strong>Completed PM forms</strong> list on the Maintenance screen.
</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>
<!-- 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 its 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 &amp;
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 &amp; 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 &amp; Teams</strong>, <strong>Password &amp; Security</strong>,
<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>
<h3>Users &amp; roles</h3>
<ul>
<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><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>
<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>
<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.
</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 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>
</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' }
]
};

View File

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