diff --git a/server/db.js b/server/db.js index d41d4ad..e5f7448 100644 --- a/server/db.js +++ b/server/db.js @@ -416,6 +416,28 @@ function initialize() { UNIQUE(entity_id, code) ); + CREATE TABLE IF NOT EXISTS gl_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_id INTEGER REFERENCES entities(id), + book_code TEXT NOT NULL, + entry_date TEXT NOT NULL, + fiscal_year INTEGER, + account TEXT NOT NULL, + account_type TEXT, + description TEXT, + reference TEXT, + debit REAL NOT NULL DEFAULT 0, + credit REAL NOT NULL DEFAULT 0, + asset_id INTEGER REFERENCES assets(id) ON DELETE SET NULL, + external_id TEXT, + source TEXT NOT NULL DEFAULT 'manual', + created_by INTEGER REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_gl_entries_scope ON gl_entries (entity_id, book_code, fiscal_year); + CREATE INDEX IF NOT EXISTS idx_gl_entries_extid ON gl_entries (entity_id, book_code, external_id); + CREATE TABLE IF NOT EXISTS alerts ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, diff --git a/server/routes/books.js b/server/routes/books.js index 7fdd5fe..d736e44 100644 --- a/server/routes/books.js +++ b/server/routes/books.js @@ -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; diff --git a/server/services/glEntries.js b/server/services/glEntries.js new file mode 100644 index 0000000..3aa6c1e --- /dev/null +++ b/server/services/glEntries.js @@ -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 +}; diff --git a/server/smoke-test.js b/server/smoke-test.js index b41fbd4..642b378 100644 --- a/server/smoke-test.js +++ b/server/smoke-test.js @@ -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'); } diff --git a/src/components/UserManual.vue b/src/components/UserManual.vue index 0d279f2..4033527 100644 --- a/src/components/UserManual.vue +++ b/src/components/UserManual.vue @@ -266,8 +266,34 @@

Select a book to view its GL ledger in debit/credit format — asset cost, accumulated depreciation, and the period’s depreciation expense rolled to GL accounts, with per-asset detail and a summary. You can export the ledger. + This rollup is computed automatically from depreciation; the Journal entries panel below it is a separate, + editable layer (the two do not double-count).

+

GL journal entries — add, edit, import & track changes

+

+ Below the computed ledger, the Journal entries 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. +

+ +

Depreciation methods & critical fields

Each book’s depreciation is computed from the depreciation-critical fields: property type, placed-in-service date, @@ -523,6 +549,36 @@ Completed forms — with signature, ratings, notes, and photos — are viewable from both the asset’s PM tab and the Completed PM forms list on the Maintenance screen. + +

Dynamic scheduling — usage meters & the lifespan wear curve

+

+ Beyond a fixed calendar interval, a schedule’s next-due date can adjust to real-world use. When usage and + age signals are enabled, the next service is due at the earliest of three triggers — the + calendar interval, a projected usage threshold, or an age-compressed date — and the asset’s PM list shows a + small badge (📅 date, ⏱ usage, or 📉 age) indicating which one is driving the date. +

+ + + Both signals are opt-in: an administrator enables “Usage-based scheduling” and the “Lifespan + wear curve” (with its end-of-life factor and aggressiveness) under Admin → Application Configuration → + Preventative maintenance defaults. A nightly job re-projects due-dates so usage/age schedules stay + current even when an asset isn’t serviced for a while; admins can also run it on demand with Run + now. + @@ -561,6 +617,7 @@
  • Use the type selector and filter box to find records; create a contact with New contact.
  • Capture name/organization, international-friendly address and phone, and email.
  • Type-specific fields: employee ID/department/manager/start date; contractor tax ID or business license; vendor and service-provider star rating and credit term.
  • +
  • Location map — once an address is entered, the contact card can show an embedded Google map of the location, with an Open in Google Maps link. For a new contact the map appears on a Show map click; when you open an existing contact with an address it’s shown automatically.
  • Keep a per-contact notes log for running history.
  • How contacts connect to the rest of the app

    @@ -605,6 +662,7 @@ @@ -655,21 +713,65 @@ + +
    +

    Microsoft Teams integration

    +

    + DepreCore can post newly raised alerts to a Microsoft Teams channel or chat. One digest card is sent per + alert cycle, alongside (or instead of) email, webhook, and ServiceNow delivery. Configure it in + Administration → Integrations → Microsoft Teams. +

    +

    Connecting

    +
      +
    1. In Teams, create an incoming webhook for the target channel — a Workflows “post to a channel when a + webhook request is received” flow (recommended), or a legacy Incoming Webhook connector.
    2. +
    3. Paste the webhook URL into DepreCore and pick the matching card format: Adaptive Card (Workflows) + for the modern flow, or MessageCard (legacy connector).
    4. +
    5. Choose a minimum severity to post (e.g. Warning and above) so the channel only gets what matters, turn on + Enable Teams delivery, and click Send test card to confirm it lands in the channel.
    6. +
    + + Cards are colour-coded by severity and summarize the pending alerts (title, detail, due date, asset). Each alert is delivered + once across all channels. Note: this “Teams” (Microsoft Teams chat) is unrelated to Admin → Users & + Teams, which groups your DepreCore users. + +
    +

    Administration

    - Admin (admin role) is where the application is configured. It is split into three sub-screens in the - navigation rail: Users & Teams, Application Configuration (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 Integrations (email, webhook, ServiceNow, Workday). Each is described below. + Admin (admin role) is where the application is configured. It is split into sub-screens in the + navigation rail: Users & Teams, Password & Security, + Activity & Audit Trail, Application Configuration (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 + Integrations (email, webhook, Microsoft Teams, ServiceNow, Workday). Each is described below.

    Users & roles

    +

    Companies

    +

    Add, edit, and dispose/restore companies, each with its own books, GL, and data. Covered under + Companies.

    + +

    Password & security

    +

    Password complexity/expiry/history rules and account lockout. Covered under + Password & security.

    + +

    Activity & audit trail

    +

    A searchable, exportable log of every change made in the system. Covered under + Password & security.

    +

    Teams

    Group users into teams. Select a team to edit or delete it (a team in use by members can’t be deleted until they’re reassigned).

    @@ -747,11 +849,20 @@ payload is shown for whoever builds the receiving side.

    +

    Microsoft Teams

    +

    Post alert digests to a Teams channel — webhook URL, card format, and minimum severity. Covered under Microsoft Teams integration.

    +

    ServiceNow integration

    Connection, incident push, and CMDB pull settings — covered in detail under ServiceNow integration.

    Maintenance (PM) defaults

    -

    Set the default maintenance frequency for newly attached plans and how many days ahead PM-due alerts should appear.

    +

    + 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 dynamic scheduling: usage-based scheduling (meters) and the + lifespan wear curve (with its end-of-life interval factor and curve aggressiveness), plus the + nightly recompute job (on/off, hour of day, and a Run now button). See + Preventative maintenance for how these signals drive due-dates. +

    Workday connector

    + +
    +

    Companies (multi-company)

    +

    + DepreCore can manage multiple companies, 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 active company at a time. +

    +

    Switching companies

    +

    + When you have access to more than one company, a company switcher 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. +

    +

    Managing companies

    +

    Administrators manage companies in Admin → Application Configuration → Companies:

    + +

    Who can see which company

    +

    + Each user is granted access to one or more companies (set in the user’s edit dialog — see + Administration); the switcher only lists the companies they may use, and a + user can’t reach another company’s data. Administrators see every company. +

    + + A few things are deliberately shared across all companies: 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. + +
    + + +
    +

    Password & security · audit trail

    + +

    Password & account security policy

    +

    + Administrators set the rules for locally managed passwords in Admin → Password & Security. They are + enforced whenever a password is set or changed and at sign-in: +

    + +

    + The live rule preview shows users exactly what’s required. Users change their own password from the account menu + (Change password); when a change is required (expired, or set by an admin) they’re prompted at sign-in. +

    + +

    Activity & Audit Trail

    +

    + Admin → Activity & Audit Trail 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. +

    + + + Because every change is captured here with full before/after detail, the audit trail is the system-of-record for change + tracking — for example, each GL journal-entry edit is logged here and also surfaced inline via the entry’s + history icon (see Books, depreciation & tax). + +
    + @@ -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' } ] }; diff --git a/src/views/BooksView.vue b/src/views/BooksView.vue index fbfcd7c..fe1dd14 100644 --- a/src/views/BooksView.vue +++ b/src/views/BooksView.vue @@ -149,6 +149,159 @@
    Select a book to view its general ledger.
    + + +
    +
    +

    Journal entries

    +
    + Manual & imported GL postings for {{ ledgerBook }} ({{ ledgerYear }}). All changes are + recorded in Admin → Activity & Audit Trail. +
    +
    + + Import + + + + + + + + + Add entry +
    + +
    +
    Entries{{ entrySummary.count }}
    +
    Debit{{ currency(entrySummary.debit) }}
    +
    Credit{{ currency(entrySummary.credit) }}
    +
    Balance{{ currency(entrySummary.balance) }}
    +
    + + + + + + + + {{ entryError }} + {{ message }} +
    + + + + + {{ entryForm.id ? 'Edit GL entry' : 'New GL entry' }} + +
    + + + + + + + + +
    + {{ entryDialogError }} +
    + + + Cancel + Save entry + +
    +
    + + + + + Import preview — {{ ledgerBook }} + +
    +
    New{{ importPreview.summary.new }}
    +
    Update{{ importPreview.summary.update }}
    +
    Duplicate{{ importPreview.summary.duplicate }}
    +
    Errors{{ importPreview.summary.error }}
    +
    +
    Conflict strategy
    + + + + + +
    + + + + + + + + + + + + +
    StatusDateAccountDebitCreditReference
    {{ r.status }} · {{ r.error }}{{ r.entry.entry_date }}{{ r.entry.account }}{{ r.entry.debit ? currency(r.entry.debit) : '' }}{{ r.entry.credit ? currency(r.entry.credit) : '' }}{{ r.entry.reference }}
    +
    +
    + + + Cancel + Import + +
    +
    + + + + + Change history + +
    Loading…
    +
    No recorded changes.
    +
    +
    + {{ historyLabel(ev.action) }} + {{ ev.actor_name || 'System' }} + + {{ shortDate(ev.created_at) }} +
    + + + + + + + +
    {{ c.label }}{{ c.from }}{{ c.to }}
    +
    No field changes.
    +
    +
    + + + Close + +
    +
    + New book @@ -178,7 +331,7 @@