Added Multi-Company Support
This commit is contained in:
@@ -7,6 +7,7 @@ const path = require('path');
|
|||||||
const { DB_PATH, initialize } = require('./db');
|
const { DB_PATH, initialize } = require('./db');
|
||||||
const { createCorsMiddleware } = require('./middleware/cors');
|
const { createCorsMiddleware } = require('./middleware/cors');
|
||||||
const { requireAuth } = require('./middleware/auth');
|
const { requireAuth } = require('./middleware/auth');
|
||||||
|
const { resolveCompany } = require('./middleware/company');
|
||||||
|
|
||||||
const adminRoutes = require('./routes/admin');
|
const adminRoutes = require('./routes/admin');
|
||||||
const alertRoutes = require('./routes/alerts');
|
const alertRoutes = require('./routes/alerts');
|
||||||
@@ -46,6 +47,7 @@ function createApp() {
|
|||||||
|
|
||||||
app.use('/api', authRoutes);
|
app.use('/api', authRoutes);
|
||||||
app.use('/api', requireAuth);
|
app.use('/api', requireAuth);
|
||||||
|
app.use('/api', resolveCompany);
|
||||||
app.use('/api', profileRoutes);
|
app.use('/api', profileRoutes);
|
||||||
app.use('/api', dashboardRoutes);
|
app.use('/api', dashboardRoutes);
|
||||||
app.use('/api', referenceRoutes);
|
app.use('/api', referenceRoutes);
|
||||||
|
|||||||
295
server/db.js
295
server/db.js
@@ -108,6 +108,12 @@ function initialize() {
|
|||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_companies (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
entity_id INTEGER NOT NULL REFERENCES entities(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, entity_id)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS entities (
|
CREATE TABLE IF NOT EXISTS entities (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -115,17 +121,21 @@ function initialize() {
|
|||||||
tax_id TEXT,
|
tax_id TEXT,
|
||||||
fiscal_year_end_month INTEGER NOT NULL DEFAULT 12,
|
fiscal_year_end_month INTEGER NOT NULL DEFAULT 12,
|
||||||
base_currency TEXT NOT NULL DEFAULT 'USD',
|
base_currency TEXT NOT NULL DEFAULT 'USD',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
disposed_at TEXT,
|
||||||
|
disposed_reason TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS reference_values (
|
CREATE TABLE IF NOT EXISTS reference_values (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_id INTEGER REFERENCES entities(id),
|
||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
code TEXT,
|
code TEXT,
|
||||||
metadata TEXT,
|
metadata TEXT,
|
||||||
UNIQUE(type, name)
|
UNIQUE(entity_id, type, name)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS employees (
|
CREATE TABLE IF NOT EXISTS employees (
|
||||||
@@ -207,12 +217,14 @@ function initialize() {
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS asset_templates (
|
CREATE TABLE IF NOT EXISTS asset_templates (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
entity_id INTEGER REFERENCES entities(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
defaults TEXT NOT NULL,
|
defaults TEXT NOT NULL,
|
||||||
custom_fields TEXT NOT NULL,
|
custom_fields TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(entity_id, name)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS tax_rule_sets (
|
CREATE TABLE IF NOT EXISTS tax_rule_sets (
|
||||||
@@ -328,6 +340,7 @@ function initialize() {
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS saved_reports (
|
CREATE TABLE IF NOT EXISTS saved_reports (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_id INTEGER REFERENCES entities(id),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
report_type TEXT NOT NULL,
|
report_type TEXT NOT NULL,
|
||||||
options_json TEXT NOT NULL,
|
options_json TEXT NOT NULL,
|
||||||
@@ -382,7 +395,8 @@ function initialize() {
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS books (
|
CREATE TABLE IF NOT EXISTS books (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
code TEXT NOT NULL UNIQUE,
|
entity_id INTEGER REFERENCES entities(id),
|
||||||
|
code TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
book_type TEXT NOT NULL DEFAULT 'tax',
|
book_type TEXT NOT NULL DEFAULT 'tax',
|
||||||
@@ -394,7 +408,8 @@ function initialize() {
|
|||||||
fiscal_year_end_month INTEGER NOT NULL DEFAULT 12,
|
fiscal_year_end_month INTEGER NOT NULL DEFAULT 12,
|
||||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(entity_id, code)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS alerts (
|
CREATE TABLE IF NOT EXISTS alerts (
|
||||||
@@ -528,6 +543,7 @@ function initialize() {
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS contacts (
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_id INTEGER REFERENCES entities(id),
|
||||||
type TEXT NOT NULL DEFAULT 'other',
|
type TEXT NOT NULL DEFAULT 'other',
|
||||||
first_name TEXT,
|
first_name TEXT,
|
||||||
last_name TEXT,
|
last_name TEXT,
|
||||||
@@ -571,7 +587,8 @@ function initialize() {
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS parts (
|
CREATE TABLE IF NOT EXISTS parts (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
part_number TEXT NOT NULL UNIQUE,
|
entity_id INTEGER REFERENCES entities(id),
|
||||||
|
part_number TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
category TEXT,
|
category TEXT,
|
||||||
@@ -588,7 +605,8 @@ function initialize() {
|
|||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_by INTEGER REFERENCES users(id),
|
created_by INTEGER REFERENCES users(id),
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(entity_id, part_number)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS part_stock (
|
CREATE TABLE IF NOT EXISTS part_stock (
|
||||||
@@ -759,6 +777,154 @@ function migrate() {
|
|||||||
ensureColumn('users', 'last_login_at', 'TEXT');
|
ensureColumn('users', 'last_login_at', 'TEXT');
|
||||||
ensureColumn('users', 'must_change_password', 'INTEGER NOT NULL DEFAULT 0');
|
ensureColumn('users', 'must_change_password', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
dropUsersRoleCheck();
|
dropUsersRoleCheck();
|
||||||
|
// Multi-company scoping: company lifecycle + per-company foreign keys.
|
||||||
|
ensureColumn('entities', 'status', "TEXT NOT NULL DEFAULT 'active'");
|
||||||
|
ensureColumn('entities', 'disposed_at', 'TEXT');
|
||||||
|
ensureColumn('entities', 'disposed_reason', 'TEXT');
|
||||||
|
ensureColumn('pm_plans', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
||||||
|
ensureColumn('alerts', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
||||||
|
rebuildBooksPerCompany();
|
||||||
|
// Phase 2: scope reference data, contacts, templates, saved reports per company.
|
||||||
|
ensureColumn('contacts', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
||||||
|
ensureColumn('saved_reports', 'entity_id', 'INTEGER REFERENCES entities(id)');
|
||||||
|
rebuildReferenceValuesPerCompany();
|
||||||
|
rebuildAssetTemplatesPerCompany();
|
||||||
|
rebuildPartsPerCompany();
|
||||||
|
backfillCompanyScope();
|
||||||
|
}
|
||||||
|
|
||||||
|
// parts became per-company: UNIQUE(part_number) → UNIQUE(entity_id, part_number).
|
||||||
|
function rebuildPartsPerCompany() {
|
||||||
|
const ddl = one("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'parts'");
|
||||||
|
if (!ddl || /entity_id/i.test(ddl.sql)) return; // already scoped
|
||||||
|
db.exec('PRAGMA foreign_keys=OFF');
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE parts_rebuild (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_id INTEGER REFERENCES entities(id),
|
||||||
|
part_number TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT,
|
||||||
|
manufacturer TEXT,
|
||||||
|
manufacturer_part_number TEXT,
|
||||||
|
barcode TEXT,
|
||||||
|
unit_of_measure TEXT NOT NULL DEFAULT 'each',
|
||||||
|
unit_cost REAL NOT NULL DEFAULT 0,
|
||||||
|
reorder_point REAL NOT NULL DEFAULT 0,
|
||||||
|
reorder_quantity REAL NOT NULL DEFAULT 0,
|
||||||
|
measurements TEXT,
|
||||||
|
supplier_contact_id INTEGER REFERENCES contacts(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
notes TEXT,
|
||||||
|
created_by INTEGER REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(entity_id, part_number)
|
||||||
|
);
|
||||||
|
INSERT INTO parts_rebuild (id, part_number, name, description, category, manufacturer, manufacturer_part_number, barcode, unit_of_measure, unit_cost, reorder_point, reorder_quantity, measurements, supplier_contact_id, status, notes, created_by, created_at, updated_at)
|
||||||
|
SELECT id, part_number, name, description, category, manufacturer, manufacturer_part_number, barcode, unit_of_measure, unit_cost, reorder_point, reorder_quantity, measurements, supplier_contact_id, status, notes, created_by, created_at, updated_at FROM parts;
|
||||||
|
DROP TABLE parts;
|
||||||
|
ALTER TABLE parts_rebuild RENAME TO parts;
|
||||||
|
`);
|
||||||
|
db.exec('PRAGMA foreign_keys=ON');
|
||||||
|
}
|
||||||
|
|
||||||
|
// reference_values became per-company: UNIQUE(type, name) → UNIQUE(entity_id, type, name).
|
||||||
|
function rebuildReferenceValuesPerCompany() {
|
||||||
|
const ddl = one("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'reference_values'");
|
||||||
|
if (!ddl || /entity_id/i.test(ddl.sql)) return; // already scoped
|
||||||
|
db.exec('PRAGMA foreign_keys=OFF');
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE reference_values_rebuild (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_id INTEGER REFERENCES entities(id),
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
code TEXT,
|
||||||
|
metadata TEXT,
|
||||||
|
UNIQUE(entity_id, type, name)
|
||||||
|
);
|
||||||
|
INSERT INTO reference_values_rebuild (id, type, name, code, metadata)
|
||||||
|
SELECT id, type, name, code, metadata FROM reference_values;
|
||||||
|
DROP TABLE reference_values;
|
||||||
|
ALTER TABLE reference_values_rebuild RENAME TO reference_values;
|
||||||
|
`);
|
||||||
|
db.exec('PRAGMA foreign_keys=ON');
|
||||||
|
}
|
||||||
|
|
||||||
|
// asset_templates became per-company: UNIQUE(name) → UNIQUE(entity_id, name).
|
||||||
|
function rebuildAssetTemplatesPerCompany() {
|
||||||
|
const ddl = one("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'asset_templates'");
|
||||||
|
if (!ddl || /entity_id/i.test(ddl.sql)) return; // already scoped
|
||||||
|
db.exec('PRAGMA foreign_keys=OFF');
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE asset_templates_rebuild (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_id INTEGER REFERENCES entities(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
defaults TEXT NOT NULL,
|
||||||
|
custom_fields TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(entity_id, name)
|
||||||
|
);
|
||||||
|
INSERT INTO asset_templates_rebuild (id, name, description, defaults, custom_fields, created_at, updated_at)
|
||||||
|
SELECT id, name, description, defaults, custom_fields, created_at, updated_at FROM asset_templates;
|
||||||
|
DROP TABLE asset_templates;
|
||||||
|
ALTER TABLE asset_templates_rebuild RENAME TO asset_templates;
|
||||||
|
`);
|
||||||
|
db.exec('PRAGMA foreign_keys=ON');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Books became per-company: the old global UNIQUE(code) must become UNIQUE(entity_id, code).
|
||||||
|
// SQLite can't alter a constraint in place, so rebuild when the legacy column-level UNIQUE is present.
|
||||||
|
function rebuildBooksPerCompany() {
|
||||||
|
const ddl = one("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'books'");
|
||||||
|
if (!ddl || !/code\s+TEXT\s+NOT\s+NULL\s+UNIQUE/i.test(ddl.sql)) return; // already composite
|
||||||
|
db.exec('PRAGMA foreign_keys=OFF');
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE books_rebuild (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
entity_id INTEGER REFERENCES entities(id),
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
book_type TEXT NOT NULL DEFAULT 'tax',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tax_rule_set_id INTEGER REFERENCES tax_rule_sets(id),
|
||||||
|
default_method TEXT,
|
||||||
|
default_convention TEXT,
|
||||||
|
fiscal_year_end_month INTEGER NOT NULL DEFAULT 12,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(entity_id, code)
|
||||||
|
);
|
||||||
|
INSERT INTO books_rebuild (id, code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, fiscal_year_end_month, sort_order, created_at, updated_at)
|
||||||
|
SELECT id, code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, fiscal_year_end_month, sort_order, created_at, updated_at FROM books;
|
||||||
|
DROP TABLE books;
|
||||||
|
ALTER TABLE books_rebuild RENAME TO books;
|
||||||
|
`);
|
||||||
|
db.exec('PRAGMA foreign_keys=ON');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign pre-multi-company rows to the default (first active) company. No-op on a fresh DB.
|
||||||
|
function backfillCompanyScope() {
|
||||||
|
const def = one("SELECT id FROM entities WHERE status = 'active' ORDER BY id LIMIT 1") || one('SELECT id FROM entities ORDER BY id LIMIT 1');
|
||||||
|
if (!def) return;
|
||||||
|
// OR IGNORE so a pre-existing duplicate (e.g. two templates with the same name) can't break the
|
||||||
|
// new per-company UNIQUE constraints — the duplicate is left unscoped rather than crashing init.
|
||||||
|
for (const table of ['assets', 'books', 'pm_plans', 'alerts', 'reference_values', 'contacts', 'asset_templates', 'saved_reports', 'parts']) {
|
||||||
|
run(`UPDATE OR IGNORE ${table} SET entity_id = ? WHERE entity_id IS NULL`, [def.id]);
|
||||||
|
}
|
||||||
|
// Preserve "everyone sees all" on upgrade: grant each existing user every existing company,
|
||||||
|
// but only when the mapping is empty (first run) so deliberate later restrictions aren't undone.
|
||||||
|
if (!one('SELECT 1 AS x FROM user_companies LIMIT 1')) {
|
||||||
|
run('INSERT OR IGNORE INTO user_companies (user_id, entity_id) SELECT u.id, e.id FROM users u CROSS JOIN entities e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom roles require the legacy CHECK(role IN (...)) constraint on `users` to be removed.
|
// Custom roles require the legacy CHECK(role IN (...)) constraint on `users` to be removed.
|
||||||
@@ -787,6 +953,62 @@ function dropUsersRoleCheck() {
|
|||||||
db.exec('PRAGMA foreign_keys=ON');
|
db.exec('PRAGMA foreign_keys=ON');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed the standard book set for a company (idempotent per company). Called during initial seed and
|
||||||
|
// whenever a new company is created so it starts with GAAP/FEDERAL/STATE/AMT/ACE/USER + the PM book.
|
||||||
|
function seedBooksForCompany(entityId) {
|
||||||
|
if (!entityId || one('SELECT id FROM books WHERE entity_id = ? LIMIT 1', [entityId])) return;
|
||||||
|
const createdAt = now();
|
||||||
|
const federalRule = one("SELECT id FROM tax_rule_sets WHERE active = 1 ORDER BY effective_date DESC, id DESC LIMIT 1");
|
||||||
|
const ruleId = federalRule ? federalRule.id : null;
|
||||||
|
const bookDefs = [
|
||||||
|
['GAAP', 'GAAP / Financial', 'Primary book for financial statements', 'financial', 1, 1, 'straight_line', 'half_year'],
|
||||||
|
['FEDERAL', 'Federal Tax', 'US federal income tax depreciation', 'tax', 1, 0, 'macrs_gds_200db_5', 'half_year'],
|
||||||
|
['STATE', 'State Tax', 'State income tax depreciation', 'tax', 1, 0, 'macrs_gds_200db_5', 'half_year'],
|
||||||
|
['AMT', 'Alternative Minimum Tax', 'AMT depreciation adjustments', 'tax', 1, 0, 'macrs_gds_150db_5', 'half_year'],
|
||||||
|
['ACE', 'Adjusted Current Earnings', 'ACE depreciation', 'tax', 1, 0, 'macrs_ads_sl_5', 'half_year'],
|
||||||
|
['USER', 'User-defined', 'Custom internal reporting book', 'internal', 0, 0, 'straight_line', 'half_year']
|
||||||
|
];
|
||||||
|
bookDefs.forEach((def, index) => {
|
||||||
|
run(
|
||||||
|
`INSERT INTO books (entity_id, code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, sort_order, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[entityId, def[0], def[1], def[2], def[3], def[4], def[5], ruleId, def[6], def[7], index, createdAt, createdAt]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// The PM book tracks actual preventative-maintenance spend per asset (not depreciation).
|
||||||
|
run(
|
||||||
|
`INSERT OR IGNORE INTO books (entity_id, code, name, description, book_type, enabled, is_primary, sort_order, created_at, updated_at)
|
||||||
|
VALUES (?, 'PM', 'PM / Maintenance', 'Actual preventative-maintenance spend per asset', 'maintenance', 1, 0, 6, ?, ?)`,
|
||||||
|
[entityId, createdAt, createdAt]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the standard reference data (categories, locations, departments, part categories) for a company.
|
||||||
|
// Idempotent per company; called during initial seed and when a new company is created.
|
||||||
|
function seedReferenceValuesForCompany(entityId) {
|
||||||
|
if (!entityId) return;
|
||||||
|
const refs = [
|
||||||
|
['location', 'Headquarters', 'HQ'],
|
||||||
|
['location', 'Warehouse', 'WH'],
|
||||||
|
['department', 'Accounting', 'ACCT'],
|
||||||
|
['department', 'Operations', 'OPS'],
|
||||||
|
['category', 'Computer Equipment', 'COMP'],
|
||||||
|
['category', 'Office Furniture', 'FURN'],
|
||||||
|
['category', 'Vehicles', 'AUTO'],
|
||||||
|
['category', 'Leasehold Improvements', 'LHI'],
|
||||||
|
['part_category', 'Electrical', null],
|
||||||
|
['part_category', 'Plumbing', null],
|
||||||
|
['part_category', 'Structural Component', null],
|
||||||
|
['part_category', 'Electronic Component', null],
|
||||||
|
['part_category', 'Fire Safety', null],
|
||||||
|
['part_category', 'Mechanical Safety', null],
|
||||||
|
['part_category', 'Other', null]
|
||||||
|
];
|
||||||
|
for (const item of refs) {
|
||||||
|
run('INSERT OR IGNORE INTO reference_values (entity_id, type, name, code, metadata) VALUES (?, ?, ?, ?, ?)', [entityId, item[0], item[1], item[2], '{}']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function seed() {
|
function seed() {
|
||||||
const createdAt = now();
|
const createdAt = now();
|
||||||
|
|
||||||
@@ -915,31 +1137,8 @@ function seed() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const refs = [
|
const refEntity = one('SELECT id FROM entities ORDER BY id LIMIT 1');
|
||||||
['location', 'Headquarters', 'HQ'],
|
if (refEntity) seedReferenceValuesForCompany(refEntity.id);
|
||||||
['location', 'Warehouse', 'WH'],
|
|
||||||
['department', 'Accounting', 'ACCT'],
|
|
||||||
['department', 'Operations', 'OPS'],
|
|
||||||
['category', 'Computer Equipment', 'COMP'],
|
|
||||||
['category', 'Office Furniture', 'FURN'],
|
|
||||||
['category', 'Vehicles', 'AUTO'],
|
|
||||||
['category', 'Leasehold Improvements', 'LHI'],
|
|
||||||
['part_category', 'Electrical', null],
|
|
||||||
['part_category', 'Plumbing', null],
|
|
||||||
['part_category', 'Structural Component', null],
|
|
||||||
['part_category', 'Electronic Component', null],
|
|
||||||
['part_category', 'Fire Safety', null],
|
|
||||||
['part_category', 'Mechanical Safety', null],
|
|
||||||
['part_category', 'Other', null]
|
|
||||||
];
|
|
||||||
for (const item of refs) {
|
|
||||||
run('INSERT OR IGNORE INTO reference_values (type, name, code, metadata) VALUES (?, ?, ?, ?)', [
|
|
||||||
item[0],
|
|
||||||
item[1],
|
|
||||||
item[2],
|
|
||||||
'{}'
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
run(
|
run(
|
||||||
`INSERT OR IGNORE INTO employees (
|
`INSERT OR IGNORE INTO employees (
|
||||||
@@ -1005,32 +1204,10 @@ function seed() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!one('SELECT id FROM books LIMIT 1')) {
|
// Seed the standard book set for the default company. Each company gets its own set; see
|
||||||
const federalRule = one("SELECT id FROM tax_rule_sets WHERE active = 1 ORDER BY effective_date DESC, id DESC LIMIT 1");
|
// seedBooksForCompany (also called when a new company is created).
|
||||||
const ruleId = federalRule ? federalRule.id : null;
|
const defaultEntity = one('SELECT id FROM entities ORDER BY id LIMIT 1');
|
||||||
const bookDefs = [
|
if (defaultEntity) seedBooksForCompany(defaultEntity.id);
|
||||||
['GAAP', 'GAAP / Financial', 'Primary book for financial statements', 'financial', 1, 1, 'straight_line', 'half_year'],
|
|
||||||
['FEDERAL', 'Federal Tax', 'US federal income tax depreciation', 'tax', 1, 0, 'macrs_gds_200db_5', 'half_year'],
|
|
||||||
['STATE', 'State Tax', 'State income tax depreciation', 'tax', 1, 0, 'macrs_gds_200db_5', 'half_year'],
|
|
||||||
['AMT', 'Alternative Minimum Tax', 'AMT depreciation adjustments', 'tax', 1, 0, 'macrs_gds_150db_5', 'half_year'],
|
|
||||||
['ACE', 'Adjusted Current Earnings', 'ACE depreciation', 'tax', 1, 0, 'macrs_ads_sl_5', 'half_year'],
|
|
||||||
['USER', 'User-defined', 'Custom internal reporting book', 'internal', 0, 0, 'straight_line', 'half_year']
|
|
||||||
];
|
|
||||||
bookDefs.forEach((def, index) => {
|
|
||||||
run(
|
|
||||||
`INSERT INTO books (code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, sort_order, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[def[0], def[1], def[2], def[3], def[4], def[5], ruleId, def[6], def[7], index, createdAt, createdAt]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// The PM book tracks actual preventative-maintenance spend per asset (not depreciation).
|
|
||||||
run(
|
|
||||||
`INSERT OR IGNORE INTO books (code, name, description, book_type, enabled, is_primary, sort_order, created_at, updated_at)
|
|
||||||
VALUES ('PM', 'PM / Maintenance', 'Actual preventative-maintenance spend per asset', 'maintenance', 1, 0, 6, ?, ?)`,
|
|
||||||
[createdAt, createdAt]
|
|
||||||
);
|
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
server_fqdn: process.env.DEPRECORE_SERVER_FQDN || 'localhost',
|
server_fqdn: process.env.DEPRECORE_SERVER_FQDN || 'localhost',
|
||||||
@@ -1091,5 +1268,7 @@ module.exports = {
|
|||||||
one,
|
one,
|
||||||
parseJson,
|
parseJson,
|
||||||
run,
|
run,
|
||||||
|
seedBooksForCompany,
|
||||||
|
seedReferenceValuesForCompany,
|
||||||
tx
|
tx
|
||||||
};
|
};
|
||||||
|
|||||||
13
server/middleware/company.js
Normal file
13
server/middleware/company.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Resolve the active company for the request from the X-Company-Id header. The company must be one
|
||||||
|
// the user may access (see companies.companiesForUser); otherwise we fall back to their first
|
||||||
|
// accessible company so the app always has a valid, permitted company context. Mount after requireAuth.
|
||||||
|
// Required lazily to avoid a load-time cycle (companies -> roles -> ...).
|
||||||
|
function resolveCompany(req, res, next) {
|
||||||
|
const { companyIdsForUser } = require('../services/companies');
|
||||||
|
const accessible = companyIdsForUser(req.user);
|
||||||
|
const requested = Number(req.headers['x-company-id']);
|
||||||
|
req.companyId = accessible.includes(requested) ? requested : (accessible[0] || null);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { resolveCompany };
|
||||||
@@ -17,8 +17,12 @@ const userSelect = `
|
|||||||
LEFT JOIN teams t ON t.id = u.team_id
|
LEFT JOIN teams t ON t.id = u.team_id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const companies = require('../services/companies');
|
||||||
|
|
||||||
function publicAdminUser(id) {
|
function publicAdminUser(id) {
|
||||||
return one(`${userSelect} WHERE u.id = ?`, [id]);
|
const user = one(`${userSelect} WHERE u.id = ?`, [id]);
|
||||||
|
if (user) user.company_ids = companies.assignedCompanyIds(user.id);
|
||||||
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateRole(role) {
|
function validateRole(role) {
|
||||||
@@ -45,7 +49,8 @@ router.put('/settings', requireCapability('admin.settings'), (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.get('/users', requireCapability('admin.users'), (req, res) => {
|
router.get('/users', requireCapability('admin.users'), (req, res) => {
|
||||||
res.json({ users: all(`${userSelect} ORDER BY u.name`) });
|
const users = all(`${userSelect} ORDER BY u.name`).map((u) => ({ ...u, company_ids: companies.assignedCompanyIds(u.id) }));
|
||||||
|
res.json({ users });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/users', requireCapability('admin.users'), (req, res) => {
|
router.post('/users', requireCapability('admin.users'), (req, res) => {
|
||||||
@@ -77,6 +82,9 @@ router.post('/users', requireCapability('admin.users'), (req, res) => {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
passwordPolicy.recordPassword(result.lastInsertRowid, hash);
|
passwordPolicy.recordPassword(result.lastInsertRowid, hash);
|
||||||
|
// Assign company access: the supplied list, else default to the creating admin's active company.
|
||||||
|
const companyIds = Array.isArray(req.body.company_ids) && req.body.company_ids.length ? req.body.company_ids : (req.companyId ? [req.companyId] : []);
|
||||||
|
companies.setUserCompanies(result.lastInsertRowid, companyIds, req.user.id);
|
||||||
audit(req.user.id, 'create', 'user', result.lastInsertRowid, null, { ...req.body, password: '[redacted]' });
|
audit(req.user.id, 'create', 'user', result.lastInsertRowid, null, { ...req.body, password: '[redacted]' });
|
||||||
res.status(201).json({ user: publicAdminUser(result.lastInsertRowid) });
|
res.status(201).json({ user: publicAdminUser(result.lastInsertRowid) });
|
||||||
});
|
});
|
||||||
@@ -145,6 +153,10 @@ router.put('/users/:id', requireCapability('admin.users'), (req, res) => {
|
|||||||
passwordPolicy.recordPassword(req.params.id, hash);
|
passwordPolicy.recordPassword(req.params.id, hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.body.company_ids !== undefined) {
|
||||||
|
companies.setUserCompanies(req.params.id, req.body.company_ids, req.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
const user = publicAdminUser(req.params.id);
|
const user = publicAdminUser(req.params.id);
|
||||||
audit(req.user.id, 'update', 'user', req.params.id, before, { ...user, password: req.body.password ? '[changed]' : undefined });
|
audit(req.user.id, 'update', 'user', req.params.id, before, { ...user, password: req.body.password ? '[changed]' : undefined });
|
||||||
return res.json({ user });
|
return res.json({ user });
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ const { submitTicket } = require('../services/servicenow');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/alerts', (req, res) => {
|
router.get('/alerts', (req, res) => {
|
||||||
res.json({ alerts: listAlerts(req.query), summary: summary() });
|
res.json({ alerts: listAlerts({ ...req.query, entity_id: req.companyId }), summary: summary(req.companyId) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/alerts/scan', requireCapability('alerts.manage'), (req, res) => {
|
router.post('/alerts/scan', requireCapability('alerts.manage'), (req, res) => {
|
||||||
const result = scanAlerts(req.user.id);
|
const result = scanAlerts(req.user.id);
|
||||||
res.json({ created: result.created.length, openCount: result.openCount, alerts: listAlerts({}), summary: summary() });
|
res.json({ created: result.created.length, openCount: result.openCount, alerts: listAlerts({ entity_id: req.companyId }), summary: summary(req.companyId) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/alerts/run', requireCapability('admin.integrations'), async (req, res, next) => {
|
router.post('/alerts/run', requireCapability('admin.integrations'), async (req, res, next) => {
|
||||||
|
|||||||
@@ -21,48 +21,48 @@ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/assets', (req, res) => {
|
router.get('/assets', (req, res) => {
|
||||||
res.json({ assets: listAssets(req.query) });
|
res.json({ assets: listAssets({ ...req.query, entity_id: req.companyId }) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/assets', requireCapability('assets.edit'), (req, res) => {
|
router.post('/assets', requireCapability('assets.edit'), (req, res) => {
|
||||||
res.status(201).json({ asset: createAsset(req.body, req.user.id) });
|
res.status(201).json({ asset: createAsset(req.body, req.user.id, req.companyId) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/assets/lookup', (req, res) => {
|
router.get('/assets/lookup', (req, res) => {
|
||||||
const code = String(req.query.code || '').trim();
|
const code = String(req.query.code || '').trim();
|
||||||
if (!code) return res.status(400).json({ error: 'Scan code is required' });
|
if (!code) return res.status(400).json({ error: 'Scan code is required' });
|
||||||
const asset = lookupAssetByCode(code);
|
const asset = lookupAssetByCode(code, req.companyId);
|
||||||
if (!asset) return res.status(404).json({ error: `No asset matches "${code}"` });
|
if (!asset) return res.status(404).json({ error: `No asset matches "${code}"` });
|
||||||
return res.json({ asset });
|
return res.json({ asset });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/assets/:id', (req, res) => {
|
router.get('/assets/:id', (req, res) => {
|
||||||
const asset = hydrateAsset(req.params.id);
|
const asset = hydrateAsset(req.params.id);
|
||||||
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
if (!asset || (req.companyId && asset.entity_id !== req.companyId)) return res.status(404).json({ error: 'Asset not found' });
|
||||||
return res.json({ asset });
|
return res.json({ asset });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/assets/:id', requireCapability('assets.edit'), (req, res) => {
|
router.put('/assets/:id', requireCapability('assets.edit'), (req, res) => {
|
||||||
const asset = updateAsset(req.params.id, req.body, req.user.id);
|
const asset = updateAsset(req.params.id, req.body, req.user.id, req.companyId);
|
||||||
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
||||||
return res.json({ asset });
|
return res.json({ asset });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/assets/:id', requireCapability('assets.delete'), (req, res) => {
|
router.delete('/assets/:id', requireCapability('assets.delete'), (req, res) => {
|
||||||
if (!deleteAsset(req.params.id, req.user.id)) return res.status(404).json({ error: 'Asset not found' });
|
if (!deleteAsset(req.params.id, req.user.id, req.companyId)) return res.status(404).json({ error: 'Asset not found' });
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/assets/mass-update', requireCapability('assets.bulk'), (req, res) => {
|
router.post('/assets/mass-update', requireCapability('assets.bulk'), (req, res) => {
|
||||||
const ids = Array.isArray(req.body.ids) ? req.body.ids : [];
|
const ids = Array.isArray(req.body.ids) ? req.body.ids : [];
|
||||||
const updated = massUpdateAssets(ids, req.body.changes || {}, req.user.id);
|
const updated = massUpdateAssets(ids, req.body.changes || {}, req.user.id, req.companyId);
|
||||||
if (!updated) return res.status(400).json({ error: 'Choose assets and editable fields' });
|
if (!updated) return res.status(400).json({ error: 'Choose assets and editable fields' });
|
||||||
return res.json({ updated });
|
return res.json({ updated });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/assets/:id/depreciation', (req, res) => {
|
router.get('/assets/:id/depreciation', (req, res) => {
|
||||||
const asset = hydrateAsset(req.params.id);
|
const asset = hydrateAsset(req.params.id);
|
||||||
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
if (!asset || (req.companyId && asset.entity_id !== req.companyId)) return res.status(404).json({ error: 'Asset not found' });
|
||||||
const startYear = Number(req.query.startYear || new Date().getFullYear());
|
const startYear = Number(req.query.startYear || new Date().getFullYear());
|
||||||
const endYear = Number(req.query.endYear || startYear + 5);
|
const endYear = Number(req.query.endYear || startYear + 5);
|
||||||
const schedules = asset.books
|
const schedules = asset.books
|
||||||
|
|||||||
@@ -21,25 +21,25 @@ router.post('/employees', requireCapability('assets.assign'), (req, res) => {
|
|||||||
|
|
||||||
router.get('/assignments', (req, res) => {
|
router.get('/assignments', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
summary: assignmentSummary(),
|
summary: assignmentSummary(req.companyId),
|
||||||
assignments: assignmentRows({
|
assignments: assignmentRows({
|
||||||
asset_id: req.query.asset_id || null,
|
asset_id: req.query.asset_id || null,
|
||||||
employee_id: req.query.employee_id || null,
|
employee_id: req.query.employee_id || null,
|
||||||
active: req.query.active === 'true'
|
active: req.query.active === 'true'
|
||||||
})
|
}, req.companyId)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/assignments', requireCapability('assets.assign'), (req, res, next) => {
|
router.post('/assignments', requireCapability('assets.assign'), (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
res.status(201).json({ assignment: assignAsset(req.body, req.user.id) });
|
res.status(201).json({ assignment: assignAsset(req.body, req.user.id, req.companyId) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/assignments/:id/release', requireCapability('assets.assign'), (req, res) => {
|
router.put('/assignments/:id/release', requireCapability('assets.assign'), (req, res) => {
|
||||||
const assignment = releaseAssignment(req.params.id, req.body, req.user.id);
|
const assignment = releaseAssignment(req.params.id, req.body, req.user.id, req.companyId);
|
||||||
if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
|
if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
|
||||||
return res.json({ assignment });
|
return res.json({ assignment });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,34 +6,34 @@ const { exportReport } = require('../services/reportExport');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/books', (req, res) => {
|
router.get('/books', (req, res) => {
|
||||||
res.json(listBooks());
|
res.json(listBooks(req.companyId));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/books', requireCapability('finance.manage'), (req, res) => {
|
router.post('/books', requireCapability('finance.manage'), (req, res) => {
|
||||||
res.status(201).json({ book: createBook(req.body, req.user.id) });
|
res.status(201).json({ book: createBook(req.body, req.user.id, req.companyId) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/books/:id', requireCapability('finance.manage'), (req, res) => {
|
router.put('/books/:id', requireCapability('finance.manage'), (req, res) => {
|
||||||
const book = updateBook(req.params.id, req.body, req.user.id);
|
const book = updateBook(req.params.id, req.body, req.user.id, req.companyId);
|
||||||
if (!book) return res.status(404).json({ error: 'Book not found' });
|
if (!book) return res.status(404).json({ error: 'Book not found' });
|
||||||
return res.json({ book });
|
return res.json({ book });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/books/:id', requireCapability('finance.manage'), (req, res) => {
|
router.delete('/books/:id', requireCapability('finance.manage'), (req, res) => {
|
||||||
if (!deleteBook(req.params.id, req.user.id)) return res.status(404).json({ error: 'Book not found' });
|
if (!deleteBook(req.params.id, req.user.id, req.companyId)) return res.status(404).json({ error: 'Book not found' });
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/books/:code/ledger', (req, res) => {
|
router.get('/books/:code/ledger', (req, res) => {
|
||||||
const year = Number(req.query.year) || new Date().getFullYear();
|
const year = Number(req.query.year) || new Date().getFullYear();
|
||||||
const ledger = bookLedger(req.params.code, year);
|
const ledger = bookLedger(req.params.code, year, req.companyId);
|
||||||
if (!ledger) return res.status(404).json({ error: 'Book not found' });
|
if (!ledger) return res.status(404).json({ error: 'Book not found' });
|
||||||
return res.json({ ledger });
|
return res.json({ ledger });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/books/:code/ledger/export', async (req, res) => {
|
router.post('/books/:code/ledger/export', async (req, res) => {
|
||||||
const year = Number(req.body.year) || new Date().getFullYear();
|
const year = Number(req.body.year) || new Date().getFullYear();
|
||||||
const report = ledgerReport(req.params.code, year);
|
const report = ledgerReport(req.params.code, year, req.companyId);
|
||||||
if (!report) return res.status(404).json({ error: 'Book not found' });
|
if (!report) return res.status(404).json({ error: 'Book not found' });
|
||||||
const format = String(req.body.format || 'csv').toLowerCase();
|
const format = String(req.body.format || 'csv').toLowerCase();
|
||||||
const output = await exportReport(report, format);
|
const output = await exportReport(report, format);
|
||||||
|
|||||||
@@ -14,39 +14,39 @@ const router = express.Router();
|
|||||||
const editor = requireCapability('contacts.manage');
|
const editor = requireCapability('contacts.manage');
|
||||||
|
|
||||||
router.get('/contacts', (req, res) => {
|
router.get('/contacts', (req, res) => {
|
||||||
res.json({ contacts: listContacts(req.query) });
|
res.json({ contacts: listContacts(req.query, req.companyId) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/contacts/:id', (req, res) => {
|
router.get('/contacts/:id', (req, res) => {
|
||||||
const contact = getContact(req.params.id);
|
const contact = getContact(req.params.id, req.companyId);
|
||||||
if (!contact) return res.status(404).json({ error: 'Contact not found' });
|
if (!contact) return res.status(404).json({ error: 'Contact not found' });
|
||||||
return res.json({ contact });
|
return res.json({ contact });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/contacts', editor, (req, res) => {
|
router.post('/contacts', editor, (req, res) => {
|
||||||
res.status(201).json({ contact: createContact(req.body, req.user.id) });
|
res.status(201).json({ contact: createContact(req.body, req.user.id, req.companyId) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/contacts/:id', editor, (req, res) => {
|
router.put('/contacts/:id', editor, (req, res) => {
|
||||||
const contact = updateContact(req.params.id, req.body, req.user.id);
|
const contact = updateContact(req.params.id, req.body, req.user.id, req.companyId);
|
||||||
if (!contact) return res.status(404).json({ error: 'Contact not found' });
|
if (!contact) return res.status(404).json({ error: 'Contact not found' });
|
||||||
return res.json({ contact });
|
return res.json({ contact });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/contacts/:id', requireCapability('contacts.manage'), (req, res) => {
|
router.delete('/contacts/:id', requireCapability('contacts.manage'), (req, res) => {
|
||||||
if (!deleteContact(req.params.id, req.user.id)) return res.status(404).json({ error: 'Contact not found' });
|
if (!deleteContact(req.params.id, req.user.id, req.companyId)) return res.status(404).json({ error: 'Contact not found' });
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/contacts/:id/notes', editor, (req, res) => {
|
router.post('/contacts/:id/notes', editor, (req, res) => {
|
||||||
if (!req.body.body) return res.status(400).json({ error: 'Note body is required' });
|
if (!req.body.body) return res.status(400).json({ error: 'Note body is required' });
|
||||||
const note = addNote(req.params.id, req.body.body, req.user.id);
|
const note = addNote(req.params.id, req.body.body, req.user.id, req.companyId);
|
||||||
if (!note) return res.status(404).json({ error: 'Contact not found' });
|
if (!note) return res.status(404).json({ error: 'Contact not found' });
|
||||||
return res.status(201).json({ note });
|
return res.status(201).json({ note });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/contacts/:id/notes/:noteId', editor, (req, res) => {
|
router.delete('/contacts/:id/notes/:noteId', editor, (req, res) => {
|
||||||
if (!deleteNote(req.params.id, req.params.noteId, req.user.id)) return res.status(404).json({ error: 'Note not found' });
|
if (!deleteNote(req.params.id, req.params.noteId, req.user.id, req.companyId)) return res.status(404).json({ error: 'Note not found' });
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,24 +4,25 @@ const { all, one } = require('../db');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/dashboard', (req, res) => {
|
router.get('/dashboard', (req, res) => {
|
||||||
|
const cid = req.companyId || null;
|
||||||
const stats = one(`
|
const stats = one(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) AS asset_count,
|
COUNT(*) AS asset_count,
|
||||||
COALESCE(SUM(acquisition_cost), 0) AS total_cost,
|
COALESCE(SUM(acquisition_cost), 0) AS total_cost,
|
||||||
COALESCE(SUM(CASE WHEN status = 'disposed' THEN 1 ELSE 0 END), 0) AS disposed_count,
|
COALESCE(SUM(CASE WHEN status = 'disposed' THEN 1 ELSE 0 END), 0) AS disposed_count,
|
||||||
COALESCE(SUM(CASE WHEN status = 'in_service' THEN 1 ELSE 0 END), 0) AS in_service_count
|
COALESCE(SUM(CASE WHEN status = 'in_service' THEN 1 ELSE 0 END), 0) AS in_service_count
|
||||||
FROM assets
|
FROM assets WHERE (? IS NULL OR entity_id = ?)
|
||||||
`);
|
`, [cid, cid]);
|
||||||
const byCategory = all('SELECT category, COUNT(*) AS count, COALESCE(SUM(acquisition_cost), 0) AS value FROM assets GROUP BY category ORDER BY value DESC');
|
const byCategory = all('SELECT category, COUNT(*) AS count, COALESCE(SUM(acquisition_cost), 0) AS value FROM assets WHERE (? IS NULL OR entity_id = ?) GROUP BY category ORDER BY value DESC', [cid, cid]);
|
||||||
const byStatus = all('SELECT status, COUNT(*) AS count FROM assets GROUP BY status ORDER BY count DESC');
|
const byStatus = all('SELECT status, COUNT(*) AS count FROM assets WHERE (? IS NULL OR entity_id = ?) GROUP BY status ORDER BY count DESC', [cid, cid]);
|
||||||
const upcomingTasks = all(`
|
const upcomingTasks = all(`
|
||||||
SELECT t.*, a.asset_id, a.description
|
SELECT t.*, a.asset_id, a.description
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
LEFT JOIN assets a ON a.id = t.asset_id
|
LEFT JOIN assets a ON a.id = t.asset_id
|
||||||
WHERE t.status != 'complete'
|
WHERE t.status != 'complete' AND (? IS NULL OR a.entity_id = ?)
|
||||||
ORDER BY t.due_date IS NULL, t.due_date
|
ORDER BY t.due_date IS NULL, t.due_date
|
||||||
LIMIT 8
|
LIMIT 8
|
||||||
`);
|
`, [cid, cid]);
|
||||||
const auditTrail = all('SELECT al.*, u.name AS actor_name FROM audit_logs al LEFT JOIN users u ON u.id = al.actor_id ORDER BY al.id DESC LIMIT 10');
|
const auditTrail = all('SELECT al.*, u.name AS actor_name FROM audit_logs al LEFT JOIN users u ON u.id = al.actor_id ORDER BY al.id DESC LIMIT 10');
|
||||||
res.json({ stats, byCategory, byStatus, upcomingTasks, auditTrail });
|
res.json({ stats, byCategory, byStatus, upcomingTasks, auditTrail });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const router = express.Router();
|
|||||||
|
|
||||||
router.get('/export/assets', async (req, res) => {
|
router.get('/export/assets', async (req, res) => {
|
||||||
const format = String(req.query.format || 'json').toLowerCase();
|
const format = String(req.query.format || 'json').toLowerCase();
|
||||||
const output = await assetExport(format);
|
const output = await assetExport(format, req.companyId);
|
||||||
res.setHeader('Content-Type', output.contentType);
|
res.setHeader('Content-Type', output.contentType);
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${output.filename}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${output.filename}"`);
|
||||||
return res.send(output.body);
|
return res.send(output.body);
|
||||||
@@ -19,7 +19,7 @@ router.get('/export/assets', async (req, res) => {
|
|||||||
router.post('/import/assets', requireCapability('assets.bulk'), upload.single('file'), async (req, res) => {
|
router.post('/import/assets', requireCapability('assets.bulk'), upload.single('file'), async (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'File is required' });
|
if (!req.file) return res.status(400).json({ error: 'File is required' });
|
||||||
const rows = await rowsFromImport(extensionForUpload(req.file.originalname), req.file.buffer);
|
const rows = await rowsFromImport(extensionForUpload(req.file.originalname), req.file.buffer);
|
||||||
const imported = upsertImportedAssets(rows, req.user.id);
|
const imported = upsertImportedAssets(rows, req.user.id, req.companyId);
|
||||||
audit(req.user.id, 'import', 'asset', null, null, { file: req.file.originalname, imported });
|
audit(req.user.id, 'import', 'asset', null, null, { file: req.file.originalname, imported });
|
||||||
res.json({ imported });
|
res.json({ imported });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,59 +17,59 @@ const router = express.Router();
|
|||||||
const editor = requireCapability('parts.manage');
|
const editor = requireCapability('parts.manage');
|
||||||
|
|
||||||
router.get('/parts', (req, res) => {
|
router.get('/parts', (req, res) => {
|
||||||
res.json({ parts: listParts(req.query) });
|
res.json({ parts: listParts(req.query, req.companyId) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/parts/:id', (req, res) => {
|
router.get('/parts/:id', (req, res) => {
|
||||||
const part = getPart(req.params.id);
|
const part = getPart(req.params.id, req.companyId);
|
||||||
if (!part) return res.status(404).json({ error: 'Part not found' });
|
if (!part) return res.status(404).json({ error: 'Part not found' });
|
||||||
return res.json({ part });
|
return res.json({ part });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/parts', editor, (req, res) => {
|
router.post('/parts', editor, (req, res) => {
|
||||||
res.status(201).json({ part: createPart(req.body, req.user.id) });
|
res.status(201).json({ part: createPart(req.body, req.user.id, req.companyId) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/parts/:id', editor, (req, res) => {
|
router.put('/parts/:id', editor, (req, res) => {
|
||||||
const part = updatePart(req.params.id, req.body, req.user.id);
|
const part = updatePart(req.params.id, req.body, req.user.id, req.companyId);
|
||||||
if (!part) return res.status(404).json({ error: 'Part not found' });
|
if (!part) return res.status(404).json({ error: 'Part not found' });
|
||||||
return res.json({ part });
|
return res.json({ part });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/parts/:id', requireCapability('parts.manage'), (req, res) => {
|
router.delete('/parts/:id', requireCapability('parts.manage'), (req, res) => {
|
||||||
if (!deletePart(req.params.id, req.user.id)) return res.status(404).json({ error: 'Part not found' });
|
if (!deletePart(req.params.id, req.user.id, req.companyId)) return res.status(404).json({ error: 'Part not found' });
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stock locations (aisle/bin + on-hand/reserved per location)
|
// Stock locations (aisle/bin + on-hand/reserved per location)
|
||||||
router.post('/parts/:id/stock', editor, (req, res) => {
|
router.post('/parts/:id/stock', editor, (req, res) => {
|
||||||
const part = saveStock(req.params.id, req.body, req.user.id);
|
const part = saveStock(req.params.id, req.body, req.user.id, req.companyId);
|
||||||
if (!part) return res.status(404).json({ error: 'Part or stock location not found' });
|
if (!part) return res.status(404).json({ error: 'Part or stock location not found' });
|
||||||
return res.status(201).json({ part });
|
return res.status(201).json({ part });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/parts/:id/stock/:stockId', editor, (req, res) => {
|
router.delete('/parts/:id/stock/:stockId', editor, (req, res) => {
|
||||||
const part = deleteStock(req.params.id, req.params.stockId, req.user.id);
|
const part = deleteStock(req.params.id, req.params.stockId, req.user.id, req.companyId);
|
||||||
if (!part) return res.status(404).json({ error: 'Stock location not found' });
|
if (!part) return res.status(404).json({ error: 'Stock location not found' });
|
||||||
return res.json({ part });
|
return res.json({ part });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stock movements (receive/issue/adjust/reserve/unreserve)
|
// Stock movements (receive/issue/adjust/reserve/unreserve)
|
||||||
router.post('/parts/:id/transactions', editor, (req, res) => {
|
router.post('/parts/:id/transactions', editor, (req, res) => {
|
||||||
const part = recordTransaction(req.params.id, req.body, req.user.id);
|
const part = recordTransaction(req.params.id, req.body, req.user.id, req.companyId);
|
||||||
if (!part) return res.status(404).json({ error: 'Part not found' });
|
if (!part) return res.status(404).json({ error: 'Part not found' });
|
||||||
return res.status(201).json({ part });
|
return res.status(201).json({ part });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Photos
|
// Photos
|
||||||
router.post('/parts/:id/photos', editor, (req, res) => {
|
router.post('/parts/:id/photos', editor, (req, res) => {
|
||||||
const part = addPhoto(req.params.id, req.body, req.user.id);
|
const part = addPhoto(req.params.id, req.body, req.user.id, req.companyId);
|
||||||
if (!part) return res.status(404).json({ error: 'Part not found' });
|
if (!part) return res.status(404).json({ error: 'Part not found' });
|
||||||
return res.status(201).json({ part });
|
return res.status(201).json({ part });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/parts/:id/photos/:photoId', editor, (req, res) => {
|
router.delete('/parts/:id/photos/:photoId', editor, (req, res) => {
|
||||||
const part = deletePhoto(req.params.id, req.params.photoId, req.user.id);
|
const part = deletePhoto(req.params.id, req.params.photoId, req.user.id, req.companyId);
|
||||||
if (!part) return res.status(404).json({ error: 'Photo not found' });
|
if (!part) return res.status(404).json({ error: 'Photo not found' });
|
||||||
return res.json({ part });
|
return res.json({ part });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const assetPm = requireCapability('pm.complete'); // attach to assets & complete
|
|||||||
|
|
||||||
// PM plan templates
|
// PM plan templates
|
||||||
router.get('/pm-plans', (req, res) => {
|
router.get('/pm-plans', (req, res) => {
|
||||||
res.json({ plans: listPlans() });
|
res.json({ plans: listPlans(req.companyId) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/pm-plans/:id', (req, res) => {
|
router.get('/pm-plans/:id', (req, res) => {
|
||||||
@@ -35,7 +35,7 @@ router.get('/pm-plans/:id', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/pm-plans', planEditor, (req, res) => {
|
router.post('/pm-plans', planEditor, (req, res) => {
|
||||||
res.status(201).json({ plan: createPlan(req.body, req.user.id) });
|
res.status(201).json({ plan: createPlan(req.body, req.user.id, req.companyId) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/pm-plans/:id', planEditor, (req, res) => {
|
router.put('/pm-plans/:id', planEditor, (req, res) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const { all, audit, json, now, one, parseJson, run } = require('../db');
|
const { all, audit, json, now, one, parseJson, run } = require('../db');
|
||||||
const { requireCapability } = require('../middleware/auth');
|
const { requireCapability } = require('../middleware/auth');
|
||||||
const { formatAssetId } = require('../services/assets');
|
const { formatAssetId } = require('../services/assets');
|
||||||
|
const companies = require('../services/companies');
|
||||||
const assetClasses = require('../services/assetClasses');
|
const assetClasses = require('../services/assetClasses');
|
||||||
const zones = require('../services/zones');
|
const zones = require('../services/zones');
|
||||||
const section179Limits = require('../services/section179Limits');
|
const section179Limits = require('../services/section179Limits');
|
||||||
@@ -95,30 +96,43 @@ router.delete('/asset-classes/:id', requireCapability('config.manage'), (req, re
|
|||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Companies (stored in `entities`). Returns the user's accessible active companies for the switcher;
|
||||||
|
// company admins requesting ?includeDisposed=1 get the full management list (incl. disposed).
|
||||||
router.get('/entities', (req, res) => {
|
router.get('/entities', (req, res) => {
|
||||||
res.json({ entities: all('SELECT * FROM entities ORDER BY name') });
|
if (req.query.includeDisposed === '1' && companies.isCompanyAdmin(req.user)) {
|
||||||
|
return res.json({ entities: companies.listCompanies({ includeDisposed: true }) });
|
||||||
|
}
|
||||||
|
return res.json({ entities: companies.companiesForUser(req.user) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/entities', requireCapability('config.manage'), (req, res) => {
|
router.post('/entities', requireCapability('config.manage'), (req, res, next) => {
|
||||||
const created = now();
|
try {
|
||||||
const result = run(
|
res.status(201).json({ entity: companies.createCompany(req.body, req.user.id) });
|
||||||
'INSERT INTO entities (name, legal_name, tax_id, fiscal_year_end_month, base_currency, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
} catch (error) {
|
||||||
[
|
next(error);
|
||||||
req.body.name,
|
}
|
||||||
req.body.legal_name || null,
|
});
|
||||||
req.body.tax_id || null,
|
|
||||||
Number(req.body.fiscal_year_end_month || 12),
|
router.put('/entities/:id', requireCapability('config.manage'), (req, res) => {
|
||||||
req.body.base_currency || 'USD',
|
const entity = companies.updateCompany(req.params.id, req.body, req.user.id);
|
||||||
created,
|
if (!entity) return res.status(404).json({ error: 'Company not found' });
|
||||||
created
|
return res.json({ entity });
|
||||||
]
|
});
|
||||||
);
|
|
||||||
audit(req.user.id, 'create', 'entity', result.lastInsertRowid, null, req.body);
|
router.post('/entities/:id/dispose', requireCapability('config.manage'), (req, res) => {
|
||||||
res.status(201).json({ entity: one('SELECT * FROM entities WHERE id = ?', [result.lastInsertRowid]) });
|
const result = companies.disposeCompany(req.params.id, req.body || {}, req.user.id);
|
||||||
|
if (!result.ok) return res.status(result.status).json({ error: result.error });
|
||||||
|
return res.json({ entity: result.company });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/entities/:id/restore', requireCapability('config.manage'), (req, res) => {
|
||||||
|
const entity = companies.restoreCompany(req.params.id, req.user.id);
|
||||||
|
if (!entity) return res.status(404).json({ error: 'Company not found' });
|
||||||
|
return res.json({ entity });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/references', (req, res) => {
|
router.get('/references', (req, res) => {
|
||||||
const rows = all('SELECT * FROM reference_values ORDER BY type, name');
|
const rows = all('SELECT * FROM reference_values WHERE entity_id = ? ORDER BY type, name', [req.companyId]);
|
||||||
res.json({ references: rows.map((row) => ({ ...row, metadata: parseJson(row.metadata, {}) })) });
|
res.json({ references: rows.map((row) => ({ ...row, metadata: parseJson(row.metadata, {}) })) });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,7 +158,7 @@ function categoryRow(row) {
|
|||||||
id_template_id: template ? templateId : null,
|
id_template_id: template ? templateId : null,
|
||||||
id_template_name: template ? template.name : null,
|
id_template_name: template ? template.name : null,
|
||||||
id_template_pattern: template ? template.pattern : null,
|
id_template_pattern: template ? template.pattern : null,
|
||||||
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE category = ?', [row.name]).c
|
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE category = ? AND entity_id = ?', [row.name, row.entity_id]).c
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,31 +179,31 @@ function resolveCategoryClass(body, prev = {}) {
|
|||||||
const categoryEditor = requireCapability('config.manage');
|
const categoryEditor = requireCapability('config.manage');
|
||||||
|
|
||||||
router.get('/asset-categories', (req, res) => {
|
router.get('/asset-categories', (req, res) => {
|
||||||
const rows = all("SELECT * FROM reference_values WHERE type = 'category' ORDER BY name");
|
const rows = all("SELECT * FROM reference_values WHERE type = 'category' AND entity_id = ? ORDER BY name", [req.companyId]);
|
||||||
res.json({ categories: rows.map(categoryRow) });
|
res.json({ categories: rows.map(categoryRow) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/asset-categories', categoryEditor, (req, res) => {
|
router.post('/asset-categories', categoryEditor, (req, res) => {
|
||||||
const name = String(req.body.name || '').trim();
|
const name = String(req.body.name || '').trim();
|
||||||
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||||
if (one("SELECT id FROM reference_values WHERE type = 'category' AND name = ?", [name])) {
|
if (one("SELECT id FROM reference_values WHERE type = 'category' AND name = ? AND entity_id = ?", [name, req.companyId])) {
|
||||||
return res.status(400).json({ error: 'That category already exists' });
|
return res.status(400).json({ error: 'That category already exists' });
|
||||||
}
|
}
|
||||||
const { classId, classNumber } = resolveCategoryClass(req.body);
|
const { classId, classNumber } = resolveCategoryClass(req.body);
|
||||||
const result = run(
|
const result = run(
|
||||||
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('category', ?, ?, ?)",
|
"INSERT INTO reference_values (entity_id, type, name, code, metadata) VALUES (?, 'category', ?, ?, ?)",
|
||||||
[name, req.body.code || null, json({ id_template_id: req.body.id_template_id || null, asset_class_id: classId, asset_class_number: classNumber })]
|
[req.companyId, name, req.body.code || null, json({ id_template_id: req.body.id_template_id || null, asset_class_id: classId, asset_class_number: classNumber })]
|
||||||
);
|
);
|
||||||
audit(req.user.id, 'create', 'asset_category', result.lastInsertRowid, null, { name });
|
audit(req.user.id, 'create', 'asset_category', result.lastInsertRowid, null, { name });
|
||||||
return res.status(201).json({ category: categoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
return res.status(201).json({ category: categoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/asset-categories/:id', categoryEditor, (req, res) => {
|
router.put('/asset-categories/:id', categoryEditor, (req, res) => {
|
||||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'category'", [req.params.id]);
|
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'category' AND entity_id = ?", [req.params.id, req.companyId]);
|
||||||
if (!before) return res.status(404).json({ error: 'Category not found' });
|
if (!before) return res.status(404).json({ error: 'Category not found' });
|
||||||
const name = String(req.body.name ?? before.name).trim();
|
const name = String(req.body.name ?? before.name).trim();
|
||||||
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||||
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'category' AND name = ?", [name])) {
|
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'category' AND name = ? AND entity_id = ?", [name, req.companyId])) {
|
||||||
return res.status(400).json({ error: 'That category already exists' });
|
return res.status(400).json({ error: 'That category already exists' });
|
||||||
}
|
}
|
||||||
const meta = parseJson(before.metadata, {});
|
const meta = parseJson(before.metadata, {});
|
||||||
@@ -203,20 +217,20 @@ router.put('/asset-categories/:id', categoryEditor, (req, res) => {
|
|||||||
run('UPDATE reference_values SET name = ?, code = ?, metadata = ? WHERE id = ?', [name, req.body.code ?? before.code, json(meta), req.params.id]);
|
run('UPDATE reference_values SET name = ?, code = ?, metadata = ? WHERE id = ?', [name, req.body.code ?? before.code, json(meta), req.params.id]);
|
||||||
let renamed = 0;
|
let renamed = 0;
|
||||||
if (name !== before.name) {
|
if (name !== before.name) {
|
||||||
renamed = run('UPDATE assets SET category = ?, updated_at = ? WHERE category = ?', [name, now(), before.name]).changes || 0;
|
renamed = run('UPDATE assets SET category = ?, updated_at = ? WHERE category = ? AND entity_id = ?', [name, now(), before.name, req.companyId]).changes || 0;
|
||||||
}
|
}
|
||||||
// The class number is shared by every asset in the category — cascade any change.
|
// The class number is shared by every asset in the category — cascade any change (within this company).
|
||||||
if (classChanged) {
|
if (classChanged) {
|
||||||
run('UPDATE assets SET asset_class_number = ?, updated_at = ? WHERE category = ?', [meta.asset_class_number, now(), name]);
|
run('UPDATE assets SET asset_class_number = ?, updated_at = ? WHERE category = ? AND entity_id = ?', [meta.asset_class_number, now(), name, req.companyId]);
|
||||||
}
|
}
|
||||||
audit(req.user.id, 'update', 'asset_category', req.params.id, before, { name, renamed_assets: renamed });
|
audit(req.user.id, 'update', 'asset_category', req.params.id, before, { name, renamed_assets: renamed });
|
||||||
return res.json({ category: categoryRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_assets: renamed });
|
return res.json({ category: categoryRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_assets: renamed });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/asset-categories/:id', categoryEditor, (req, res) => {
|
router.delete('/asset-categories/:id', categoryEditor, (req, res) => {
|
||||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'category'", [req.params.id]);
|
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'category' AND entity_id = ?", [req.params.id, req.companyId]);
|
||||||
if (!before) return res.status(404).json({ error: 'Category not found' });
|
if (!before) return res.status(404).json({ error: 'Category not found' });
|
||||||
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE category = ?', [before.name]).c;
|
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE category = ? AND entity_id = ?', [before.name, req.companyId]).c;
|
||||||
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
|
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
|
||||||
audit(req.user.id, 'delete', 'asset_category', req.params.id, before, { asset_count: affected });
|
audit(req.user.id, 'delete', 'asset_category', req.params.id, before, { asset_count: affected });
|
||||||
return res.json({ deleted: true, asset_count: affected });
|
return res.json({ deleted: true, asset_count: affected });
|
||||||
@@ -230,50 +244,50 @@ function departmentRow(row) {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE department = ?', [row.name]).c
|
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE department = ? AND entity_id = ?', [row.name, row.entity_id]).c
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/asset-departments', (req, res) => {
|
router.get('/asset-departments', (req, res) => {
|
||||||
const rows = all("SELECT * FROM reference_values WHERE type = 'department' ORDER BY name");
|
const rows = all("SELECT * FROM reference_values WHERE type = 'department' AND entity_id = ? ORDER BY name", [req.companyId]);
|
||||||
res.json({ departments: rows.map(departmentRow) });
|
res.json({ departments: rows.map(departmentRow) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/asset-departments', categoryEditor, (req, res) => {
|
router.post('/asset-departments', categoryEditor, (req, res) => {
|
||||||
const name = String(req.body.name || '').trim();
|
const name = String(req.body.name || '').trim();
|
||||||
if (!name) return res.status(400).json({ error: 'Department name is required' });
|
if (!name) return res.status(400).json({ error: 'Department name is required' });
|
||||||
if (one("SELECT id FROM reference_values WHERE type = 'department' AND name = ?", [name])) {
|
if (one("SELECT id FROM reference_values WHERE type = 'department' AND name = ? AND entity_id = ?", [name, req.companyId])) {
|
||||||
return res.status(400).json({ error: 'That department already exists' });
|
return res.status(400).json({ error: 'That department already exists' });
|
||||||
}
|
}
|
||||||
const result = run(
|
const result = run(
|
||||||
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('department', ?, ?, '{}')",
|
"INSERT INTO reference_values (entity_id, type, name, code, metadata) VALUES (?, 'department', ?, ?, '{}')",
|
||||||
[name, req.body.code || null]
|
[req.companyId, name, req.body.code || null]
|
||||||
);
|
);
|
||||||
audit(req.user.id, 'create', 'asset_department', result.lastInsertRowid, null, { name });
|
audit(req.user.id, 'create', 'asset_department', result.lastInsertRowid, null, { name });
|
||||||
return res.status(201).json({ department: departmentRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
return res.status(201).json({ department: departmentRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/asset-departments/:id', categoryEditor, (req, res) => {
|
router.put('/asset-departments/:id', categoryEditor, (req, res) => {
|
||||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'department'", [req.params.id]);
|
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'department' AND entity_id = ?", [req.params.id, req.companyId]);
|
||||||
if (!before) return res.status(404).json({ error: 'Department not found' });
|
if (!before) return res.status(404).json({ error: 'Department not found' });
|
||||||
const name = String(req.body.name ?? before.name).trim();
|
const name = String(req.body.name ?? before.name).trim();
|
||||||
if (!name) return res.status(400).json({ error: 'Department name is required' });
|
if (!name) return res.status(400).json({ error: 'Department name is required' });
|
||||||
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'department' AND name = ?", [name])) {
|
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'department' AND name = ? AND entity_id = ?", [name, req.companyId])) {
|
||||||
return res.status(400).json({ error: 'That department already exists' });
|
return res.status(400).json({ error: 'That department already exists' });
|
||||||
}
|
}
|
||||||
run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
|
run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
|
||||||
let renamed = 0;
|
let renamed = 0;
|
||||||
if (name !== before.name) {
|
if (name !== before.name) {
|
||||||
renamed = run('UPDATE assets SET department = ?, updated_at = ? WHERE department = ?', [name, now(), before.name]).changes || 0;
|
renamed = run('UPDATE assets SET department = ?, updated_at = ? WHERE department = ? AND entity_id = ?', [name, now(), before.name, req.companyId]).changes || 0;
|
||||||
}
|
}
|
||||||
audit(req.user.id, 'update', 'asset_department', req.params.id, before, { name, renamed_assets: renamed });
|
audit(req.user.id, 'update', 'asset_department', req.params.id, before, { name, renamed_assets: renamed });
|
||||||
return res.json({ department: departmentRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_assets: renamed });
|
return res.json({ department: departmentRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_assets: renamed });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/asset-departments/:id', categoryEditor, (req, res) => {
|
router.delete('/asset-departments/:id', categoryEditor, (req, res) => {
|
||||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'department'", [req.params.id]);
|
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'department' AND entity_id = ?", [req.params.id, req.companyId]);
|
||||||
if (!before) return res.status(404).json({ error: 'Department not found' });
|
if (!before) return res.status(404).json({ error: 'Department not found' });
|
||||||
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE department = ?', [before.name]).c;
|
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE department = ? AND entity_id = ?', [before.name, req.companyId]).c;
|
||||||
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
|
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
|
||||||
audit(req.user.id, 'delete', 'asset_department', req.params.id, before, { asset_count: affected });
|
audit(req.user.id, 'delete', 'asset_department', req.params.id, before, { asset_count: affected });
|
||||||
return res.json({ deleted: true, asset_count: affected });
|
return res.json({ deleted: true, asset_count: affected });
|
||||||
@@ -287,50 +301,50 @@ function pmCategoryRow(row) {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
plan_count: one('SELECT COUNT(*) AS c FROM pm_plans WHERE category = ?', [row.name]).c
|
plan_count: one('SELECT COUNT(*) AS c FROM pm_plans WHERE category = ? AND entity_id = ?', [row.name, row.entity_id]).c
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/pm-categories', (req, res) => {
|
router.get('/pm-categories', (req, res) => {
|
||||||
const rows = all("SELECT * FROM reference_values WHERE type = 'pm_category' ORDER BY name");
|
const rows = all("SELECT * FROM reference_values WHERE type = 'pm_category' AND entity_id = ? ORDER BY name", [req.companyId]);
|
||||||
res.json({ categories: rows.map(pmCategoryRow) });
|
res.json({ categories: rows.map(pmCategoryRow) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/pm-categories', categoryEditor, (req, res) => {
|
router.post('/pm-categories', categoryEditor, (req, res) => {
|
||||||
const name = String(req.body.name || '').trim();
|
const name = String(req.body.name || '').trim();
|
||||||
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||||
if (one("SELECT id FROM reference_values WHERE type = 'pm_category' AND name = ?", [name])) {
|
if (one("SELECT id FROM reference_values WHERE type = 'pm_category' AND name = ? AND entity_id = ?", [name, req.companyId])) {
|
||||||
return res.status(400).json({ error: 'That PM category already exists' });
|
return res.status(400).json({ error: 'That PM category already exists' });
|
||||||
}
|
}
|
||||||
const result = run(
|
const result = run(
|
||||||
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('pm_category', ?, ?, '{}')",
|
"INSERT INTO reference_values (entity_id, type, name, code, metadata) VALUES (?, 'pm_category', ?, ?, '{}')",
|
||||||
[name, req.body.code || null]
|
[req.companyId, name, req.body.code || null]
|
||||||
);
|
);
|
||||||
audit(req.user.id, 'create', 'pm_category', result.lastInsertRowid, null, { name });
|
audit(req.user.id, 'create', 'pm_category', result.lastInsertRowid, null, { name });
|
||||||
return res.status(201).json({ category: pmCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
return res.status(201).json({ category: pmCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/pm-categories/:id', categoryEditor, (req, res) => {
|
router.put('/pm-categories/:id', categoryEditor, (req, res) => {
|
||||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'pm_category'", [req.params.id]);
|
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'pm_category' AND entity_id = ?", [req.params.id, req.companyId]);
|
||||||
if (!before) return res.status(404).json({ error: 'PM category not found' });
|
if (!before) return res.status(404).json({ error: 'PM category not found' });
|
||||||
const name = String(req.body.name ?? before.name).trim();
|
const name = String(req.body.name ?? before.name).trim();
|
||||||
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||||
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'pm_category' AND name = ?", [name])) {
|
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'pm_category' AND name = ? AND entity_id = ?", [name, req.companyId])) {
|
||||||
return res.status(400).json({ error: 'That PM category already exists' });
|
return res.status(400).json({ error: 'That PM category already exists' });
|
||||||
}
|
}
|
||||||
run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
|
run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
|
||||||
let renamed = 0;
|
let renamed = 0;
|
||||||
if (name !== before.name) {
|
if (name !== before.name) {
|
||||||
renamed = run('UPDATE pm_plans SET category = ?, updated_at = ? WHERE category = ?', [name, now(), before.name]).changes || 0;
|
renamed = run('UPDATE pm_plans SET category = ?, updated_at = ? WHERE category = ? AND entity_id = ?', [name, now(), before.name, req.companyId]).changes || 0;
|
||||||
}
|
}
|
||||||
audit(req.user.id, 'update', 'pm_category', req.params.id, before, { name, renamed_plans: renamed });
|
audit(req.user.id, 'update', 'pm_category', req.params.id, before, { name, renamed_plans: renamed });
|
||||||
return res.json({ category: pmCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_plans: renamed });
|
return res.json({ category: pmCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_plans: renamed });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/pm-categories/:id', categoryEditor, (req, res) => {
|
router.delete('/pm-categories/:id', categoryEditor, (req, res) => {
|
||||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'pm_category'", [req.params.id]);
|
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'pm_category' AND entity_id = ?", [req.params.id, req.companyId]);
|
||||||
if (!before) return res.status(404).json({ error: 'PM category not found' });
|
if (!before) return res.status(404).json({ error: 'PM category not found' });
|
||||||
const affected = one('SELECT COUNT(*) AS c FROM pm_plans WHERE category = ?', [before.name]).c;
|
const affected = one('SELECT COUNT(*) AS c FROM pm_plans WHERE category = ? AND entity_id = ?', [before.name, req.companyId]).c;
|
||||||
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
|
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
|
||||||
audit(req.user.id, 'delete', 'pm_category', req.params.id, before, { plan_count: affected });
|
audit(req.user.id, 'delete', 'pm_category', req.params.id, before, { plan_count: affected });
|
||||||
return res.json({ deleted: true, plan_count: affected });
|
return res.json({ deleted: true, plan_count: affected });
|
||||||
@@ -349,30 +363,30 @@ function partCategoryRow(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.get('/part-categories', (req, res) => {
|
router.get('/part-categories', (req, res) => {
|
||||||
const rows = all("SELECT * FROM reference_values WHERE type = 'part_category' ORDER BY name");
|
const rows = all("SELECT * FROM reference_values WHERE type = 'part_category' AND entity_id = ? ORDER BY name", [req.companyId]);
|
||||||
res.json({ categories: rows.map(partCategoryRow) });
|
res.json({ categories: rows.map(partCategoryRow) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/part-categories', categoryEditor, (req, res) => {
|
router.post('/part-categories', categoryEditor, (req, res) => {
|
||||||
const name = String(req.body.name || '').trim();
|
const name = String(req.body.name || '').trim();
|
||||||
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||||
if (one("SELECT id FROM reference_values WHERE type = 'part_category' AND name = ?", [name])) {
|
if (one("SELECT id FROM reference_values WHERE type = 'part_category' AND name = ? AND entity_id = ?", [name, req.companyId])) {
|
||||||
return res.status(400).json({ error: 'That part category already exists' });
|
return res.status(400).json({ error: 'That part category already exists' });
|
||||||
}
|
}
|
||||||
const result = run(
|
const result = run(
|
||||||
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('part_category', ?, ?, '{}')",
|
"INSERT INTO reference_values (entity_id, type, name, code, metadata) VALUES (?, 'part_category', ?, ?, '{}')",
|
||||||
[name, req.body.code || null]
|
[req.companyId, name, req.body.code || null]
|
||||||
);
|
);
|
||||||
audit(req.user.id, 'create', 'part_category', result.lastInsertRowid, null, { name });
|
audit(req.user.id, 'create', 'part_category', result.lastInsertRowid, null, { name });
|
||||||
return res.status(201).json({ category: partCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
return res.status(201).json({ category: partCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/part-categories/:id', categoryEditor, (req, res) => {
|
router.put('/part-categories/:id', categoryEditor, (req, res) => {
|
||||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'part_category'", [req.params.id]);
|
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'part_category' AND entity_id = ?", [req.params.id, req.companyId]);
|
||||||
if (!before) return res.status(404).json({ error: 'Part category not found' });
|
if (!before) return res.status(404).json({ error: 'Part category not found' });
|
||||||
const name = String(req.body.name ?? before.name).trim();
|
const name = String(req.body.name ?? before.name).trim();
|
||||||
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||||
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'part_category' AND name = ?", [name])) {
|
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'part_category' AND name = ? AND entity_id = ?", [name, req.companyId])) {
|
||||||
return res.status(400).json({ error: 'That part category already exists' });
|
return res.status(400).json({ error: 'That part category already exists' });
|
||||||
}
|
}
|
||||||
run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
|
run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
|
||||||
@@ -385,7 +399,7 @@ router.put('/part-categories/:id', categoryEditor, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/part-categories/:id', categoryEditor, (req, res) => {
|
router.delete('/part-categories/:id', categoryEditor, (req, res) => {
|
||||||
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'part_category'", [req.params.id]);
|
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'part_category' AND entity_id = ?", [req.params.id, req.companyId]);
|
||||||
if (!before) return res.status(404).json({ error: 'Part category not found' });
|
if (!before) return res.status(404).json({ error: 'Part category not found' });
|
||||||
const affected = one('SELECT COUNT(*) AS c FROM parts WHERE category = ?', [before.name]).c;
|
const affected = one('SELECT COUNT(*) AS c FROM parts WHERE category = ?', [before.name]).c;
|
||||||
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
|
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ router.get('/reports/catalog', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.post('/reports/run', (req, res) => {
|
router.post('/reports/run', (req, res) => {
|
||||||
res.json({ report: runReport(req.body.type, req.body.options || {}) });
|
res.json({ report: runReport(req.body.type, { ...(req.body.options || {}), entity_id: req.companyId }) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/reports/export', async (req, res) => {
|
router.post('/reports/export', async (req, res) => {
|
||||||
const format = String(req.body.format || 'csv').toLowerCase();
|
const format = String(req.body.format || 'csv').toLowerCase();
|
||||||
const report = runReport(req.body.type, req.body.options || {});
|
const report = runReport(req.body.type, { ...(req.body.options || {}), entity_id: req.companyId });
|
||||||
const output = await exportReport(report, format);
|
const output = await exportReport(report, format);
|
||||||
const extension = EXPORT_EXTENSIONS[format] || 'txt';
|
const extension = EXPORT_EXTENSIONS[format] || 'txt';
|
||||||
res.setHeader('Content-Type', output.contentType);
|
res.setHeader('Content-Type', output.contentType);
|
||||||
@@ -34,15 +34,16 @@ router.post('/reports/export', async (req, res) => {
|
|||||||
router.get('/saved-reports', (req, res) => {
|
router.get('/saved-reports', (req, res) => {
|
||||||
const reports = all(
|
const reports = all(
|
||||||
`SELECT s.*, u.name AS created_by_name FROM saved_reports s
|
`SELECT s.*, u.name AS created_by_name FROM saved_reports s
|
||||||
LEFT JOIN users u ON u.id = s.created_by ORDER BY s.name`
|
LEFT JOIN users u ON u.id = s.created_by WHERE s.entity_id = ? ORDER BY s.name`,
|
||||||
|
[req.companyId]
|
||||||
).map((row) => ({ ...row, options_json: parseJson(row.options_json, {}) }));
|
).map((row) => ({ ...row, options_json: parseJson(row.options_json, {}) }));
|
||||||
res.json({ reports });
|
res.json({ reports });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/saved-reports', requireCapability('reports.save'), (req, res) => {
|
router.post('/saved-reports', requireCapability('reports.save'), (req, res) => {
|
||||||
const result = run(
|
const result = run(
|
||||||
'INSERT INTO saved_reports (name, report_type, options_json, created_by, created_at) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO saved_reports (entity_id, name, report_type, options_json, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
[req.body.name || 'Saved report', req.body.report_type, json(req.body.options || {}), req.user.id, now()]
|
[req.companyId, req.body.name || 'Saved report', req.body.report_type, json(req.body.options || {}), req.user.id, now()]
|
||||||
);
|
);
|
||||||
audit(req.user.id, 'create', 'saved_report', result.lastInsertRowid, null, req.body);
|
audit(req.user.id, 'create', 'saved_report', result.lastInsertRowid, null, req.body);
|
||||||
const saved = one('SELECT * FROM saved_reports WHERE id = ?', [result.lastInsertRowid]);
|
const saved = one('SELECT * FROM saved_reports WHERE id = ?', [result.lastInsertRowid]);
|
||||||
@@ -50,7 +51,7 @@ router.post('/saved-reports', requireCapability('reports.save'), (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/saved-reports/:id', requireCapability('reports.save'), (req, res) => {
|
router.delete('/saved-reports/:id', requireCapability('reports.save'), (req, res) => {
|
||||||
const result = run('DELETE FROM saved_reports WHERE id = ?', [req.params.id]);
|
const result = run('DELETE FROM saved_reports WHERE id = ? AND entity_id = ?', [req.params.id, req.companyId]);
|
||||||
if (!result.changes) return res.status(404).json({ error: 'Saved report not found' });
|
if (!result.changes) return res.status(404).json({ error: 'Saved report not found' });
|
||||||
audit(req.user.id, 'delete', 'saved_report', req.params.id, null, null);
|
audit(req.user.id, 'delete', 'saved_report', req.params.id, null, null);
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
@@ -59,19 +60,19 @@ router.delete('/saved-reports/:id', requireCapability('reports.save'), (req, res
|
|||||||
router.get('/reports/depreciation', (req, res) => {
|
router.get('/reports/depreciation', (req, res) => {
|
||||||
const year = Number(req.query.year || new Date().getFullYear());
|
const year = Number(req.query.year || new Date().getFullYear());
|
||||||
const bookType = req.query.book || 'GAAP';
|
const bookType = req.query.book || 'GAAP';
|
||||||
res.json(depreciationReport(year, bookType));
|
res.json(depreciationReport(year, bookType, req.companyId));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/reports/monthly-depreciation', (req, res) => {
|
router.get('/reports/monthly-depreciation', (req, res) => {
|
||||||
const year = Number(req.query.year || new Date().getFullYear());
|
const year = Number(req.query.year || new Date().getFullYear());
|
||||||
const bookType = req.query.book || 'GAAP';
|
const bookType = req.query.book || 'GAAP';
|
||||||
res.json(monthlyDepreciationReport(year, bookType));
|
res.json(monthlyDepreciationReport(year, bookType, req.companyId));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/reports/depreciation.pdf', (req, res) => {
|
router.get('/reports/depreciation.pdf', (req, res) => {
|
||||||
const year = Number(req.query.year || new Date().getFullYear());
|
const year = Number(req.query.year || new Date().getFullYear());
|
||||||
const book = req.query.book || 'GAAP';
|
const book = req.query.book || 'GAAP';
|
||||||
writeDepreciationPdf(res, year, book);
|
writeDepreciationPdf(res, year, book, req.companyId);
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ const router = express.Router();
|
|||||||
router.get('/templates', (req, res) => {
|
router.get('/templates', (req, res) => {
|
||||||
const templates = all(
|
const templates = all(
|
||||||
`SELECT t.*, (SELECT COUNT(*) FROM assets a WHERE a.template_id = t.id) AS asset_count
|
`SELECT t.*, (SELECT COUNT(*) FROM assets a WHERE a.template_id = t.id) AS asset_count
|
||||||
FROM asset_templates t ORDER BY t.name`
|
FROM asset_templates t WHERE t.entity_id = ? ORDER BY t.name`,
|
||||||
|
[req.companyId]
|
||||||
).map((row) => ({
|
).map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
defaults: parseJson(row.defaults, {}),
|
defaults: parseJson(row.defaults, {}),
|
||||||
@@ -19,15 +20,15 @@ router.get('/templates', (req, res) => {
|
|||||||
router.post('/templates', requireCapability('config.manage'), (req, res) => {
|
router.post('/templates', requireCapability('config.manage'), (req, res) => {
|
||||||
const created = now();
|
const created = now();
|
||||||
const result = run(
|
const result = run(
|
||||||
'INSERT INTO asset_templates (name, description, defaults, custom_fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
'INSERT INTO asset_templates (entity_id, name, description, defaults, custom_fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
[req.body.name, req.body.description || null, json(req.body.defaults || {}), json(req.body.custom_fields || []), created, created]
|
[req.companyId, req.body.name, req.body.description || null, json(req.body.defaults || {}), json(req.body.custom_fields || []), created, created]
|
||||||
);
|
);
|
||||||
audit(req.user.id, 'create', 'asset_template', result.lastInsertRowid, null, req.body);
|
audit(req.user.id, 'create', 'asset_template', result.lastInsertRowid, null, req.body);
|
||||||
res.status(201).json({ template: one('SELECT * FROM asset_templates WHERE id = ?', [result.lastInsertRowid]) });
|
res.status(201).json({ template: one('SELECT * FROM asset_templates WHERE id = ?', [result.lastInsertRowid]) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/templates/:id', requireCapability('config.manage'), (req, res) => {
|
router.put('/templates/:id', requireCapability('config.manage'), (req, res) => {
|
||||||
const before = one('SELECT * FROM asset_templates WHERE id = ?', [req.params.id]);
|
const before = one('SELECT * FROM asset_templates WHERE id = ? AND entity_id = ?', [req.params.id, req.companyId]);
|
||||||
if (!before) return res.status(404).json({ error: 'Template not found' });
|
if (!before) return res.status(404).json({ error: 'Template not found' });
|
||||||
run(
|
run(
|
||||||
'UPDATE asset_templates SET name = ?, description = ?, defaults = ?, custom_fields = ?, updated_at = ? WHERE id = ?',
|
'UPDATE asset_templates SET name = ?, description = ?, defaults = ?, custom_fields = ?, updated_at = ? WHERE id = ?',
|
||||||
@@ -47,7 +48,7 @@ router.put('/templates/:id', requireCapability('config.manage'), (req, res) => {
|
|||||||
// Deleting a template unlinks it from any assets (their stored field values are kept)
|
// Deleting a template unlinks it from any assets (their stored field values are kept)
|
||||||
// and removes it from the picker for future assets.
|
// and removes it from the picker for future assets.
|
||||||
router.delete('/templates/:id', requireCapability('config.manage'), (req, res) => {
|
router.delete('/templates/:id', requireCapability('config.manage'), (req, res) => {
|
||||||
const before = one('SELECT * FROM asset_templates WHERE id = ?', [req.params.id]);
|
const before = one('SELECT * FROM asset_templates WHERE id = ? AND entity_id = ?', [req.params.id, req.companyId]);
|
||||||
if (!before) return res.status(404).json({ error: 'Template not found' });
|
if (!before) return res.status(404).json({ error: 'Template not found' });
|
||||||
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE template_id = ?', [req.params.id]).c;
|
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE template_id = ?', [req.params.id]).c;
|
||||||
run('UPDATE assets SET template_id = NULL, updated_at = ? WHERE template_id = ?', [now(), req.params.id]);
|
run('UPDATE assets SET template_id = NULL, updated_at = ? WHERE template_id = ?', [now(), req.params.id]);
|
||||||
|
|||||||
@@ -78,20 +78,22 @@ function scanAlerts(userId) {
|
|||||||
|
|
||||||
for (const c of cands) {
|
for (const c of cands) {
|
||||||
seen.add(`${c.reference_type}:${c.reference_id}:${c.type}`);
|
seen.add(`${c.reference_type}:${c.reference_id}:${c.type}`);
|
||||||
|
// Every candidate derives from an asset; the alert inherits that asset's company for filtering.
|
||||||
|
const entityId = c.asset_id ? (one('SELECT entity_id FROM assets WHERE id = ?', [c.asset_id])?.entity_id ?? null) : null;
|
||||||
const existing = one('SELECT * FROM alerts WHERE reference_type = ? AND reference_id = ? AND type = ?', [c.reference_type, c.reference_id, c.type]);
|
const existing = one('SELECT * FROM alerts WHERE reference_type = ? AND reference_id = ? AND type = ?', [c.reference_type, c.reference_id, c.type]);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.status === 'dismissed') continue;
|
if (existing.status === 'dismissed') continue;
|
||||||
run(
|
run(
|
||||||
`UPDATE alerts SET asset_id = ?, severity = ?, title = ?, message = ?, due_date = ?,
|
`UPDATE alerts SET asset_id = ?, entity_id = ?, severity = ?, title = ?, message = ?, due_date = ?,
|
||||||
status = CASE WHEN status = 'resolved' THEN 'open' ELSE status END, updated_at = ?
|
status = CASE WHEN status = 'resolved' THEN 'open' ELSE status END, updated_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
[c.asset_id, c.severity, c.title, c.message, c.due_date, ts, existing.id]
|
[c.asset_id, entityId, c.severity, c.title, c.message, c.due_date, ts, existing.id]
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const result = run(
|
const result = run(
|
||||||
`INSERT INTO alerts (type, reference_type, reference_id, asset_id, severity, title, message, due_date, status, created_at, updated_at)
|
`INSERT INTO alerts (type, reference_type, reference_id, asset_id, entity_id, severity, title, message, due_date, status, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
|
||||||
[c.type, c.reference_type, c.reference_id, c.asset_id, c.severity, c.title, c.message, c.due_date, ts, ts]
|
[c.type, c.reference_type, c.reference_id, c.asset_id, entityId, c.severity, c.title, c.message, c.due_date, ts, ts]
|
||||||
);
|
);
|
||||||
created.push(one('SELECT * FROM alerts WHERE id = ?', [result.lastInsertRowid]));
|
created.push(one('SELECT * FROM alerts WHERE id = ?', [result.lastInsertRowid]));
|
||||||
}
|
}
|
||||||
@@ -113,14 +115,17 @@ function listAlerts(query = {}) {
|
|||||||
FROM alerts al
|
FROM alerts al
|
||||||
LEFT JOIN assets a ON a.id = al.asset_id
|
LEFT JOIN assets a ON a.id = al.asset_id
|
||||||
LEFT JOIN users u ON u.id = al.acknowledged_by
|
LEFT JOIN users u ON u.id = al.acknowledged_by
|
||||||
WHERE (? IS NULL OR al.status = ?) AND (? IS NULL OR al.type = ?)
|
WHERE (? IS NULL OR al.status = ?) AND (? IS NULL OR al.type = ?) AND (? IS NULL OR al.entity_id = ?)
|
||||||
ORDER BY CASE al.severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, al.due_date IS NULL, al.due_date`,
|
ORDER BY CASE al.severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, al.due_date IS NULL, al.due_date`,
|
||||||
[query.status || null, query.status || null, query.type || null, query.type || null]
|
[query.status || null, query.status || null, query.type || null, query.type || null, query.entity_id || null, query.entity_id || null]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function summary() {
|
function summary(companyId) {
|
||||||
const rows = all("SELECT status, severity, COUNT(*) AS count FROM alerts GROUP BY status, severity");
|
const rows = all(
|
||||||
|
"SELECT status, severity, COUNT(*) AS count FROM alerts WHERE (? IS NULL OR entity_id = ?) GROUP BY status, severity",
|
||||||
|
[companyId || null, companyId || null]
|
||||||
|
);
|
||||||
const open = rows.filter((r) => r.status === 'open');
|
const open = rows.filter((r) => r.status === 'open');
|
||||||
return {
|
return {
|
||||||
open: open.reduce((s, r) => s + r.count, 0),
|
open: open.reduce((s, r) => s + r.count, 0),
|
||||||
|
|||||||
@@ -44,9 +44,12 @@ const assetColumns = [
|
|||||||
const defaultBooks = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
|
const defaultBooks = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
|
||||||
|
|
||||||
// Depreciation books currently enabled in the registry (excludes the maintenance/PM book).
|
// Depreciation books currently enabled in the registry (excludes the maintenance/PM book).
|
||||||
function enabledBookCodes() {
|
function enabledBookCodes(companyId) {
|
||||||
try {
|
try {
|
||||||
const rows = all("SELECT code FROM books WHERE enabled = 1 AND book_type != 'maintenance' ORDER BY sort_order, id");
|
const rows = all(
|
||||||
|
"SELECT code FROM books WHERE enabled = 1 AND book_type != 'maintenance' AND (? IS NULL OR entity_id = ?) ORDER BY sort_order, id",
|
||||||
|
[companyId || null, companyId || null]
|
||||||
|
);
|
||||||
if (rows.length) return rows.map((row) => row.code);
|
if (rows.length) return rows.map((row) => row.code);
|
||||||
} catch {
|
} catch {
|
||||||
// books table may not exist on a very old database
|
// books table may not exist on a very old database
|
||||||
@@ -121,17 +124,18 @@ function hydrateAsset(id) {
|
|||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
function lookupAssetByCode(code) {
|
function lookupAssetByCode(code, companyId) {
|
||||||
const value = String(code || '').trim();
|
const value = String(code || '').trim();
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const row = one(
|
const row = one(
|
||||||
`SELECT id FROM assets
|
`SELECT id FROM assets
|
||||||
WHERE barcode_value = ? COLLATE NOCASE
|
WHERE (? IS NULL OR entity_id = ?)
|
||||||
|
AND (barcode_value = ? COLLATE NOCASE
|
||||||
OR asset_id = ? COLLATE NOCASE
|
OR asset_id = ? COLLATE NOCASE
|
||||||
OR serial_number = ? COLLATE NOCASE
|
OR serial_number = ? COLLATE NOCASE)
|
||||||
ORDER BY (barcode_value = ? COLLATE NOCASE) DESC, id
|
ORDER BY (barcode_value = ? COLLATE NOCASE) DESC, id
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[value, value, value, value]
|
[companyId || null, companyId || null, value, value, value, value]
|
||||||
);
|
);
|
||||||
return row ? hydrateAsset(row.id) : null;
|
return row ? hydrateAsset(row.id) : null;
|
||||||
}
|
}
|
||||||
@@ -212,8 +216,8 @@ function normalizeAssetInput(body) {
|
|||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBooks(assetId, asset, books = []) {
|
function createBooks(assetId, asset, books = [], companyId) {
|
||||||
const selectedBooks = books.length ? books : enabledBookCodes().map((book) => ({ book_type: book }));
|
const selectedBooks = books.length ? books : enabledBookCodes(companyId).map((book) => ({ book_type: book }));
|
||||||
for (const book of selectedBooks) {
|
for (const book of selectedBooks) {
|
||||||
run(
|
run(
|
||||||
`INSERT OR REPLACE INTO asset_books (
|
`INSERT OR REPLACE INTO asset_books (
|
||||||
@@ -262,9 +266,10 @@ function listAssets(query = {}) {
|
|||||||
]).map(assetFromRow);
|
]).map(assetFromRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAsset(body, userId) {
|
function createAsset(body, userId, companyId) {
|
||||||
const created = now();
|
const created = now();
|
||||||
const input = normalizeAssetInput(body);
|
const input = normalizeAssetInput(body);
|
||||||
|
if (companyId) input.entity_id = companyId; // new assets belong to the active company
|
||||||
const result = tx(() => {
|
const result = tx(() => {
|
||||||
input.asset_id = resolveNewAssetId(input); // category ID template, else default sequence
|
input.asset_id = resolveNewAssetId(input); // category ID template, else default sequence
|
||||||
const insert = run(
|
const insert = run(
|
||||||
@@ -272,7 +277,7 @@ function createAsset(body, userId) {
|
|||||||
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
|
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||||
[...assetColumns.map((column) => input[column] ?? null), userId, created, created]
|
[...assetColumns.map((column) => input[column] ?? null), userId, created, created]
|
||||||
);
|
);
|
||||||
createBooks(insert.lastInsertRowid, input, body.books || []);
|
createBooks(insert.lastInsertRowid, input, body.books || [], input.entity_id);
|
||||||
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), insert.lastInsertRowid]);
|
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), insert.lastInsertRowid]);
|
||||||
return insert;
|
return insert;
|
||||||
});
|
});
|
||||||
@@ -281,11 +286,13 @@ function createAsset(body, userId) {
|
|||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAsset(id, body, userId) {
|
function updateAsset(id, body, userId, companyId) {
|
||||||
const before = hydrateAsset(id);
|
const before = hydrateAsset(id);
|
||||||
if (!before) return null;
|
if (!before) return null;
|
||||||
|
if (companyId && before.entity_id !== companyId) return null; // belongs to another company
|
||||||
|
|
||||||
const input = normalizeAssetInput({ ...before, ...body });
|
const input = normalizeAssetInput({ ...before, ...body });
|
||||||
|
if (companyId) input.entity_id = companyId; // assets stay within their company in phase 1
|
||||||
// When depreciation-critical fields change, the caller chooses when the change applies.
|
// When depreciation-critical fields change, the caller chooses when the change applies.
|
||||||
// 'placed_in_service' rebuilds the whole schedule by clearing prior depreciation;
|
// 'placed_in_service' rebuilds the whole schedule by clearing prior depreciation;
|
||||||
// 'going_forward' keeps depreciation already recorded (the stored prior depreciation).
|
// 'going_forward' keeps depreciation already recorded (the stored prior depreciation).
|
||||||
@@ -296,7 +303,7 @@ function updateAsset(id, body, userId) {
|
|||||||
`UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
|
`UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
|
||||||
[...assetColumns.map((column) => input[column] ?? null), updated, id]
|
[...assetColumns.map((column) => input[column] ?? null), updated, id]
|
||||||
);
|
);
|
||||||
if (Array.isArray(body.books)) createBooks(id, input, body.books);
|
if (Array.isArray(body.books)) createBooks(id, input, body.books, input.entity_id);
|
||||||
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), id]);
|
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -305,17 +312,23 @@ function updateAsset(id, body, userId) {
|
|||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteAsset(id, userId) {
|
function deleteAsset(id, userId, companyId) {
|
||||||
const before = hydrateAsset(id);
|
const before = hydrateAsset(id);
|
||||||
if (!before) return false;
|
if (!before) return false;
|
||||||
|
if (companyId && before.entity_id !== companyId) return false; // belongs to another company
|
||||||
run('DELETE FROM assets WHERE id = ?', [id]);
|
run('DELETE FROM assets WHERE id = ?', [id]);
|
||||||
audit(userId, 'delete', 'asset', id, before, null);
|
audit(userId, 'delete', 'asset', id, before, null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function massUpdateAssets(ids, changes, userId) {
|
function massUpdateAssets(ids, changes, userId, companyId) {
|
||||||
const allowed = ['status', 'location', 'department', 'group_name', 'in_service_date', 'useful_life_months', 'condition', 'disposal_date'];
|
const allowed = ['status', 'location', 'department', 'group_name', 'in_service_date', 'useful_life_months', 'condition', 'disposal_date'];
|
||||||
const columns = allowed.filter((column) => Object.prototype.hasOwnProperty.call(changes, column));
|
const columns = allowed.filter((column) => Object.prototype.hasOwnProperty.call(changes, column));
|
||||||
|
// Restrict to assets in the active company so a forged id list can't touch another company's data.
|
||||||
|
if (companyId && ids.length) {
|
||||||
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
|
ids = all(`SELECT id FROM assets WHERE entity_id = ? AND id IN (${placeholders})`, [companyId, ...ids]).map((r) => r.id);
|
||||||
|
}
|
||||||
if (!ids.length || !columns.length) return 0;
|
if (!ids.length || !columns.length) return 0;
|
||||||
|
|
||||||
tx(() => {
|
tx(() => {
|
||||||
@@ -330,26 +343,29 @@ function massUpdateAssets(ids, changes, userId) {
|
|||||||
return ids.length;
|
return ids.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function assetExportRows() {
|
function assetExportRows(companyId) {
|
||||||
return all(`
|
return all(`
|
||||||
SELECT a.*, e.name AS entity_name
|
SELECT a.*, e.name AS entity_name
|
||||||
FROM assets a
|
FROM assets a
|
||||||
LEFT JOIN entities e ON e.id = a.entity_id
|
LEFT JOIN entities e ON e.id = a.entity_id
|
||||||
|
WHERE (? IS NULL OR a.entity_id = ?)
|
||||||
ORDER BY a.asset_id
|
ORDER BY a.asset_id
|
||||||
`).map((asset) => ({
|
`, [companyId || null, companyId || null]).map((asset) => ({
|
||||||
...asset,
|
...asset,
|
||||||
listed_property: Boolean(asset.listed_property),
|
listed_property: Boolean(asset.listed_property),
|
||||||
custom_fields: parseJson(asset.custom_fields, {})
|
custom_fields: parseJson(asset.custom_fields, {})
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertImportedAssets(rows, userId) {
|
function upsertImportedAssets(rows, userId, companyId) {
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
tx(() => {
|
tx(() => {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const input = normalizeAssetInput(row);
|
const input = normalizeAssetInput(row);
|
||||||
|
if (companyId) input.entity_id = companyId; // imports land in the active company
|
||||||
if (!input.asset_id) input.asset_id = resolveNewAssetId(input);
|
if (!input.asset_id) input.asset_id = resolveNewAssetId(input);
|
||||||
const existing = one('SELECT id FROM assets WHERE asset_id = ?', [input.asset_id]);
|
// Match within the active company so imports don't collide with another company's asset ids.
|
||||||
|
const existing = one('SELECT id FROM assets WHERE asset_id = ? AND (? IS NULL OR entity_id = ?)', [input.asset_id, companyId || null, companyId || null]);
|
||||||
const classNumber = assetClassNumberForCategory(input.category);
|
const classNumber = assetClassNumberForCategory(input.category);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
run(
|
run(
|
||||||
@@ -362,7 +378,7 @@ function upsertImportedAssets(rows, userId) {
|
|||||||
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
|
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||||
[...assetColumns.map((column) => input[column] ?? null), userId, now(), now()]
|
[...assetColumns.map((column) => input[column] ?? null), userId, now(), now()]
|
||||||
);
|
);
|
||||||
createBooks(result.lastInsertRowid, input, []);
|
createBooks(result.lastInsertRowid, input, [], input.entity_id);
|
||||||
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [classNumber, result.lastInsertRowid]);
|
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [classNumber, result.lastInsertRowid]);
|
||||||
}
|
}
|
||||||
imported += 1;
|
imported += 1;
|
||||||
|
|||||||
@@ -87,19 +87,21 @@ function upsertEmployee(employee) {
|
|||||||
return one('SELECT * FROM employees WHERE id = ?', [result.lastInsertRowid]);
|
return one('SELECT * FROM employees WHERE id = ?', [result.lastInsertRowid]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignmentRows(query = {}) {
|
function assignmentRows(query = {}, companyId) {
|
||||||
return all(`
|
return all(`
|
||||||
SELECT aa.*, a.asset_id AS asset_code, a.description AS asset_description,
|
SELECT aa.*, a.asset_id AS asset_code, a.description AS asset_description,
|
||||||
e.name AS employee_name, e.email AS employee_email, e.employee_number
|
e.name AS employee_name, e.email AS employee_email, e.employee_number
|
||||||
FROM asset_assignments aa
|
FROM asset_assignments aa
|
||||||
JOIN assets a ON a.id = aa.asset_id
|
JOIN assets a ON a.id = aa.asset_id
|
||||||
LEFT JOIN employees e ON e.id = aa.employee_id
|
LEFT JOIN employees e ON e.id = aa.employee_id
|
||||||
WHERE (? IS NULL OR aa.asset_id = ?)
|
WHERE (? IS NULL OR a.entity_id = ?)
|
||||||
|
AND (? IS NULL OR aa.asset_id = ?)
|
||||||
AND (? IS NULL OR aa.employee_id = ?)
|
AND (? IS NULL OR aa.employee_id = ?)
|
||||||
AND (? IS NULL OR aa.released_at IS NULL)
|
AND (? IS NULL OR aa.released_at IS NULL)
|
||||||
ORDER BY aa.assigned_at DESC, aa.id DESC
|
ORDER BY aa.assigned_at DESC, aa.id DESC
|
||||||
LIMIT 500
|
LIMIT 500
|
||||||
`, [
|
`, [
|
||||||
|
companyId || null, companyId || null,
|
||||||
query.asset_id || null,
|
query.asset_id || null,
|
||||||
query.asset_id || null,
|
query.asset_id || null,
|
||||||
query.employee_id || null,
|
query.employee_id || null,
|
||||||
@@ -108,9 +110,10 @@ function assignmentRows(query = {}) {
|
|||||||
]).map(assignmentFromRow);
|
]).map(assignmentFromRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignAsset(body, userId) {
|
function assignAsset(body, userId, companyId) {
|
||||||
const timestamp = now();
|
const timestamp = now();
|
||||||
const asset = one('SELECT * FROM assets WHERE id = ?', [body.asset_id]);
|
// The asset must belong to the active company so assignments can't cross companies.
|
||||||
|
const asset = one('SELECT * FROM assets WHERE id = ? AND (? IS NULL OR entity_id = ?)', [body.asset_id, companyId || null, companyId || null]);
|
||||||
if (!asset) throw Object.assign(new Error('Asset not found'), { status: 404 });
|
if (!asset) throw Object.assign(new Error('Asset not found'), { status: 404 });
|
||||||
|
|
||||||
const employee = body.employee_id ? one('SELECT * FROM employees WHERE id = ?', [body.employee_id]) : null;
|
const employee = body.employee_id ? one('SELECT * FROM employees WHERE id = ?', [body.employee_id]) : null;
|
||||||
@@ -154,15 +157,18 @@ function assignAsset(body, userId) {
|
|||||||
[employee?.name || asset.custodian || null, department, location, timestamp, body.asset_id]
|
[employee?.name || asset.custodian || null, department, location, timestamp, body.asset_id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const assignment = assignmentRows({ asset_id: body.asset_id, active: true })[0];
|
const assignment = assignmentRows({ asset_id: body.asset_id, active: true }, companyId)[0];
|
||||||
audit(userId, 'assign', 'asset', body.asset_id, null, assignment);
|
audit(userId, 'assign', 'asset', body.asset_id, null, assignment);
|
||||||
return assignment || one('SELECT * FROM asset_assignments WHERE id = ?', [result.lastInsertRowid]);
|
return assignment || one('SELECT * FROM asset_assignments WHERE id = ?', [result.lastInsertRowid]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function releaseAssignment(id, body, userId) {
|
function releaseAssignment(id, body, userId, companyId) {
|
||||||
const before = one('SELECT * FROM asset_assignments WHERE id = ?', [id]);
|
const before = one(
|
||||||
if (!before) return null;
|
'SELECT aa.*, a.entity_id AS asset_entity_id FROM asset_assignments aa JOIN assets a ON a.id = aa.asset_id WHERE aa.id = ?',
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!before || (companyId && before.asset_entity_id !== companyId)) return null;
|
||||||
const timestamp = now();
|
const timestamp = now();
|
||||||
run(
|
run(
|
||||||
`UPDATE asset_assignments
|
`UPDATE asset_assignments
|
||||||
@@ -171,26 +177,28 @@ function releaseAssignment(id, body, userId) {
|
|||||||
[timestamp, body.release_reason || 'Released', body.notes || null, timestamp, id]
|
[timestamp, body.release_reason || 'Released', body.notes || null, timestamp, id]
|
||||||
);
|
);
|
||||||
audit(userId, 'release_assignment', 'asset_assignment', id, before, { released_at: timestamp, ...body });
|
audit(userId, 'release_assignment', 'asset_assignment', id, before, { released_at: timestamp, ...body });
|
||||||
return assignmentRows({ asset_id: before.asset_id }).find((assignment) => assignment.id === Number(id));
|
return assignmentRows({ asset_id: before.asset_id }, companyId).find((assignment) => assignment.id === Number(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignmentSummary() {
|
function assignmentSummary(companyId) {
|
||||||
const stats = one(`
|
const stats = one(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) AS active_assignments,
|
COUNT(*) AS active_assignments,
|
||||||
COUNT(DISTINCT employee_id) AS assigned_employees,
|
COUNT(DISTINCT aa.employee_id) AS assigned_employees,
|
||||||
COUNT(DISTINCT location) AS assigned_locations
|
COUNT(DISTINCT aa.location) AS assigned_locations
|
||||||
FROM asset_assignments
|
FROM asset_assignments aa
|
||||||
WHERE released_at IS NULL
|
JOIN assets a ON a.id = aa.asset_id
|
||||||
`);
|
WHERE aa.released_at IS NULL AND (? IS NULL OR a.entity_id = ?)
|
||||||
|
`, [companyId || null, companyId || null]);
|
||||||
const unassigned = one(`
|
const unassigned = one(`
|
||||||
SELECT COUNT(*) AS count
|
SELECT COUNT(*) AS count
|
||||||
FROM assets a
|
FROM assets a
|
||||||
WHERE NOT EXISTS (
|
WHERE (? IS NULL OR a.entity_id = ?)
|
||||||
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM asset_assignments aa
|
SELECT 1 FROM asset_assignments aa
|
||||||
WHERE aa.asset_id = a.id AND aa.released_at IS NULL
|
WHERE aa.asset_id = a.id AND aa.released_at IS NULL
|
||||||
)
|
)
|
||||||
`);
|
`, [companyId || null, companyId || null]);
|
||||||
return { ...stats, unassigned_assets: unassigned.count };
|
return { ...stats, unassigned_assets: unassigned.count };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,35 +19,39 @@ function bookFromRow(row) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listBooks() {
|
function listBooks(companyId) {
|
||||||
const books = all(
|
const books = all(
|
||||||
`SELECT b.*, t.name AS tax_rule_set_name, t.version AS tax_rule_set_version,
|
`SELECT b.*, t.name AS tax_rule_set_name, t.version AS tax_rule_set_version,
|
||||||
(SELECT COUNT(*) FROM asset_books ab WHERE ab.book_type = b.code AND ab.active = 1) AS asset_count
|
(SELECT COUNT(*) FROM asset_books ab JOIN assets a ON a.id = ab.asset_id
|
||||||
|
WHERE ab.book_type = b.code AND ab.active = 1 AND a.entity_id = b.entity_id) AS asset_count
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN tax_rule_sets t ON t.id = b.tax_rule_set_id
|
LEFT JOIN tax_rule_sets t ON t.id = b.tax_rule_set_id
|
||||||
ORDER BY b.sort_order, b.id`
|
WHERE (? IS NULL OR b.entity_id = ?)
|
||||||
|
ORDER BY b.sort_order, b.id`,
|
||||||
|
[companyId || null, companyId || null]
|
||||||
).map(bookFromRow);
|
).map(bookFromRow);
|
||||||
const ruleSets = all('SELECT id, name, jurisdiction, version, active FROM tax_rule_sets ORDER BY active DESC, name')
|
const ruleSets = all('SELECT id, name, jurisdiction, version, active FROM tax_rule_sets ORDER BY active DESC, name')
|
||||||
.map((row) => ({ ...row, active: Boolean(row.active) }));
|
.map((row) => ({ ...row, active: Boolean(row.active) }));
|
||||||
return { books, ruleSets };
|
return { books, ruleSets };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBook(code) {
|
function getBook(code, companyId) {
|
||||||
return bookFromRow(one('SELECT * FROM books WHERE code = ? OR id = ?', [code, code]) || null) || null;
|
return bookFromRow(one('SELECT * FROM books WHERE (code = ? OR id = ?) AND (? IS NULL OR entity_id = ?)', [code, code, companyId || null, companyId || null]) || null) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createBook(body, userId) {
|
function createBook(body, userId, companyId) {
|
||||||
const code = String(body.code || '').trim().toUpperCase().replace(/[^A-Z0-9_]/g, '').slice(0, 20);
|
const code = String(body.code || '').trim().toUpperCase().replace(/[^A-Z0-9_]/g, '').slice(0, 20);
|
||||||
if (!code) throw Object.assign(new Error('Book code is required'), { status: 400 });
|
if (!code) throw Object.assign(new Error('Book code is required'), { status: 400 });
|
||||||
if (one('SELECT id FROM books WHERE code = ?', [code])) {
|
if (one('SELECT id FROM books WHERE code = ? AND entity_id = ?', [code, companyId])) {
|
||||||
throw Object.assign(new Error(`A book with code "${code}" already exists`), { status: 400 });
|
throw Object.assign(new Error(`A book with code "${code}" already exists`), { status: 400 });
|
||||||
}
|
}
|
||||||
const ts = now();
|
const ts = now();
|
||||||
const next = one('SELECT MAX(sort_order) AS max FROM books');
|
const next = one('SELECT MAX(sort_order) AS max FROM books WHERE entity_id = ?', [companyId]);
|
||||||
const result = run(
|
const result = run(
|
||||||
`INSERT INTO books (code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, fiscal_year_end_month, sort_order, created_at, updated_at)
|
`INSERT INTO books (entity_id, code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, fiscal_year_end_month, sort_order, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
|
companyId,
|
||||||
code,
|
code,
|
||||||
body.name || code,
|
body.name || code,
|
||||||
body.description || null,
|
body.description || null,
|
||||||
@@ -65,20 +69,20 @@ function createBook(body, userId) {
|
|||||||
return bookFromRow(one('SELECT * FROM books WHERE id = ?', [result.lastInsertRowid]));
|
return bookFromRow(one('SELECT * FROM books WHERE id = ?', [result.lastInsertRowid]));
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteBook(id, userId) {
|
function deleteBook(id, userId, companyId) {
|
||||||
const before = one('SELECT * FROM books WHERE id = ?', [id]);
|
const before = one('SELECT * FROM books WHERE id = ? AND (? IS NULL OR entity_id = ?)', [id, companyId || null, companyId || null]);
|
||||||
if (!before) return false;
|
if (!before) return false;
|
||||||
if (before.is_primary) throw Object.assign(new Error('The primary book cannot be deleted'), { status: 400 });
|
if (before.is_primary) throw Object.assign(new Error('The primary book cannot be deleted'), { status: 400 });
|
||||||
if (before.book_type === 'maintenance') throw Object.assign(new Error('The PM book cannot be deleted'), { status: 400 });
|
if (before.book_type === 'maintenance') throw Object.assign(new Error('The PM book cannot be deleted'), { status: 400 });
|
||||||
const used = one('SELECT COUNT(*) AS count FROM asset_books WHERE book_type = ?', [before.code]).count;
|
const used = one('SELECT COUNT(*) AS count FROM asset_books ab JOIN assets a ON a.id = ab.asset_id WHERE ab.book_type = ? AND a.entity_id = ?', [before.code, before.entity_id]).count;
|
||||||
if (used) throw Object.assign(new Error(`This book is used by ${used} asset record(s); disable it instead`), { status: 400 });
|
if (used) throw Object.assign(new Error(`This book is used by ${used} asset record(s); disable it instead`), { status: 400 });
|
||||||
run('DELETE FROM books WHERE id = ?', [id]);
|
run('DELETE FROM books WHERE id = ?', [id]);
|
||||||
audit(userId, 'delete', 'book', id, before, null);
|
audit(userId, 'delete', 'book', id, before, null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBook(id, body, userId) {
|
function updateBook(id, body, userId, companyId) {
|
||||||
const before = one('SELECT * FROM books WHERE id = ?', [id]);
|
const before = one('SELECT * FROM books WHERE id = ? AND (? IS NULL OR entity_id = ?)', [id, companyId || null, companyId || null]);
|
||||||
if (!before) return null;
|
if (!before) return null;
|
||||||
run(
|
run(
|
||||||
`UPDATE books SET
|
`UPDATE books SET
|
||||||
@@ -104,11 +108,11 @@ function updateBook(id, body, userId) {
|
|||||||
|
|
||||||
// General-ledger view of a book: per-asset basis, accumulated depreciation and
|
// General-ledger view of a book: per-asset basis, accumulated depreciation and
|
||||||
// current-year expense rolled up to GL accounts, plus an asset-level detail.
|
// current-year expense rolled up to GL accounts, plus an asset-level detail.
|
||||||
function bookLedger(code, year) {
|
function bookLedger(code, year, companyId) {
|
||||||
const book = getBook(code);
|
const book = getBook(code, companyId);
|
||||||
if (!book) return null;
|
if (!book) return null;
|
||||||
if (book.book_type === 'maintenance') {
|
if (book.book_type === 'maintenance') {
|
||||||
return { ...pmBookLedger(year), book };
|
return { ...pmBookLedger(year, companyId), book };
|
||||||
}
|
}
|
||||||
const rules = ruleSetForBook(code);
|
const rules = ruleSetForBook(code);
|
||||||
const rows = all(
|
const rows = all(
|
||||||
@@ -116,9 +120,9 @@ function bookLedger(code, year) {
|
|||||||
b.useful_life_months AS book_life, b.section_179_amount, b.bonus_percent,
|
b.useful_life_months AS book_life, b.section_179_amount, b.bonus_percent,
|
||||||
b.business_use_percent AS book_bup, b.investment_use_percent AS book_ivp, b.manual_depreciation
|
b.business_use_percent AS book_bup, b.investment_use_percent AS book_ivp, b.manual_depreciation
|
||||||
FROM assets a JOIN asset_books b ON b.asset_id = a.id
|
FROM assets a JOIN asset_books b ON b.asset_id = a.id
|
||||||
WHERE b.book_type = ? AND b.active = 1
|
WHERE b.book_type = ? AND b.active = 1 AND (? IS NULL OR a.entity_id = ?)
|
||||||
ORDER BY a.asset_id`,
|
ORDER BY a.asset_id`,
|
||||||
[code]
|
[code, companyId || null, companyId || null]
|
||||||
);
|
);
|
||||||
|
|
||||||
const accountMap = new Map();
|
const accountMap = new Map();
|
||||||
@@ -195,8 +199,8 @@ function bookLedger(code, year) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Shape the ledger account rollup as a generic report for PDF/XLSX/CSV export.
|
// Shape the ledger account rollup as a generic report for PDF/XLSX/CSV export.
|
||||||
function ledgerReport(code, year) {
|
function ledgerReport(code, year, companyId) {
|
||||||
const ledger = bookLedger(code, year);
|
const ledger = bookLedger(code, year, companyId);
|
||||||
if (!ledger) return null;
|
if (!ledger) return null;
|
||||||
return {
|
return {
|
||||||
title: `${ledger.book.name} general ledger — ${year}`,
|
title: `${ledger.book.name} general ledger — ${year}`,
|
||||||
|
|||||||
128
server/services/companies.js
Normal file
128
server/services/companies.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
const { all, audit, now, one, run, seedBooksForCompany, seedReferenceValuesForCompany } = require('../db');
|
||||||
|
|
||||||
|
// Companies are stored in the `entities` table. Each gets its own books/GL, PM plans, alerts, and
|
||||||
|
// (in phase 2) reference data. Disposing a company is a soft status change that preserves all history.
|
||||||
|
|
||||||
|
function listCompanies({ includeDisposed = false } = {}) {
|
||||||
|
const where = includeDisposed ? '' : "WHERE status = 'active'";
|
||||||
|
return all(`SELECT * FROM entities ${where} ORDER BY status, name`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompany(id) {
|
||||||
|
return one('SELECT * FROM entities WHERE id = ?', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeCount() {
|
||||||
|
return one("SELECT COUNT(*) AS c FROM entities WHERE status = 'active'").c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCompany(body, userId) {
|
||||||
|
const name = String(body.name || '').trim();
|
||||||
|
if (!name) throw Object.assign(new Error('A company name is required'), { status: 400 });
|
||||||
|
const ts = now();
|
||||||
|
const result = run(
|
||||||
|
`INSERT INTO entities (name, legal_name, tax_id, fiscal_year_end_month, base_currency, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`,
|
||||||
|
[name, body.legal_name || null, body.tax_id || null, Number(body.fiscal_year_end_month || 12), body.base_currency || 'USD', ts, ts]
|
||||||
|
);
|
||||||
|
// A new company starts with the standard book set + reference data (categories/locations/depts).
|
||||||
|
seedBooksForCompany(result.lastInsertRowid);
|
||||||
|
seedReferenceValuesForCompany(result.lastInsertRowid);
|
||||||
|
audit(userId, 'create', 'entity', result.lastInsertRowid, null, { name });
|
||||||
|
return getCompany(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCompany(id, body, userId) {
|
||||||
|
const before = getCompany(id);
|
||||||
|
if (!before) return null;
|
||||||
|
run(
|
||||||
|
`UPDATE entities SET name = ?, legal_name = ?, tax_id = ?, fiscal_year_end_month = ?, base_currency = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
[
|
||||||
|
body.name !== undefined ? String(body.name).trim() || before.name : before.name,
|
||||||
|
body.legal_name !== undefined ? body.legal_name : before.legal_name,
|
||||||
|
body.tax_id !== undefined ? body.tax_id : before.tax_id,
|
||||||
|
body.fiscal_year_end_month !== undefined ? Number(body.fiscal_year_end_month) : before.fiscal_year_end_month,
|
||||||
|
body.base_currency !== undefined ? body.base_currency : before.base_currency,
|
||||||
|
now(), id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
audit(userId, 'update', 'entity', id, before, body);
|
||||||
|
return getCompany(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive/retire a company: it stays in the database (assets, books, GL preserved) but is hidden from
|
||||||
|
// active selection and can no longer be the active company. Guards the last active company.
|
||||||
|
function disposeCompany(id, body, userId) {
|
||||||
|
const before = getCompany(id);
|
||||||
|
if (!before) return { ok: false, status: 404, error: 'Company not found' };
|
||||||
|
if (before.status === 'disposed') return { ok: false, status: 400, error: 'Company is already disposed' };
|
||||||
|
if (activeCount() <= 1) return { ok: false, status: 400, error: 'At least one active company is required' };
|
||||||
|
run("UPDATE entities SET status = 'disposed', disposed_at = ?, disposed_reason = ?, updated_at = ? WHERE id = ?",
|
||||||
|
[now(), body.reason || null, now(), id]);
|
||||||
|
audit(userId, 'dispose', 'entity', id, before, { reason: body.reason || null });
|
||||||
|
return { ok: true, company: getCompany(id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreCompany(id, userId) {
|
||||||
|
const before = getCompany(id);
|
||||||
|
if (!before) return null;
|
||||||
|
run("UPDATE entities SET status = 'active', disposed_at = NULL, disposed_reason = NULL, updated_at = ? WHERE id = ?", [now(), id]);
|
||||||
|
audit(userId, 'restore', 'entity', id, before, null);
|
||||||
|
return getCompany(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Per-company user access ------------------------------------------------
|
||||||
|
|
||||||
|
// A user who can manage users (admin.users / wildcard) sees every company; this prevents an admin
|
||||||
|
// from locking themselves out of any company. Required lazily to avoid a load-time cycle.
|
||||||
|
function isCompanyAdmin(user) {
|
||||||
|
const { capabilitiesForRole } = require('./roles');
|
||||||
|
const { capabilitySetIncludes } = require('./accessControl');
|
||||||
|
return capabilitySetIncludes(capabilitiesForRole(user.role), 'admin.users');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active companies a user may see/switch to. Admins → all; others → their assigned companies, falling
|
||||||
|
// back to the default company when they have none (so a user is never locked out).
|
||||||
|
function companiesForUser(user) {
|
||||||
|
if (isCompanyAdmin(user)) {
|
||||||
|
return all("SELECT * FROM entities WHERE status = 'active' ORDER BY name");
|
||||||
|
}
|
||||||
|
const rows = all(
|
||||||
|
`SELECT e.* FROM entities e JOIN user_companies uc ON uc.entity_id = e.id
|
||||||
|
WHERE uc.user_id = ? AND e.status = 'active' ORDER BY e.name`,
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
if (rows.length) return rows;
|
||||||
|
const def = one("SELECT * FROM entities WHERE status = 'active' ORDER BY id LIMIT 1");
|
||||||
|
return def ? [def] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function companyIdsForUser(user) {
|
||||||
|
return companiesForUser(user).map((c) => c.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit assignments stored for a user (independent of the admin bypass).
|
||||||
|
function assignedCompanyIds(userId) {
|
||||||
|
return all('SELECT entity_id FROM user_companies WHERE user_id = ?', [userId]).map((r) => r.entity_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUserCompanies(userId, ids, actorId) {
|
||||||
|
const valid = (Array.isArray(ids) ? ids : []).filter((id) => one('SELECT id FROM entities WHERE id = ?', [id]));
|
||||||
|
run('DELETE FROM user_companies WHERE user_id = ?', [userId]);
|
||||||
|
for (const id of valid) run('INSERT OR IGNORE INTO user_companies (user_id, entity_id) VALUES (?, ?)', [userId, id]);
|
||||||
|
if (actorId) audit(actorId, 'set_companies', 'user', userId, null, { company_ids: valid });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
assignedCompanyIds,
|
||||||
|
companiesForUser,
|
||||||
|
companyIdsForUser,
|
||||||
|
createCompany,
|
||||||
|
disposeCompany,
|
||||||
|
getCompany,
|
||||||
|
isCompanyAdmin,
|
||||||
|
listCompanies,
|
||||||
|
restoreCompany,
|
||||||
|
setUserCompanies,
|
||||||
|
updateCompany
|
||||||
|
};
|
||||||
@@ -47,10 +47,11 @@ function normalize(body) {
|
|||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
function listContacts(query = {}) {
|
function listContacts(query = {}, companyId) {
|
||||||
return all(
|
return all(
|
||||||
`SELECT * FROM contacts
|
`SELECT * FROM contacts
|
||||||
WHERE (? IS NULL OR type = ?)
|
WHERE (? IS NULL OR entity_id = ?)
|
||||||
|
AND (? IS NULL OR type = ?)
|
||||||
AND (? IS NULL OR status = ?)
|
AND (? IS NULL OR status = ?)
|
||||||
AND (? IS NULL OR (
|
AND (? IS NULL OR (
|
||||||
first_name LIKE '%' || ? || '%' OR last_name LIKE '%' || ? || '%' OR
|
first_name LIKE '%' || ? || '%' OR last_name LIKE '%' || ? || '%' OR
|
||||||
@@ -59,6 +60,7 @@ function listContacts(query = {}) {
|
|||||||
))
|
))
|
||||||
ORDER BY type, organization, last_name, first_name`,
|
ORDER BY type, organization, last_name, first_name`,
|
||||||
[
|
[
|
||||||
|
companyId || null, companyId || null,
|
||||||
query.type || null, query.type || null,
|
query.type || null, query.type || null,
|
||||||
query.status || null, query.status || null,
|
query.status || null, query.status || null,
|
||||||
query.search || null, query.search || null, query.search || null,
|
query.search || null, query.search || null, query.search || null,
|
||||||
@@ -67,51 +69,52 @@ function listContacts(query = {}) {
|
|||||||
).map((row) => fromRow(row));
|
).map((row) => fromRow(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getContact(id) {
|
function getContact(id, companyId) {
|
||||||
return fromRow(one('SELECT * FROM contacts WHERE id = ?', [id]), true);
|
return fromRow(one('SELECT * FROM contacts WHERE id = ? AND (? IS NULL OR entity_id = ?)', [id, companyId || null, companyId || null]), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createContact(body, userId) {
|
function createContact(body, userId, companyId) {
|
||||||
const input = normalize(body);
|
const input = normalize(body);
|
||||||
const ts = now();
|
const ts = now();
|
||||||
const cols = COLUMNS.filter((c) => input[c] !== undefined);
|
const cols = COLUMNS.filter((c) => input[c] !== undefined);
|
||||||
const result = run(
|
const result = run(
|
||||||
`INSERT INTO contacts (${cols.join(', ')}, created_by, created_at, updated_at)
|
`INSERT INTO contacts (entity_id, ${cols.join(', ')}, created_by, created_at, updated_at)
|
||||||
VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
VALUES (?, ${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||||
[...cols.map((c) => input[c]), userId, ts, ts]
|
[companyId || null, ...cols.map((c) => input[c]), userId, ts, ts]
|
||||||
);
|
);
|
||||||
audit(userId, 'create', 'contact', result.lastInsertRowid, null, { type: input.type, name: displayName(input) });
|
audit(userId, 'create', 'contact', result.lastInsertRowid, null, { type: input.type, name: displayName(input) });
|
||||||
return getContact(result.lastInsertRowid);
|
return getContact(result.lastInsertRowid, companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateContact(id, body, userId) {
|
function updateContact(id, body, userId, companyId) {
|
||||||
const before = one('SELECT * FROM contacts WHERE id = ?', [id]);
|
const before = one('SELECT * FROM contacts WHERE id = ?', [id]);
|
||||||
if (!before) return null;
|
if (!before || (companyId && before.entity_id !== companyId)) return null;
|
||||||
const input = normalize({ ...before, ...body });
|
const input = normalize({ ...before, ...body });
|
||||||
run(
|
run(
|
||||||
`UPDATE contacts SET ${COLUMNS.map((c) => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
|
`UPDATE contacts SET ${COLUMNS.map((c) => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
|
||||||
[...COLUMNS.map((c) => input[c] ?? null), now(), id]
|
[...COLUMNS.map((c) => input[c] ?? null), now(), id]
|
||||||
);
|
);
|
||||||
audit(userId, 'update', 'contact', id, before, body);
|
audit(userId, 'update', 'contact', id, before, body);
|
||||||
return getContact(id);
|
return getContact(id, companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteContact(id, userId) {
|
function deleteContact(id, userId, companyId) {
|
||||||
const before = one('SELECT * FROM contacts WHERE id = ?', [id]);
|
const before = one('SELECT * FROM contacts WHERE id = ?', [id]);
|
||||||
if (!before) return false;
|
if (!before || (companyId && before.entity_id !== companyId)) return false;
|
||||||
run('DELETE FROM contacts WHERE id = ?', [id]);
|
run('DELETE FROM contacts WHERE id = ?', [id]);
|
||||||
audit(userId, 'delete', 'contact', id, before, null);
|
audit(userId, 'delete', 'contact', id, before, null);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addNote(contactId, bodyText, userId) {
|
function addNote(contactId, bodyText, userId, companyId) {
|
||||||
if (!one('SELECT id FROM contacts WHERE id = ?', [contactId])) return null;
|
if (!one('SELECT id FROM contacts WHERE id = ? AND (? IS NULL OR entity_id = ?)', [contactId, companyId || null, companyId || null])) return null;
|
||||||
const result = run('INSERT INTO contact_notes (contact_id, body, created_by, created_at) VALUES (?, ?, ?, ?)', [contactId, bodyText, userId, now()]);
|
const result = run('INSERT INTO contact_notes (contact_id, body, created_by, created_at) VALUES (?, ?, ?, ?)', [contactId, bodyText, userId, now()]);
|
||||||
audit(userId, 'create', 'contact_note', result.lastInsertRowid, null, { contact_id: contactId });
|
audit(userId, 'create', 'contact_note', result.lastInsertRowid, null, { contact_id: contactId });
|
||||||
return one('SELECT n.id, n.body, n.created_at, u.name AS author FROM contact_notes n LEFT JOIN users u ON u.id = n.created_by WHERE n.id = ?', [result.lastInsertRowid]);
|
return one('SELECT n.id, n.body, n.created_at, u.name AS author FROM contact_notes n LEFT JOIN users u ON u.id = n.created_by WHERE n.id = ?', [result.lastInsertRowid]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteNote(contactId, noteId, userId) {
|
function deleteNote(contactId, noteId, userId, companyId) {
|
||||||
|
if (companyId && !one('SELECT id FROM contacts WHERE id = ? AND entity_id = ?', [contactId, companyId])) return false;
|
||||||
const result = run('DELETE FROM contact_notes WHERE id = ? AND contact_id = ?', [noteId, contactId]);
|
const result = run('DELETE FROM contact_notes WHERE id = ? AND contact_id = ?', [noteId, contactId]);
|
||||||
if (!result.changes) return false;
|
if (!result.changes) return false;
|
||||||
audit(userId, 'delete', 'contact_note', noteId, null, { contact_id: contactId });
|
audit(userId, 'delete', 'contact_note', noteId, null, { contact_id: contactId });
|
||||||
@@ -152,10 +155,12 @@ function upsertWorkdayContact(worker) {
|
|||||||
);
|
);
|
||||||
return existing.id;
|
return existing.id;
|
||||||
}
|
}
|
||||||
|
// Workday sync is global (no request company context); new workers land in the default company.
|
||||||
|
const defaultCompany = one("SELECT id FROM entities WHERE status = 'active' ORDER BY id LIMIT 1");
|
||||||
const cols = Object.keys(fields);
|
const cols = Object.keys(fields);
|
||||||
const result = run(
|
const result = run(
|
||||||
`INSERT INTO contacts (${cols.join(', ')}, last_synced_at, created_at, updated_at) VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
`INSERT INTO contacts (entity_id, ${cols.join(', ')}, last_synced_at, created_at, updated_at) VALUES (?, ${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||||
[...cols.map((c) => fields[c]), ts, ts, ts]
|
[defaultCompany ? defaultCompany.id : null, ...cols.map((c) => fields[c]), ts, ts, ts]
|
||||||
);
|
);
|
||||||
return result.lastInsertRowid;
|
return result.lastInsertRowid;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ async function rowsFromImport(format, buffer) {
|
|||||||
return Papa.parse(text, { header: true, skipEmptyLines: true }).data;
|
return Papa.parse(text, { header: true, skipEmptyLines: true }).data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assetExport(format) {
|
async function assetExport(format, companyId) {
|
||||||
const rows = assetExportRows();
|
const rows = assetExportRows(companyId);
|
||||||
if (format === 'csv') {
|
if (format === 'csv') {
|
||||||
return {
|
return {
|
||||||
contentType: 'text/csv',
|
contentType: 'text/csv',
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ function decorate(row, totals) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listParts(query = {}) {
|
function listParts(query = {}, companyId) {
|
||||||
const rows = all(
|
const rows = all(
|
||||||
`SELECT p.*,
|
`SELECT p.*,
|
||||||
sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last,
|
sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last,
|
||||||
@@ -111,7 +111,8 @@ function listParts(query = {}) {
|
|||||||
SELECT part_id, SUM(quantity) AS on_hand, SUM(reserved) AS reserved, COUNT(*) AS locations
|
SELECT part_id, SUM(quantity) AS on_hand, SUM(reserved) AS reserved, COUNT(*) AS locations
|
||||||
FROM part_stock GROUP BY part_id
|
FROM part_stock GROUP BY part_id
|
||||||
) st ON st.part_id = p.id
|
) st ON st.part_id = p.id
|
||||||
WHERE (? IS NULL OR p.status = ?)
|
WHERE (? IS NULL OR p.entity_id = ?)
|
||||||
|
AND (? IS NULL OR p.status = ?)
|
||||||
AND (? IS NULL OR p.category = ?)
|
AND (? IS NULL OR p.category = ?)
|
||||||
AND (? IS NULL OR (
|
AND (? IS NULL OR (
|
||||||
p.part_number LIKE '%' || ? || '%' OR p.name LIKE '%' || ? || '%' OR
|
p.part_number LIKE '%' || ? || '%' OR p.name LIKE '%' || ? || '%' OR
|
||||||
@@ -120,6 +121,7 @@ function listParts(query = {}) {
|
|||||||
))
|
))
|
||||||
ORDER BY p.name`,
|
ORDER BY p.name`,
|
||||||
[
|
[
|
||||||
|
companyId || null, companyId || null,
|
||||||
query.status || null, query.status || null,
|
query.status || null, query.status || null,
|
||||||
query.category || null, query.category || null,
|
query.category || null, query.category || null,
|
||||||
query.search || null, query.search || null, query.search || null,
|
query.search || null, query.search || null, query.search || null,
|
||||||
@@ -134,11 +136,11 @@ function listParts(query = {}) {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPart(id) {
|
function getPart(id, companyId) {
|
||||||
const row = one(
|
const row = one(
|
||||||
`SELECT p.*, sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last
|
`SELECT p.*, sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last
|
||||||
FROM parts p LEFT JOIN contacts sup ON sup.id = p.supplier_contact_id WHERE p.id = ?`,
|
FROM parts p LEFT JOIN contacts sup ON sup.id = p.supplier_contact_id WHERE p.id = ? AND (? IS NULL OR p.entity_id = ?)`,
|
||||||
[id]
|
[id, companyId || null, companyId || null]
|
||||||
);
|
);
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
const stock = stockRows(id);
|
const stock = stockRows(id);
|
||||||
@@ -163,28 +165,28 @@ function normalize(body) {
|
|||||||
return input;
|
return input;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPart(body, userId) {
|
function createPart(body, userId, companyId) {
|
||||||
const input = normalize(body);
|
const input = normalize(body);
|
||||||
if (!input.part_number) throw Object.assign(new Error('A part number is required'), { status: 400 });
|
if (!input.part_number) throw Object.assign(new Error('A part number is required'), { status: 400 });
|
||||||
if (one('SELECT id FROM parts WHERE part_number = ?', [input.part_number])) {
|
if (one('SELECT id FROM parts WHERE part_number = ? AND entity_id = ?', [input.part_number, companyId])) {
|
||||||
throw Object.assign(new Error('That part number already exists'), { status: 400 });
|
throw Object.assign(new Error('That part number already exists'), { status: 400 });
|
||||||
}
|
}
|
||||||
const ts = now();
|
const ts = now();
|
||||||
const cols = COLUMNS.filter((c) => input[c] !== undefined);
|
const cols = COLUMNS.filter((c) => input[c] !== undefined);
|
||||||
const result = run(
|
const result = run(
|
||||||
`INSERT INTO parts (${cols.join(', ')}, created_by, created_at, updated_at)
|
`INSERT INTO parts (entity_id, ${cols.join(', ')}, created_by, created_at, updated_at)
|
||||||
VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
VALUES (?, ${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||||
[...cols.map((c) => input[c]), userId, ts, ts]
|
[companyId || null, ...cols.map((c) => input[c]), userId, ts, ts]
|
||||||
);
|
);
|
||||||
audit(userId, 'create', 'part', result.lastInsertRowid, null, { part_number: input.part_number });
|
audit(userId, 'create', 'part', result.lastInsertRowid, null, { part_number: input.part_number });
|
||||||
return getPart(result.lastInsertRowid);
|
return getPart(result.lastInsertRowid, companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updatePart(id, body, userId) {
|
function updatePart(id, body, userId, companyId) {
|
||||||
const before = one('SELECT * FROM parts WHERE id = ?', [id]);
|
const before = one('SELECT * FROM parts WHERE id = ?', [id]);
|
||||||
if (!before) return null;
|
if (!before || (companyId && before.entity_id !== companyId)) return null;
|
||||||
const input = normalize({ ...before, ...body });
|
const input = normalize({ ...before, ...body });
|
||||||
if (input.part_number !== before.part_number && one('SELECT id FROM parts WHERE part_number = ? AND id <> ?', [input.part_number, id])) {
|
if (input.part_number !== before.part_number && one('SELECT id FROM parts WHERE part_number = ? AND entity_id = ? AND id <> ?', [input.part_number, before.entity_id, id])) {
|
||||||
throw Object.assign(new Error('That part number already exists'), { status: 400 });
|
throw Object.assign(new Error('That part number already exists'), { status: 400 });
|
||||||
}
|
}
|
||||||
run(
|
run(
|
||||||
@@ -192,12 +194,12 @@ function updatePart(id, body, userId) {
|
|||||||
[...COLUMNS.map((c) => input[c] ?? null), now(), id]
|
[...COLUMNS.map((c) => input[c] ?? null), now(), id]
|
||||||
);
|
);
|
||||||
audit(userId, 'update', 'part', id, before, body);
|
audit(userId, 'update', 'part', id, before, body);
|
||||||
return getPart(id);
|
return getPart(id, companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deletePart(id, userId) {
|
function deletePart(id, userId, companyId) {
|
||||||
const before = one('SELECT * FROM parts WHERE id = ?', [id]);
|
const before = one('SELECT * FROM parts WHERE id = ?', [id]);
|
||||||
if (!before) return false;
|
if (!before || (companyId && before.entity_id !== companyId)) return false;
|
||||||
run('DELETE FROM parts WHERE id = ?', [id]);
|
run('DELETE FROM parts WHERE id = ?', [id]);
|
||||||
audit(userId, 'delete', 'part', id, before, null);
|
audit(userId, 'delete', 'part', id, before, null);
|
||||||
return true;
|
return true;
|
||||||
@@ -205,8 +207,12 @@ function deletePart(id, userId) {
|
|||||||
|
|
||||||
// ---- Stock locations -------------------------------------------------------
|
// ---- Stock locations -------------------------------------------------------
|
||||||
|
|
||||||
function saveStock(partId, body, userId) {
|
function partInCompany(partId, companyId) {
|
||||||
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
|
return Boolean(one('SELECT id FROM parts WHERE id = ? AND (? IS NULL OR entity_id = ?)', [partId, companyId || null, companyId || null]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveStock(partId, body, userId, companyId) {
|
||||||
|
if (!partInCompany(partId, companyId)) return null;
|
||||||
const ts = now();
|
const ts = now();
|
||||||
const location = body.location_contact_id || null;
|
const location = body.location_contact_id || null;
|
||||||
const aisle = body.aisle || null;
|
const aisle = body.aisle || null;
|
||||||
@@ -228,20 +234,21 @@ function saveStock(partId, body, userId) {
|
|||||||
);
|
);
|
||||||
audit(userId, 'create', 'part_stock', result.lastInsertRowid, null, { part_id: partId });
|
audit(userId, 'create', 'part_stock', result.lastInsertRowid, null, { part_id: partId });
|
||||||
}
|
}
|
||||||
return getPart(partId);
|
return getPart(partId, companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteStock(partId, stockId, userId) {
|
function deleteStock(partId, stockId, userId, companyId) {
|
||||||
|
if (!partInCompany(partId, companyId)) return null;
|
||||||
const result = run('DELETE FROM part_stock WHERE id = ? AND part_id = ?', [stockId, partId]);
|
const result = run('DELETE FROM part_stock WHERE id = ? AND part_id = ?', [stockId, partId]);
|
||||||
if (!result.changes) return null;
|
if (!result.changes) return null;
|
||||||
audit(userId, 'delete', 'part_stock', stockId, { part_id: partId }, null);
|
audit(userId, 'delete', 'part_stock', stockId, { part_id: partId }, null);
|
||||||
return getPart(partId);
|
return getPart(partId, companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Stock movements -------------------------------------------------------
|
// ---- Stock movements -------------------------------------------------------
|
||||||
|
|
||||||
function recordTransaction(partId, body, userId) {
|
function recordTransaction(partId, body, userId, companyId) {
|
||||||
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
|
if (!partInCompany(partId, companyId)) return null;
|
||||||
const type = TRANSACTION_TYPES.includes(body.type) ? body.type : 'receive';
|
const type = TRANSACTION_TYPES.includes(body.type) ? body.type : 'receive';
|
||||||
const stock = one('SELECT * FROM part_stock WHERE id = ? AND part_id = ?', [body.stock_id, partId]);
|
const stock = one('SELECT * FROM part_stock WHERE id = ? AND part_id = ?', [body.stock_id, partId]);
|
||||||
if (!stock) throw Object.assign(new Error('Select a stock location for this movement'), { status: 400 });
|
if (!stock) throw Object.assign(new Error('Select a stock location for this movement'), { status: 400 });
|
||||||
@@ -272,28 +279,29 @@ function recordTransaction(partId, body, userId) {
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
audit(userId, 'movement', 'part', partId, null, { type, quantity: qty, stock_id: stock.id });
|
audit(userId, 'movement', 'part', partId, null, { type, quantity: qty, stock_id: stock.id });
|
||||||
return getPart(partId);
|
return getPart(partId, companyId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Photos ----------------------------------------------------------------
|
// ---- Photos ----------------------------------------------------------------
|
||||||
|
|
||||||
function addPhoto(partId, body, userId) {
|
function addPhoto(partId, body, userId, companyId) {
|
||||||
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
|
if (!partInCompany(partId, companyId)) return null;
|
||||||
if (!body.data) throw Object.assign(new Error('Photo data is required'), { status: 400 });
|
if (!body.data) throw Object.assign(new Error('Photo data is required'), { status: 400 });
|
||||||
run(
|
run(
|
||||||
'INSERT INTO part_photos (part_id, name, mime_type, data_base64, created_at) VALUES (?, ?, ?, ?, ?)',
|
'INSERT INTO part_photos (part_id, name, mime_type, data_base64, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||||
[partId, body.name || null, body.mime || body.mime_type || 'image/jpeg', body.data, now()]
|
[partId, body.name || null, body.mime || body.mime_type || 'image/jpeg', body.data, now()]
|
||||||
);
|
);
|
||||||
audit(userId, 'create', 'part_photo', partId, null, { name: body.name });
|
audit(userId, 'create', 'part_photo', partId, null, { name: body.name });
|
||||||
return getPart(partId);
|
return getPart(partId, companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deletePhoto(partId, photoId, userId) {
|
function deletePhoto(partId, photoId, userId, companyId) {
|
||||||
|
if (!partInCompany(partId, companyId)) return null;
|
||||||
const result = run('DELETE FROM part_photos WHERE id = ? AND part_id = ?', [photoId, partId]);
|
const result = run('DELETE FROM part_photos WHERE id = ? AND part_id = ?', [photoId, partId]);
|
||||||
if (!result.changes) return null;
|
if (!result.changes) return null;
|
||||||
audit(userId, 'delete', 'part_photo', photoId, { part_id: partId }, null);
|
audit(userId, 'delete', 'part_photo', photoId, { part_id: partId }, null);
|
||||||
return getPart(partId);
|
return getPart(partId, companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -99,8 +99,9 @@ function planFromRow(row, { withPhotoData = true } = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listPlans() {
|
function listPlans(companyId) {
|
||||||
return all('SELECT * FROM pm_plans ORDER BY name').map((row) => planFromRow(row, { withPhotoData: false }));
|
return all('SELECT * FROM pm_plans WHERE (? IS NULL OR entity_id = ?) ORDER BY name', [companyId || null, companyId || null])
|
||||||
|
.map((row) => planFromRow(row, { withPhotoData: false }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPlan(id) {
|
function getPlan(id) {
|
||||||
@@ -162,13 +163,14 @@ function resolveEstimatedMinutes(body, fallback) {
|
|||||||
return fallback ?? null;
|
return fallback ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPlan(body, userId) {
|
function createPlan(body, userId, companyId) {
|
||||||
const ts = now();
|
const ts = now();
|
||||||
return tx(() => {
|
return tx(() => {
|
||||||
const result = run(
|
const result = run(
|
||||||
`INSERT INTO pm_plans (name, description, category, frequency_value, frequency_unit, estimated_minutes, instructions, lifespan_adjust, created_by, created_at, updated_at)
|
`INSERT INTO pm_plans (entity_id, name, description, category, frequency_value, frequency_unit, estimated_minutes, instructions, lifespan_adjust, created_by, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
|
companyId || null,
|
||||||
body.name || 'PM plan', body.description || null, body.category || null,
|
body.name || 'PM plan', body.description || null, body.category || null,
|
||||||
Number(body.frequency_value) || 1, FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : 'months',
|
Number(body.frequency_value) || 1, FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : 'months',
|
||||||
resolveEstimatedMinutes(body, null), body.instructions || null, body.lifespan_adjust ? 1 : 0,
|
resolveEstimatedMinutes(body, null), body.instructions || null, body.lifespan_adjust ? 1 : 0,
|
||||||
@@ -609,12 +611,14 @@ function runScheduledRecompute() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Maintenance-cost "ledger" for the PM book: actual PM spend per asset for a year.
|
// Maintenance-cost "ledger" for the PM book: actual PM spend per asset for a year.
|
||||||
function pmBookLedger(year) {
|
function pmBookLedger(year, companyId) {
|
||||||
const rows = all(
|
const rows = all(
|
||||||
`SELECT c.cost, c.completed_at, a.asset_id, a.description
|
`SELECT c.cost, c.completed_at, a.asset_id, a.description
|
||||||
FROM pm_completions c
|
FROM pm_completions c
|
||||||
JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
|
JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
|
||||||
JOIN assets a ON a.id = ap.asset_id`
|
JOIN assets a ON a.id = ap.asset_id
|
||||||
|
WHERE (? IS NULL OR a.entity_id = ?)`,
|
||||||
|
[companyId || null, companyId || null]
|
||||||
);
|
);
|
||||||
const byAsset = {};
|
const byAsset = {};
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const { annualSchedule, money, monthlyFromAnnual } = require('../depreciation');
|
|||||||
const { assetExportRows, assetFromRow } = require('./assets');
|
const { assetExportRows, assetFromRow } = require('./assets');
|
||||||
const { ruleSetForBook } = require('./ruleSets');
|
const { ruleSetForBook } = require('./ruleSets');
|
||||||
|
|
||||||
function depreciationReport(year, bookType) {
|
function depreciationReport(year, bookType, companyId) {
|
||||||
const rules = ruleSetForBook(bookType);
|
const rules = ruleSetForBook(bookType);
|
||||||
const rows = all(`
|
const rows = all(`
|
||||||
SELECT a.*, b.book_type, b.active, b.cost, b.depreciation_method, b.convention, b.useful_life_months AS book_life,
|
SELECT a.*, b.book_type, b.active, b.cost, b.depreciation_method, b.convention, b.useful_life_months AS book_life,
|
||||||
@@ -13,9 +13,9 @@ function depreciationReport(year, bookType) {
|
|||||||
FROM assets a
|
FROM assets a
|
||||||
JOIN asset_books b ON b.asset_id = a.id
|
JOIN asset_books b ON b.asset_id = a.id
|
||||||
LEFT JOIN entities e ON e.id = a.entity_id
|
LEFT JOIN entities e ON e.id = a.entity_id
|
||||||
WHERE b.active = 1 AND b.book_type = ?
|
WHERE b.active = 1 AND b.book_type = ? AND (? IS NULL OR a.entity_id = ?)
|
||||||
ORDER BY a.asset_id
|
ORDER BY a.asset_id
|
||||||
`, [bookType]);
|
`, [bookType, companyId || null, companyId || null]);
|
||||||
|
|
||||||
const reportRows = rows.map((row) => {
|
const reportRows = rows.map((row) => {
|
||||||
const asset = assetFromRow(row);
|
const asset = assetFromRow(row);
|
||||||
@@ -54,9 +54,9 @@ function depreciationReport(year, bookType) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function monthlyDepreciationReport(year, bookType) {
|
function monthlyDepreciationReport(year, bookType, companyId) {
|
||||||
const rules = ruleSetForBook(bookType);
|
const rules = ruleSetForBook(bookType);
|
||||||
const assets = all('SELECT * FROM assets ORDER BY asset_id').map(assetFromRow);
|
const assets = all('SELECT * FROM assets WHERE (? IS NULL OR entity_id = ?) ORDER BY asset_id', [companyId || null, companyId || null]).map(assetFromRow);
|
||||||
const rows = assets.flatMap((asset) => {
|
const rows = assets.flatMap((asset) => {
|
||||||
const book = one('SELECT * FROM asset_books WHERE asset_id = ? AND book_type = ? AND active = 1', [asset.id, bookType]);
|
const book = one('SELECT * FROM asset_books WHERE asset_id = ? AND book_type = ? AND active = 1', [asset.id, bookType]);
|
||||||
if (!book) return [];
|
if (!book) return [];
|
||||||
@@ -72,8 +72,8 @@ function monthlyDepreciationReport(year, bookType) {
|
|||||||
return { year, book: bookType, months };
|
return { year, book: bookType, months };
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeDepreciationPdf(res, year, book) {
|
function writeDepreciationPdf(res, year, book, companyId) {
|
||||||
const rows = assetExportRows();
|
const rows = assetExportRows(companyId);
|
||||||
const doc = new PDFDocument({ margin: 36, size: 'LETTER' });
|
const doc = new PDFDocument({ margin: 36, size: 'LETTER' });
|
||||||
res.setHeader('Content-Type', 'application/pdf');
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="deprecore-${book}-${year}.pdf"`);
|
res.setHeader('Content-Disposition', `attachment; filename="deprecore-${book}-${year}.pdf"`);
|
||||||
|
|||||||
@@ -1630,6 +1630,142 @@ async function main() {
|
|||||||
const csvText = await csvResp.text();
|
const csvText = await csvResp.text();
|
||||||
if (!csvText.startsWith('id,timestamp,actor,action')) throw new Error('Audit CSV header was malformed');
|
if (!csvText.startsWith('id,timestamp,actor,action')) throw new Error('Audit CSV header was malformed');
|
||||||
|
|
||||||
|
// ---- Multi-company scoping -------------------------------------------------
|
||||||
|
const companyHeaders = (id) => ({ ...auth, 'X-Company-Id': String(id) });
|
||||||
|
// Company A is the seeded default; create company B.
|
||||||
|
const companyA = (await request('/api/entities', { headers: auth })).entities[0].id;
|
||||||
|
const companyB = (await request('/api/entities', {
|
||||||
|
method: 'POST', headers: auth, body: JSON.stringify({ name: `Company B ${suffix}`, base_currency: 'USD' })
|
||||||
|
})).entity.id;
|
||||||
|
if (!companyB || companyB === companyA) throw new Error('Second company was not created');
|
||||||
|
|
||||||
|
// A new company auto-seeds its own book set (distinct ids from company A's).
|
||||||
|
const booksA = (await request('/api/books', { headers: companyHeaders(companyA) })).books;
|
||||||
|
const booksB = (await request('/api/books', { headers: companyHeaders(companyB) })).books;
|
||||||
|
if (!booksB.some((b) => b.code === 'GAAP')) throw new Error('New company did not get a seeded book set');
|
||||||
|
if (booksA.find((b) => b.code === 'GAAP').id === booksB.find((b) => b.code === 'GAAP').id) {
|
||||||
|
throw new Error('Company B GAAP book is not a distinct per-company record');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an asset under each company via the X-Company-Id header.
|
||||||
|
const assetB = (await request('/api/assets', {
|
||||||
|
method: 'POST', headers: companyHeaders(companyB),
|
||||||
|
body: JSON.stringify({ asset_id: `B-${suffix}`, description: 'Company B asset', category: 'Computer Equipment', acquisition_cost: 5000, in_service_date: '2024-01-01' })
|
||||||
|
})).asset;
|
||||||
|
const assetAonly = (await request('/api/assets', {
|
||||||
|
method: 'POST', headers: companyHeaders(companyA),
|
||||||
|
body: JSON.stringify({ asset_id: `A-${suffix}`, description: 'Company A only', category: 'Computer Equipment', acquisition_cost: 1000, in_service_date: '2024-01-01' })
|
||||||
|
})).asset;
|
||||||
|
|
||||||
|
// Listings are scoped to the active company.
|
||||||
|
const listA = (await request('/api/assets', { headers: companyHeaders(companyA) })).assets;
|
||||||
|
const listB = (await request('/api/assets', { headers: companyHeaders(companyB) })).assets;
|
||||||
|
if (listB.some((a) => a.id === assetAonly.id)) throw new Error('Company B sees company A assets');
|
||||||
|
if (!listB.some((a) => a.id === assetB.id)) throw new Error('Company B is missing its own asset');
|
||||||
|
if (listA.some((a) => a.id === assetB.id)) throw new Error('Company A sees company B assets');
|
||||||
|
|
||||||
|
// Cross-company asset access is blocked (company A cannot fetch company B's asset).
|
||||||
|
const crossFetch = await fetch(`${baseUrl}/api/assets/${assetB.id}`, { headers: companyHeaders(companyA) });
|
||||||
|
if (crossFetch.status !== 404) throw new Error('Cross-company asset fetch should 404');
|
||||||
|
|
||||||
|
// PM plans are per-company.
|
||||||
|
await request('/api/pm-plans', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ name: `B Plan ${suffix}`, frequency_value: 3, frequency_unit: 'months' }) });
|
||||||
|
const plansB = (await request('/api/pm-plans', { headers: companyHeaders(companyB) })).plans;
|
||||||
|
const plansA = (await request('/api/pm-plans', { headers: companyHeaders(companyA) })).plans;
|
||||||
|
if (!plansB.some((p) => p.name === `B Plan ${suffix}`)) throw new Error('Company B PM plan missing');
|
||||||
|
if (plansA.some((p) => p.name === `B Plan ${suffix}`)) throw new Error('Company A sees company B PM plan');
|
||||||
|
|
||||||
|
// Dashboards differ per company.
|
||||||
|
const dashA = (await request('/api/dashboard', { headers: companyHeaders(companyA) })).stats;
|
||||||
|
const dashB = (await request('/api/dashboard', { headers: companyHeaders(companyB) })).stats;
|
||||||
|
if (dashB.asset_count !== 1) throw new Error(`Company B dashboard should count exactly its 1 asset (got ${dashB.asset_count})`);
|
||||||
|
if (dashA.asset_count <= dashB.asset_count) throw new Error('Company A (seeded data) should have more assets than the new company B');
|
||||||
|
|
||||||
|
// Dispose company B: it leaves the active list but its data is preserved; then restore it.
|
||||||
|
await request(`/api/entities/${companyB}/dispose`, { method: 'POST', headers: auth, body: JSON.stringify({ reason: 'smoke test' }) });
|
||||||
|
const activeAfter = (await request('/api/entities', { headers: auth })).entities;
|
||||||
|
if (activeAfter.some((e) => e.id === companyB)) throw new Error('Disposed company still appears in the active list');
|
||||||
|
const allAfter = (await request('/api/entities?includeDisposed=1', { headers: auth })).entities;
|
||||||
|
const disposedRow = allAfter.find((e) => e.id === companyB);
|
||||||
|
if (!disposedRow || disposedRow.status !== 'disposed') throw new Error('Disposed company missing from includeDisposed list');
|
||||||
|
await request(`/api/entities/${companyB}/restore`, { method: 'POST', headers: auth, body: JSON.stringify({}) });
|
||||||
|
const restoredAssets = (await request('/api/assets', { headers: companyHeaders(companyB) })).assets;
|
||||||
|
if (!restoredAssets.some((a) => a.id === assetB.id)) throw new Error('Company B asset was lost across dispose/restore');
|
||||||
|
|
||||||
|
// ---- Phase 2: reference data / contacts / templates are per-company ---------
|
||||||
|
// A new company auto-seeds the standard categories.
|
||||||
|
const seededCatsB = (await request('/api/asset-categories', { headers: companyHeaders(companyB) })).categories;
|
||||||
|
if (!seededCatsB.some((c) => c.name === 'Computer Equipment')) throw new Error('New company did not auto-seed reference categories');
|
||||||
|
|
||||||
|
await request('/api/asset-categories', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ name: `B Category ${suffix}` }) });
|
||||||
|
const catsA = (await request('/api/asset-categories', { headers: companyHeaders(companyA) })).categories;
|
||||||
|
const catsB = (await request('/api/asset-categories', { headers: companyHeaders(companyB) })).categories;
|
||||||
|
if (!catsB.some((c) => c.name === `B Category ${suffix}`)) throw new Error('Company B category missing');
|
||||||
|
if (catsA.some((c) => c.name === `B Category ${suffix}`)) throw new Error('Company A sees company B category');
|
||||||
|
|
||||||
|
// Contacts are per-company.
|
||||||
|
await request('/api/contacts', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ type: 'vendor', organization: `B Vendor ${suffix}` }) });
|
||||||
|
const contactsA = (await request('/api/contacts', { headers: companyHeaders(companyA) })).contacts;
|
||||||
|
const contactsB = (await request('/api/contacts', { headers: companyHeaders(companyB) })).contacts;
|
||||||
|
if (!contactsB.some((c) => c.organization === `B Vendor ${suffix}`)) throw new Error('Company B contact missing');
|
||||||
|
if (contactsA.some((c) => c.organization === `B Vendor ${suffix}`)) throw new Error('Company A sees company B contact');
|
||||||
|
|
||||||
|
// Templates are per-company.
|
||||||
|
await request('/api/templates', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ name: `B Template ${suffix}`, defaults: {}, custom_fields: [] }) });
|
||||||
|
const tplA = (await request('/api/templates', { headers: companyHeaders(companyA) })).templates;
|
||||||
|
const tplB = (await request('/api/templates', { headers: companyHeaders(companyB) })).templates;
|
||||||
|
if (!tplB.some((t) => t.name === `B Template ${suffix}`)) throw new Error('Company B template missing');
|
||||||
|
if (tplA.some((t) => t.name === `B Template ${suffix}`)) throw new Error('Company A sees company B template');
|
||||||
|
|
||||||
|
// ---- Per-company user access control ---------------------------------------
|
||||||
|
const limitedEmail = `limited-${suffix}@example.com`;
|
||||||
|
const limitedPassword = 'Acc3ssControl!2026X';
|
||||||
|
await request('/api/users', {
|
||||||
|
method: 'POST', headers: companyHeaders(companyA),
|
||||||
|
body: JSON.stringify({ name: 'Limited Person', email: limitedEmail, password: limitedPassword, role: 'viewer', company_ids: [companyA] })
|
||||||
|
});
|
||||||
|
const limitedLogin = await request('/api/auth/login', { method: 'POST', body: JSON.stringify({ email: limitedEmail, password: limitedPassword }) });
|
||||||
|
const limitedAuth = { Authorization: `Bearer ${limitedLogin.token}` };
|
||||||
|
|
||||||
|
// The switcher only lists company A for this user.
|
||||||
|
const limitedCompanies = (await request('/api/entities', { headers: limitedAuth })).entities;
|
||||||
|
if (limitedCompanies.length !== 1 || limitedCompanies[0].id !== companyA) throw new Error('Limited user can see companies beyond their assignment');
|
||||||
|
|
||||||
|
// Forging X-Company-Id to company B falls back to company A (no leak of B's data).
|
||||||
|
const limitedBView = (await request('/api/assets', { headers: { ...limitedAuth, 'X-Company-Id': String(companyB) } })).assets;
|
||||||
|
if (limitedBView.some((a) => a.id === assetB.id)) throw new Error('Limited user accessed another company by forging the header');
|
||||||
|
if (!limitedBView.some((a) => a.id === assetAonly.id)) throw new Error('Limited user did not fall back to their permitted company');
|
||||||
|
|
||||||
|
// Admins still see every active company.
|
||||||
|
const adminCompanies = (await request('/api/entities', { headers: auth })).entities;
|
||||||
|
if (!adminCompanies.some((e) => e.id === companyA) || !adminCompanies.some((e) => e.id === companyB)) {
|
||||||
|
throw new Error('Admin should see all active companies');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Parts + assignments are per-company -----------------------------------
|
||||||
|
// The same part number is allowed in different companies (per-company uniqueness).
|
||||||
|
const partB = (await request('/api/parts', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ part_number: `PN-${suffix}`, name: 'B Part' }) })).part;
|
||||||
|
const partA = (await request('/api/parts', { method: 'POST', headers: companyHeaders(companyA), body: JSON.stringify({ part_number: `PN-${suffix}`, name: 'A Part' }) })).part;
|
||||||
|
if (!partA || !partB || partA.id === partB.id) throw new Error('Per-company part-number uniqueness failed');
|
||||||
|
const partsA = (await request('/api/parts', { headers: companyHeaders(companyA) })).parts;
|
||||||
|
const partsB = (await request('/api/parts', { headers: companyHeaders(companyB) })).parts;
|
||||||
|
if (partsA.some((p) => p.id === partB.id)) throw new Error('Company A sees company B part');
|
||||||
|
if (!partsB.some((p) => p.id === partB.id)) throw new Error('Company B part missing');
|
||||||
|
const crossPart = await fetch(`${baseUrl}/api/parts/${partB.id}`, { headers: companyHeaders(companyA) });
|
||||||
|
if (crossPart.status !== 404) throw new Error('Cross-company part fetch should 404');
|
||||||
|
|
||||||
|
// Assignments scope to the assigned asset's company.
|
||||||
|
await request('/api/assignments', { method: 'POST', headers: companyHeaders(companyB), body: JSON.stringify({ asset_id: assetB.id }) });
|
||||||
|
const assignsB = (await request('/api/assignments', { headers: companyHeaders(companyB) })).assignments;
|
||||||
|
const assignsA = (await request('/api/assignments', { headers: companyHeaders(companyA) })).assignments;
|
||||||
|
if (!assignsB.some((a) => a.asset_id === assetB.id)) throw new Error('Company B assignment missing');
|
||||||
|
if (assignsA.some((a) => a.asset_id === assetB.id)) throw new Error('Company A sees company B assignment');
|
||||||
|
// Assigning another company's asset is rejected.
|
||||||
|
const crossAssign = await fetch(`${baseUrl}/api/assignments`, {
|
||||||
|
method: 'POST', headers: { ...companyHeaders(companyA), 'Content-Type': 'application/json' }, body: JSON.stringify({ asset_id: assetB.id })
|
||||||
|
});
|
||||||
|
if (crossAssign.status !== 404) throw new Error('Assigning an asset from another company should 404');
|
||||||
|
|
||||||
console.log('Smoke test passed');
|
console.log('Smoke test passed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
36
src/App.vue
36
src/App.vue
@@ -72,6 +72,9 @@
|
|||||||
:theme-items="themeItems"
|
:theme-items="themeItems"
|
||||||
:title="currentTitle"
|
:title="currentTitle"
|
||||||
:user="user"
|
:user="user"
|
||||||
|
:companies="entities"
|
||||||
|
:active-company-id="activeCompanyId"
|
||||||
|
@change-company="changeCompany"
|
||||||
@change-password="changePasswordDialog = true"
|
@change-password="changePasswordDialog = true"
|
||||||
@logout="logout"
|
@logout="logout"
|
||||||
@open-help="helpDialog = true"
|
@open-help="helpDialog = true"
|
||||||
@@ -83,7 +86,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</TopNav>
|
</TopNav>
|
||||||
|
|
||||||
<v-main id="main-content" tabindex="-1">
|
<v-main id="main-content" :key="`company-${activeCompanyId}`" tabindex="-1">
|
||||||
<DashboardView
|
<DashboardView
|
||||||
v-if="activeView === 'dashboard'"
|
v-if="activeView === 'dashboard'"
|
||||||
:category-chart="categoryChart"
|
:category-chart="categoryChart"
|
||||||
@@ -185,12 +188,14 @@
|
|||||||
:team-saving="teamSaving"
|
:team-saving="teamSaving"
|
||||||
:team-notice="teamNotice"
|
:team-notice="teamNotice"
|
||||||
:teams="teams"
|
:teams="teams"
|
||||||
|
:companies="entities"
|
||||||
:user-saving="userSaving"
|
:user-saving="userSaving"
|
||||||
:users="users"
|
:users="users"
|
||||||
:workday-connection="workdayConnection"
|
:workday-connection="workdayConnection"
|
||||||
:workday-saving="workdaySaving"
|
:workday-saving="workdaySaving"
|
||||||
:workday-syncing="workdaySyncing"
|
:workday-syncing="workdaySyncing"
|
||||||
@categories-updated="loadReferences"
|
@categories-updated="loadReferences"
|
||||||
|
@companies-updated="loadCompanies"
|
||||||
@create-team="createTeam"
|
@create-team="createTeam"
|
||||||
@update-team="updateTeam"
|
@update-team="updateTeam"
|
||||||
@delete-team="deleteTeam"
|
@delete-team="deleteTeam"
|
||||||
@@ -318,7 +323,7 @@ import MassEditDialog from './components/MassEditDialog.vue';
|
|||||||
import PmCompletionDialog from './components/PmCompletionDialog.vue';
|
import PmCompletionDialog from './components/PmCompletionDialog.vue';
|
||||||
import TopNav from './components/TopNav.vue';
|
import TopNav from './components/TopNav.vue';
|
||||||
import UserManual from './components/UserManual.vue';
|
import UserManual from './components/UserManual.vue';
|
||||||
import { apiRequest, loginRequest, saveBlob } from './services/api';
|
import { apiRequest, loginRequest, saveBlob, setActiveCompany } from './services/api';
|
||||||
import { blankAsset, defaultValueForField, makeDefaultBooks, mergeBooks, normalizeCustomFields } from './utils/assets';
|
import { blankAsset, defaultValueForField, makeDefaultBooks, mergeBooks, normalizeCustomFields } from './utils/assets';
|
||||||
import { currency, shortDate } from './utils/format';
|
import { currency, shortDate } from './utils/format';
|
||||||
import { bookItems, customFieldTypes, darkThemes, fontScaleMap, navItems, statusItems, themeItems } from './constants';
|
import { bookItems, customFieldTypes, darkThemes, fontScaleMap, navItems, statusItems, themeItems } from './constants';
|
||||||
@@ -393,6 +398,7 @@ export default {
|
|||||||
selectedLeaseId: null,
|
selectedLeaseId: null,
|
||||||
employees: [],
|
employees: [],
|
||||||
entities: [],
|
entities: [],
|
||||||
|
activeCompanyId: null,
|
||||||
error: '',
|
error: '',
|
||||||
helpDialog: false,
|
helpDialog: false,
|
||||||
changePasswordDialog: false,
|
changePasswordDialog: false,
|
||||||
@@ -554,6 +560,9 @@ export default {
|
|||||||
entityOptions() {
|
entityOptions() {
|
||||||
return this.entities.map((entity) => ({ title: entity.name, value: entity.id }));
|
return this.entities.map((entity) => ({ title: entity.name, value: entity.id }));
|
||||||
},
|
},
|
||||||
|
activeCompany() {
|
||||||
|
return this.entities.find((entity) => entity.id === this.activeCompanyId) || null;
|
||||||
|
},
|
||||||
activeBookItems() {
|
activeBookItems() {
|
||||||
return this.bookCodes.length ? this.bookCodes : this.bookItems;
|
return this.bookCodes.length ? this.bookCodes : this.bookItems;
|
||||||
},
|
},
|
||||||
@@ -1093,12 +1102,13 @@ export default {
|
|||||||
this.dashboard = await (await this.api('/api/dashboard')).json();
|
this.dashboard = await (await this.api('/api/dashboard')).json();
|
||||||
},
|
},
|
||||||
async loadInitial() {
|
async loadInitial() {
|
||||||
|
// Resolve the active company first so every other request is scoped to it.
|
||||||
|
await this.loadCompanies();
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.loadProfile(),
|
this.loadProfile(),
|
||||||
this.loadDashboard(),
|
this.loadDashboard(),
|
||||||
this.loadAssets(),
|
this.loadAssets(),
|
||||||
this.loadAssignments(),
|
this.loadAssignments(),
|
||||||
this.api('/api/entities').then((r) => r.json()).then((data) => { this.entities = data.entities; }),
|
|
||||||
this.api('/api/references').then((r) => r.json()).then((data) => { this.references = data.references; }),
|
this.api('/api/references').then((r) => r.json()).then((data) => { this.references = data.references; }),
|
||||||
this.api('/api/templates').then((r) => r.json()).then((data) => { this.templates = data.templates; }),
|
this.api('/api/templates').then((r) => r.json()).then((data) => { this.templates = data.templates; }),
|
||||||
this.api('/api/tax-rules').then((r) => r.json()).then((data) => { this.taxRules = data.ruleSets; }),
|
this.api('/api/tax-rules').then((r) => r.json()).then((data) => { this.taxRules = data.ruleSets; }),
|
||||||
@@ -1108,6 +1118,24 @@ export default {
|
|||||||
this.loadDepreciationZones()
|
this.loadDepreciationZones()
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
// Load the active companies and pick the active one (stored choice if still valid, else the first).
|
||||||
|
async loadCompanies() {
|
||||||
|
const data = await (await this.api('/api/entities')).json();
|
||||||
|
this.entities = data.entities || [];
|
||||||
|
const stored = Number(localStorage.getItem('deprecore.activeCompany'));
|
||||||
|
const valid = this.entities.find((e) => e.id === stored);
|
||||||
|
this.activeCompanyId = valid ? valid.id : (this.entities[0]?.id || null);
|
||||||
|
localStorage.setItem('deprecore.activeCompany', String(this.activeCompanyId || ''));
|
||||||
|
setActiveCompany(this.activeCompanyId);
|
||||||
|
},
|
||||||
|
// Switch the active company: persist it, update the request header, and reload all scoped data.
|
||||||
|
async changeCompany(id) {
|
||||||
|
if (!id || id === this.activeCompanyId) return;
|
||||||
|
this.activeCompanyId = id;
|
||||||
|
localStorage.setItem('deprecore.activeCompany', String(id));
|
||||||
|
setActiveCompany(id);
|
||||||
|
await this.loadInitial();
|
||||||
|
},
|
||||||
async loadAssignments() {
|
async loadAssignments() {
|
||||||
const [employees, assignments, workday] = await Promise.all([
|
const [employees, assignments, workday] = await Promise.all([
|
||||||
this.api('/api/employees').then((r) => r.json()),
|
this.api('/api/employees').then((r) => r.json()),
|
||||||
@@ -1210,7 +1238,7 @@ export default {
|
|||||||
async newAsset() {
|
async newAsset() {
|
||||||
await this.loadBookCodes();
|
await this.loadBookCodes();
|
||||||
this.assetForm = blankAsset();
|
this.assetForm = blankAsset();
|
||||||
this.assetForm.entity_id = this.entities[0]?.id || null;
|
this.assetForm.entity_id = this.activeCompanyId || this.entities[0]?.id || null;
|
||||||
this.assetForm.books = makeDefaultBooks(this.assetForm, this.bookCodes);
|
this.assetForm.books = makeDefaultBooks(this.assetForm, this.bookCodes);
|
||||||
this.selectedTemplateId = null;
|
this.selectedTemplateId = null;
|
||||||
this.drawerError = '';
|
this.drawerError = '';
|
||||||
|
|||||||
194
src/components/CompanySettings.vue
Normal file
194
src/components/CompanySettings.vue
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Companies</h2>
|
||||||
|
<p class="quiet text-body-2">
|
||||||
|
Each company keeps its own assets, books, general ledger, PM plans, and alerts. Switch the active company
|
||||||
|
from the top bar. Disposing a company archives it (history is preserved) and removes it from the switcher.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-domain-plus" @click="openNew">New company</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="companies"
|
||||||
|
row-key="id"
|
||||||
|
:page-size="10"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter companies"
|
||||||
|
empty-text="No companies."
|
||||||
|
>
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.name }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-status="{ row }">
|
||||||
|
<v-chip size="x-small" :color="row.status === 'active' ? 'success' : 'error'" variant="tonal">{{ row.status }}</v-chip>
|
||||||
|
</template>
|
||||||
|
<template #cell-base_currency="{ row }">{{ row.base_currency || 'USD' }}</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
|
||||||
|
<v-btn v-if="row.status === 'active'" icon="mdi-archive-arrow-down-outline" size="small" variant="text" color="error" title="Dispose / archive" @click="confirmDispose(row)" />
|
||||||
|
<v-btn v-else icon="mdi-restore" size="small" variant="text" color="success" title="Restore" @click="restore(row)" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
|
||||||
|
<!-- Create / edit -->
|
||||||
|
<v-dialog v-model="dialog" max-width="560">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ form.id ? 'Edit company' : 'New company' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field v-model="form.name" class="full" label="Company name *" autofocus />
|
||||||
|
<v-text-field v-model="form.legal_name" class="full" label="Legal name" />
|
||||||
|
<v-text-field v-model="form.tax_id" label="Tax ID / EIN" />
|
||||||
|
<v-text-field v-model="form.base_currency" label="Base currency" placeholder="USD" />
|
||||||
|
<v-select v-model.number="form.fiscal_year_end_month" :items="monthItems" item-title="title" item-value="value" label="Fiscal year-end month" />
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="!form.id" type="info" variant="tonal" density="compact" class="mt-2">
|
||||||
|
A new company starts with the standard book set (GAAP, Federal, State, AMT, ACE, User, PM).
|
||||||
|
</v-alert>
|
||||||
|
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!form.name.trim()" @click="save">Save company</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Dispose confirmation -->
|
||||||
|
<v-dialog v-model="disposeDialog" max-width="500">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-error">Dispose company</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="mb-3">Archive <strong>“{{ disposeTarget?.name }}”</strong>?</p>
|
||||||
|
<v-alert type="info" variant="tonal" density="comfortable" class="mb-3">
|
||||||
|
Its assets, books, GL, PM plans, and alerts are <strong>kept</strong> for audit and reporting, but the company
|
||||||
|
is hidden from the switcher and can no longer be selected for new work. You can restore it later.
|
||||||
|
</v-alert>
|
||||||
|
<v-text-field v-model="disposeReason" label="Reason (optional)" density="compact" />
|
||||||
|
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="disposeDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="error" prepend-icon="mdi-archive-arrow-down-outline" @click="performDispose">Dispose company</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DataTable from './DataTable.vue';
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
|
||||||
|
function blankCompany() {
|
||||||
|
return { id: null, name: '', legal_name: '', tax_id: '', base_currency: 'USD', fiscal_year_end_month: 12 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { DataTable },
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
emits: ['updated'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
companies: [],
|
||||||
|
dialog: false,
|
||||||
|
disposeDialog: false,
|
||||||
|
disposeTarget: null,
|
||||||
|
disposeReason: '',
|
||||||
|
form: blankCompany(),
|
||||||
|
saving: false,
|
||||||
|
error: '',
|
||||||
|
dialogError: '',
|
||||||
|
monthItems: Array.from({ length: 12 }, (_, i) => ({ title: new Date(2000, i, 1).toLocaleString('en-US', { month: 'long' }), value: i + 1 })),
|
||||||
|
columns: [
|
||||||
|
{ key: 'name', label: 'Company' },
|
||||||
|
{ key: 'legal_name', label: 'Legal name' },
|
||||||
|
{ key: 'tax_id', label: 'Tax ID' },
|
||||||
|
{ key: 'base_currency', label: 'Currency' },
|
||||||
|
{ key: 'status', label: 'Status', sortable: false }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/entities?includeDisposed=1')).json();
|
||||||
|
this.companies = data.entities;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openNew() {
|
||||||
|
this.form = blankCompany();
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
openEdit(row) {
|
||||||
|
this.form = { id: row.id, name: row.name, legal_name: row.legal_name || '', tax_id: row.tax_id || '', base_currency: row.base_currency || 'USD', fiscal_year_end_month: row.fiscal_year_end_month || 12 };
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
this.saving = true;
|
||||||
|
this.dialogError = '';
|
||||||
|
try {
|
||||||
|
const method = this.form.id ? 'PUT' : 'POST';
|
||||||
|
const url = this.form.id ? `/api/entities/${this.form.id}` : '/api/entities';
|
||||||
|
await this.api(url, { method, body: JSON.stringify(this.form) });
|
||||||
|
this.dialog = false;
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.dialogError = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmDispose(row) {
|
||||||
|
this.disposeTarget = row;
|
||||||
|
this.disposeReason = '';
|
||||||
|
this.dialogError = '';
|
||||||
|
this.disposeDialog = true;
|
||||||
|
},
|
||||||
|
async performDispose() {
|
||||||
|
this.dialogError = '';
|
||||||
|
try {
|
||||||
|
await this.api(`/api/entities/${this.disposeTarget.id}/dispose`, { method: 'POST', body: JSON.stringify({ reason: this.disposeReason || null }) });
|
||||||
|
this.disposeDialog = false;
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.dialogError = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async restore(row) {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
await this.api(`/api/entities/${row.id}/restore`, { method: 'POST', body: JSON.stringify({}) });
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -6,6 +6,23 @@
|
|||||||
<div class="text-h6 font-weight-bold">{{ title }}</div>
|
<div class="text-h6 font-weight-bold">{{ title }}</div>
|
||||||
</div>
|
</div>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
|
<v-select
|
||||||
|
v-if="companies.length > 1"
|
||||||
|
:model-value="activeCompanyId"
|
||||||
|
:items="companyItems"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
density="compact"
|
||||||
|
variant="solo-filled"
|
||||||
|
flat
|
||||||
|
hide-details
|
||||||
|
prepend-inner-icon="mdi-domain"
|
||||||
|
class="company-switch mr-2"
|
||||||
|
style="max-width:220px"
|
||||||
|
aria-label="Active company"
|
||||||
|
@update:model-value="$emit('change-company', $event)"
|
||||||
|
/>
|
||||||
|
<v-chip v-else-if="companies.length === 1" size="small" variant="tonal" prepend-icon="mdi-domain" class="mr-2">{{ companies[0].name }}</v-chip>
|
||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
<v-btn icon="mdi-help-circle-outline" variant="text" class="mr-1" title="User guide" aria-label="Open user guide" @click="$emit('open-help')" />
|
<v-btn icon="mdi-help-circle-outline" variant="text" class="mr-1" title="User guide" aria-label="Open user guide" @click="$emit('open-help')" />
|
||||||
<v-menu v-model="profileMenu" :close-on-content-click="false" location="bottom end">
|
<v-menu v-model="profileMenu" :close-on-content-click="false" location="bottom end">
|
||||||
@@ -117,9 +134,11 @@ export default {
|
|||||||
subtitle: { type: String, required: true },
|
subtitle: { type: String, required: true },
|
||||||
themeItems: { type: Array, required: true },
|
themeItems: { type: Array, required: true },
|
||||||
title: { type: String, required: true },
|
title: { type: String, required: true },
|
||||||
user: { type: Object, required: true }
|
user: { type: Object, required: true },
|
||||||
|
companies: { type: Array, default: () => [] },
|
||||||
|
activeCompanyId: { type: [Number, String], default: null }
|
||||||
},
|
},
|
||||||
emits: ['change-password', 'logout', 'open-help', 'save-preferences', 'toggle-nav'],
|
emits: ['change-company', 'change-password', 'logout', 'open-help', 'save-preferences', 'toggle-nav'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
accentPresets,
|
accentPresets,
|
||||||
@@ -131,6 +150,9 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
companyItems() {
|
||||||
|
return this.companies.map((c) => ({ title: c.name, value: c.id }));
|
||||||
|
},
|
||||||
initials() {
|
initials() {
|
||||||
return String(this.user?.name || 'MA')
|
return String(this.user?.name || 'MA')
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
|
// The active company id is sent on every request as X-Company-Id so the server scopes data to it.
|
||||||
|
let activeCompanyId = null;
|
||||||
|
export function setActiveCompany(id) {
|
||||||
|
activeCompanyId = id || null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiRequest(path, token, options = {}) {
|
export async function apiRequest(path, token, options = {}) {
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(activeCompanyId ? { 'X-Company-Id': String(activeCompanyId) } : {}),
|
||||||
...(options.headers || {})
|
...(options.headers || {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -172,6 +172,8 @@
|
|||||||
|
|
||||||
<!-- APPLICATION CONFIGURATION -->
|
<!-- APPLICATION CONFIGURATION -->
|
||||||
<template v-if="adminSection === 'admin-config'">
|
<template v-if="adminSection === 'admin-config'">
|
||||||
|
<CompanySettings :token="token" @updated="$emit('companies-updated')" />
|
||||||
|
|
||||||
<v-card class="span-6 panel-pad">
|
<v-card class="span-6 panel-pad">
|
||||||
<h2 class="section-title mb-4">Application settings</h2>
|
<h2 class="section-title mb-4">Application settings</h2>
|
||||||
<v-text-field v-model="localSettings.server_fqdn" label="Server FQDN" />
|
<v-text-field v-model="localSettings.server_fqdn" label="Server FQDN" />
|
||||||
@@ -262,6 +264,19 @@
|
|||||||
<v-text-field v-model="userForm.password" type="password" :label="userForm.id ? 'New password' : 'Password'" :hint="userForm.id ? 'Leave blank to keep the current password' : 'Leave blank to issue a temporary password'" persistent-hint />
|
<v-text-field v-model="userForm.password" type="password" :label="userForm.id ? 'New password' : 'Password'" :hint="userForm.id ? 'Leave blank to keep the current password' : 'Leave blank to issue a temporary password'" persistent-hint />
|
||||||
</div>
|
</div>
|
||||||
<v-switch v-model="userForm.must_change_password" color="primary" density="compact" hide-details class="mt-2" label="Require password change at next sign-in" />
|
<v-switch v-model="userForm.must_change_password" color="primary" density="compact" hide-details class="mt-2" label="Require password change at next sign-in" />
|
||||||
|
<v-select
|
||||||
|
v-model="userForm.company_ids"
|
||||||
|
:items="companyItems"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
label="Company access"
|
||||||
|
multiple
|
||||||
|
chips
|
||||||
|
closable-chips
|
||||||
|
class="mt-3"
|
||||||
|
hint="Companies this user can see and switch between. Administrators always see every company."
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
<v-alert v-if="userForm.role" class="mt-3" density="compact" type="info">
|
<v-alert v-if="userForm.role" class="mt-3" density="compact" type="info">
|
||||||
{{ roleSummary(userForm.role) }}
|
{{ roleSummary(userForm.role) }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
@@ -296,6 +311,7 @@ import AssetClassSettings from '../components/AssetClassSettings.vue';
|
|||||||
import AssetIdTemplateSettings from '../components/AssetIdTemplateSettings.vue';
|
import AssetIdTemplateSettings from '../components/AssetIdTemplateSettings.vue';
|
||||||
import AuditTrailSettings from '../components/AuditTrailSettings.vue';
|
import AuditTrailSettings from '../components/AuditTrailSettings.vue';
|
||||||
import CategorySettings from '../components/CategorySettings.vue';
|
import CategorySettings from '../components/CategorySettings.vue';
|
||||||
|
import CompanySettings from '../components/CompanySettings.vue';
|
||||||
import PasswordPolicySettings from '../components/PasswordPolicySettings.vue';
|
import PasswordPolicySettings from '../components/PasswordPolicySettings.vue';
|
||||||
import DataTable from '../components/DataTable.vue';
|
import DataTable from '../components/DataTable.vue';
|
||||||
import DepartmentSettings from '../components/DepartmentSettings.vue';
|
import DepartmentSettings from '../components/DepartmentSettings.vue';
|
||||||
@@ -310,7 +326,7 @@ import TeamsSettings from '../components/TeamsSettings.vue';
|
|||||||
import WebhookSettings from '../components/WebhookSettings.vue';
|
import WebhookSettings from '../components/WebhookSettings.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { AssetClassSettings, AssetIdTemplateSettings, AuditTrailSettings, CategorySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PasswordPolicySettings, PmCategorySettings, PmSettings, Section179LimitSettings, ServiceNowSettings, TeamsSettings, WebhookSettings },
|
components: { AssetClassSettings, AssetIdTemplateSettings, AuditTrailSettings, CategorySettings, CompanySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PasswordPolicySettings, PmCategorySettings, PmSettings, Section179LimitSettings, ServiceNowSettings, TeamsSettings, WebhookSettings },
|
||||||
props: {
|
props: {
|
||||||
token: { type: String, default: '' },
|
token: { type: String, default: '' },
|
||||||
section: { type: String, default: 'admin-users' },
|
section: { type: String, default: 'admin-users' },
|
||||||
@@ -322,13 +338,14 @@ export default {
|
|||||||
teamSaving: { type: Boolean, default: false },
|
teamSaving: { type: Boolean, default: false },
|
||||||
teams: { type: Array, default: () => [] },
|
teams: { type: Array, default: () => [] },
|
||||||
teamNotice: { type: String, default: '' },
|
teamNotice: { type: String, default: '' },
|
||||||
|
companies: { type: Array, default: () => [] },
|
||||||
userSaving: { type: Boolean, default: false },
|
userSaving: { type: Boolean, default: false },
|
||||||
users: { type: Array, default: () => [] },
|
users: { type: Array, default: () => [] },
|
||||||
workdayConnection: { type: Object, default: () => ({}) },
|
workdayConnection: { type: Object, default: () => ({}) },
|
||||||
workdaySaving: { type: Boolean, default: false },
|
workdaySaving: { type: Boolean, default: false },
|
||||||
workdaySyncing: { type: Boolean, default: false }
|
workdaySyncing: { type: Boolean, default: false }
|
||||||
},
|
},
|
||||||
emits: ['create-team', 'update-team', 'delete-team', 'create-user', 'update-user', 'unlock-user', 'create-role', 'update-role', 'delete-role', 'save-settings', 'save-workday', 'sync-workday', 'categories-updated'],
|
emits: ['create-team', 'update-team', 'delete-team', 'create-user', 'update-user', 'unlock-user', 'create-role', 'update-role', 'delete-role', 'save-settings', 'save-workday', 'sync-workday', 'categories-updated', 'companies-updated'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
localSettings: { ...this.settings },
|
localSettings: { ...this.settings },
|
||||||
@@ -364,6 +381,9 @@ export default {
|
|||||||
teamOptions() {
|
teamOptions() {
|
||||||
return this.teams.map((team) => ({ title: team.name, value: team.id }));
|
return this.teams.map((team) => ({ title: team.name, value: team.id }));
|
||||||
},
|
},
|
||||||
|
companyItems() {
|
||||||
|
return this.companies.map((company) => ({ title: company.name, value: company.id }));
|
||||||
|
},
|
||||||
roleSelectItems() {
|
roleSelectItems() {
|
||||||
return this.roles.map((role) => ({ title: role.name, value: role.key }));
|
return this.roles.map((role) => ({ title: role.name, value: role.key }));
|
||||||
},
|
},
|
||||||
@@ -404,7 +424,8 @@ export default {
|
|||||||
role: 'viewer',
|
role: 'viewer',
|
||||||
status: 'active',
|
status: 'active',
|
||||||
password: '',
|
password: '',
|
||||||
must_change_password: false
|
must_change_password: false,
|
||||||
|
company_ids: []
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
editUser(account) {
|
editUser(account) {
|
||||||
@@ -416,7 +437,8 @@ export default {
|
|||||||
role: account.role,
|
role: account.role,
|
||||||
status: account.status,
|
status: account.status,
|
||||||
password: '',
|
password: '',
|
||||||
must_change_password: Boolean(account.must_change_password)
|
must_change_password: Boolean(account.must_change_password),
|
||||||
|
company_ids: Array.isArray(account.company_ids) ? [...account.company_ids] : []
|
||||||
};
|
};
|
||||||
this.userDialog = true;
|
this.userDialog = true;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user