This commit is contained in:
2026-06-03 13:58:12 -05:00
commit 2f13b8c590
54 changed files with 5136 additions and 0 deletions

7
.env.example Normal file
View File

@@ -0,0 +1,7 @@
MIXEDASSETS_PORT=3000
MIXEDASSETS_DATA_DIR=./data
MIXEDASSETS_DB_PATH=./data/mixedassets.sqlite
MIXEDASSETS_SERVER_FQDN=localhost
MIXEDASSETS_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
MIXEDASSETS_JWT_SECRET=replace-with-a-long-random-secret
MIXEDASSETS_ADMIN_PASSWORD=ChangeMe123!

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules
/data
/logs
*.log
*.env
.DS_Store
dist
build
*-lock.json

38
README.md Normal file
View File

@@ -0,0 +1,38 @@
# MixedAssets
MixedAssets is a fixed asset management and depreciation SPA for small and mid-sized organizations. This first build includes an Express API, Vue/Vuetify frontend, SQLite persistence, role-aware login, asset register, templates, JSON tax-rule sets, depreciation reports, import/export, file storage scaffolding, and admin configuration.
## Run
```bash
npm install
npm run dev
```
Open `http://localhost:5173`.
Default seeded login:
```text
admin@mixedassets.local
ChangeMe123!
```
## Scripts
```bash
npm run dev # API + Vite SPA
npm run build # production SPA build
npm start # Express API and built SPA
npm test # API smoke test
```
## Configuration
Environment variables are shown in `.env.example`. The app also stores editable runtime settings in SQLite through the Admin screen, including server FQDN, allowed CORS domains, and database metadata.
Tax and depreciation rule updates live as JSON rule sets. The starter federal template is in `data/tax-rules/federal-2026.json` and is seeded into the database on first run.
## Compliance Note
The depreciation engine is built to support rule updates, projections, books, conventions, and method expansion. The included tax template is an engineering starter and should be validated against current IRS, state, and local guidance before production tax filing.

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MixedAssets</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

44
package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "mixedassets",
"version": "1.0.0",
"description": "MixedAssets fixed asset management and depreciation platform",
"main": "server/index.js",
"scripts": {
"dev": "concurrently \"npm:dev:api\" \"npm:dev:web\"",
"dev:api": "node --watch server/index.js",
"dev:web": "vite --host 0.0.0.0",
"build": "vite build",
"start": "node server/index.js",
"test": "node server/smoke-test.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@fluentui/web-components": "^2.6.1",
"@lucide/vue": "^1.17.0",
"@mdi/font": "^7.4.47",
"@vitejs/plugin-vue": "^6.0.7",
"bcryptjs": "^3.0.3",
"chart.js": "^4.5.1",
"concurrently": "^10.0.3",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"fast-xml-parser": "^5.8.0",
"jsonwebtoken": "^9.0.3",
"moment": "^2.30.1",
"multer": "^2.1.1",
"papaparse": "^5.5.3",
"path": "^0.12.7",
"pdfkit": "^0.18.0",
"read-excel-file": "^9.0.10",
"vite": "^8.0.16",
"vue": "^3.5.35",
"vue-chartjs": "^5.3.3",
"vuetify": "^4.1.0",
"winston": "^3.19.0",
"write-excel-file": "^4.0.7"
}
}

179
project.txt Normal file
View File

@@ -0,0 +1,179 @@
A fixed asset is a long-term, tangible asset that a company owns and uses in its daily operations to generate income. Also known as Property, Plant, and Equipment (PP&E), these resources are not meant for immediate sale and have a useful life extending beyond one year. Accounting & ManagementCapitalization: Instead of being expensed all at once, their high upfront costs are recorded as assets and spread out over their lifespan.Depreciation: Except for land, fixed assets lose value over time due to wear and tear. This gradual decrease in value is recorded on the balance sheet as accumulated depreciation.Tracking: Companies often use a dedicated fixed asset register or specialized asset management software to track maintenance, location, and depreciation.For further details on asset classes and financial planning, you can explore guides on financial management from resources like Investopedia's Fixed Assets Guide or NetSuite's Accounting Resources. The software packager we will be making is called "MixedAssets" and will be an accounting software used to manage assets and depreciation. The software will be designed as an SPA using node.js, express, vue.js, vuetify, charts.js, and sqlite with models/schema (Database config can be set via env variables or in web ui). The app should have a configuration screen to allow the user to setup things like the database server, database name, database user, database password, server fqdn, allowed domians for cors, etc. Use any additional modules you feel best meet the needs for things like reporting, workflow, ui/ux enhancement, etc. The UI/UX should be briliantly designed in Vue.js using modern ux design approaches and following googles material design guidelines and best practices. MixedAssets makes financial reporting more timely and accurate by automating fixed-asset depreciation and lease accounting. Develop a comprehensive list of existing assets, including relevant data like acquisition cost, in-service date, estimated useful life and more, and track lease agreement details like contract value, duration and discount rate. MixedAssets is a cloud-based asset management and depreciation tool.
The UI/UX is equally as important as the functionality. MixedAssets is a FIXED ASSET MANAGEMENT AND DEPRECIATION SOLUTION FOR SMALL AND MID-SIZED BUSINESSES AND ORGANIZATIONS WHO WANT TO GAIN CONTROL OVER THEIR FIXED ASSET DATA, GENERATE REPORTS WHEN NEEDED, AND MANAGE THE FIXED ASSET LIFECYCLE. The features required are below
ENSURE app is crafted using the latest Fluent UI, ensuring seamless functionality across all devices.
1. Multi-User (Secure Multi-User Role Based Access / Teams(Groups))
2. No limits to the number of assets or entities. (Maintains an unlimited entities/companies.)
3. Two-way data portability with import and export. (JSON/CSV/XLS/XML)
4. Ability for use to create Templates to help with data entry. (Custom Forms/Create asset templates for entering common type of assets. Simplifies data entry and reduces errors.)
5. reporting for management, accounting, purchase/sales, taxes and controlling insurance premiums and exposure. MixedAssets automatically applies depreciation rules and calculates depreciation throughout the lifecycle of an asset.
6. Share data between team members. Multiple users can work with the same data at the same time.
7. Mass Edit lets you edit groups of assets all at once. You can do mass disposals or mass changes to Service Date, Depreciation Method, Useful Life , Convention or more.
68 Tax and Accounting Depreciation Methods including:
1. 36 MACRS methods including regular MACRS, alternative MACRS and straight-line MACRS.
2. 24 ACRS methods including alternative ACRS and straight-line ACRS.
3. 6 GAAP methods including straight-line, sum of years digits and 4 declining balance methods.
4. Bonus Depreciation, Section 179 Expensing, Auto and Truck Limits, IRS Tables option.
5. Half-Year, Mid-Quarter, Mid-Month and Full-Month Conventions for first and last years.
6. Amortization and manual entry of depreciation is available.
7. Simultaneously maintains up to 6 sets of books including GAAP/Financial, Federal Tax, State Tax, Alternative Minimum Tax (AMT), Adjusted Current Earnings (ACE) and a user-defined book.
8. Enter each asset only once and it automatically updates all active books.
9. In each set of books, an asset can have a different cost, depreciation method, useful life, business use percentage, investment use percentage, section 179 amount, and convention.
10. Keep it simple and enable only the GAAP/Financial book when tax and other depreciation calculations are not required.
11. Calculates for calendar and fiscal years, full years and short years.
12. Conventions are set automatically for the year of calculation but allows forced or manual convention selections and enforces mid-quarter rule for each year when 40% or more of fixed assets are placed into service in the 4th quarter.
13. Projection function calculates depreciation for any future year to assist with budgeting, estimating future depreciation and calculating the effects of future capital expenditures.
14. Allows negative “contra” assets to account for refunds and credits received after the Service Date.
15. Easy reference to IRS asset lives table.
16. Select formulas or IRS rate tables for each MACRS asset.
17. Apply business use and investment percentages.
18. Keeps two(user definable) years open at one time for year-end and new-year calculations.
19. Calculates depreciation for the year in which asset is disposed.
20. Automatically applies tax limits to luxury automobiles & listed property.
21. Option to apply automobile limits in State and User-Defined books.
22. Default methods and lives are automatically applied in State and User-Defined books.
23. Option to use Code Sec. 179 tax rules in State and User-Defined books.
24. Choice to use Corporate Earnings and Profit Rules for Code Sec. 179 in State and User-Defined books.
COMPLETE ACCOUNTING AND TAX INFO FOR EACH ASSET
25. Asset ID is user-assigned. You can use IDs from your previous system (up to 10 characters, alpha numeric). Fixed Asset Pro automatically increments and suggests the next ID when you add an asset.
26. Detailed asset description (50 characters).
27. Acquired Date determines when an asset first appears on the books.
28. In-Service Date determines when depreciation starts.
29. Track CIP (Construction-In-Progress) projects. Store invoices for each project. When completed, place the project into service as one or more fixed assets.
30. Assign a Group, Location and Department to each asset.
31. Assign GL account codes for fixed assets, depreciation expense and accumulated depreciation to drive journal entries.
32. Vendor name and serial number for section 179 record-keeping compliance.
33. Invoice number
34. custom user-defined fields (text, date, bool and numeric value)
35. New or used designation for certain tax calculations.
36. Depreciation method easily selected from the list of available methods.
37. Convention (half-year, mid-quarter or mid-month) rules are applied automatically.
38. Tax Class (MACRS, ACRS and other).
39. Useful life of the asset.
40. Original cost of the asset and prior depreciation taken.
41. Salvage value of the asset.
42. Land value for non-depreciable land assets.
43. Section 179 deductions taken.
44. Bonus deprecation option.
45. Disposal date, sales price and sale expense are used to automatically calculate the gain or loss on disposal.
46. Partial disposals
47. Tax disposal type (e.g., sale, exchange involuntary conversion, etc.), property type (1245 tangible, 1250 real, other) and time asset was held determine disposal classification on Tax form 4797 report.
48. Listed property option to apply federal tax limits to luxury automobiles, trucks/vans and SUVs over 6000lbs.
49. Asset adjustments for impairment or other write-downs.
50. Personal property type (machinery and equipment, furniture and fixtures, small tools and supplies, real estate, leased property, exempt property, and add new categories).
51. Option to use tax rate tables or formulas for depreciation calculations.
52. Business use percentage and investment use percentage.
53. AMT type and ACE type.
54. Verify data routine validates your data and settings according to all the built-in business rules.
55. Store asset control data including tag number, inventory number, control number, barcode value, custodian, physical location, and condition to help with physical inventories.
56. Warranty info includes up to 5 warranties /service agreements per asset, so the info is at your fingertips when you need it. Includes start date, expiration date, warranty limit and units (e.g., miles), actual usage, provider, telephone, coverage details and warranty documents.
57. File Cabinet lets you store files with each asset and easily retrieve them (stored in database base64 encoded). Keep invoices, maintenance records, pictures for insurance, users manuals, etc.
58. Keep multiple pages of notes for each asset as needed.
59. Extensive Tasks system helps you keep track of important activities that need to be completed for each asset or CIP project.
REPORTING
60. Standard Reports. Includes a library of built-in reports for accounting and tax depreciation, acquisitions, disposals, journal entries and others. See below for a complete list. Flexible report options let you select the book, year, month, preparer, sorting and subtotal options, filters and more. You can save all reports to Excel, DOC and PDF
61. Report Builder. Create custom reports using the report designer. You have access to all fields in the database.
OTHER
62. Import, transfer, and duplicate assets with ease and create groups for efficient asset reporting
63. Store images of assets and vital documentation for each asset
TRACKING ASSETS
64. Tracking makes keeping track of the items you use in your organization much easier. With automated inventory functionality and built-in reconciliation, never lose track of your assets again.
65. Print asset barcode from depreciation module
66. Check in and check out assets to your employees (Workday Integration here would be super cool)
67. Changes to the tracking module are automatically written to the depreciation module, eliminating double-entry and errors
FEATURES WE SELL
1. Stay compliant with IRS regulations with our annual federal tax compliance updates. (We will provide a json dump and use the template system created earlier)
2. Asset tracking ensures complete visibility and control over every asset, providing real-time insight into location, status, ownership, and utilization across your organization. Find any asset in few clicks.
3. Keep software assets organized and easily monitored. Gain clear visibility into license usage, renewal dates, and overall software utilization, helping your team stay compliant and efficient.
4. Manage the entire lifecycle of your assets—from procurement and allocation to maintenance, renewal, and retirement. Automate workflows to ensure a smooth process and prevent important steps from being overlooked.
5. Assign assets to specific employees, departments, or locations (workday integration here would be cool) and maintain full traceability to avoid misplacements. Ensure assets are always accounted for and properly managed.
6. Track every assets lifecycle stage from purchase to disposal. Manage repairs, renewals, and upgrades to maintain optimal condition.
7. Track all asset activities with detailed audit logs. Ensure transparency, accountability, and compliance.
8. Generate clear reports on asset usage and performance. Optimize resources and make informed decisions.
9. Organize assets by category, location, or status for a clear view. Enhance visibility and streamline asset management.
10. Develop an accurate listing of depreciating and non-depreciating fixed assets, and maintain detailed records over the entire asset lifecycle. Assign and track assets by region, facility, department or other reporting segment. Transfer assets from one subsidiary or business unit to another while retaining the complete history without manually reentering data.
11. automatically calculates depreciation expenses using standard or custom methods and schedules and lets finance teams choose how and when to apply depreciation. With usage-based methods, for instance, MixedAssets gives you the option to not depreciate an asset during seasonal downtime, when equipment is offline.
12. Lease Accounting: Standardize lease accounting and ensure compliance with financial reporting requirements. Capture key lease details, including start and stop dates, total cost and discount rate, and amortize lease expense over the duration of each agreement.
13. Produce accurate financial statements that meet tax and accounting requirements, generate standard fixed-asset reports or leverage SuiteAnalytics to slice and dice asset data by location, subsidiary or other criteria.
14. Flexible Depreciation. Use built-in templates or develop custom depreciation scenarios with user-defined schedules.
15. Simplified Compliance. Automate lease payments, expense amortization and reporting, easing compliance with lease accounting standards.
16. Financial Savings. Identify “ghost assets” and stop paying to ensure equipment that is no longer in service.
17. Comprehensive Reporting. Get detailed analysis of cost, depreciation, lease expense and related data across virtually any operational domain.
18. Depreciation rules are applied consistently, on the correct schedule, using the appropriate model — all the time, for every asset.
19. Barcode scanning/printing, alerts, audit history, and powerful reports included
20. supports team collaboration right out of the box. Every user can have their own level of access, so your system works the way your organization does.
21. View your assets with our predefined reports that include statements about your assets, incorporating factors such as their status, depreciation, maintenance, check-out times, and more. Alleviate your workload with fully customizable reports to fit your needs. Save your reports and reuse or revamp them to get the most out of your data.
22. no downloads, no installations, and no servers to manage. Just log in from any browser to access your asset data anytime, anywhere.
23. simple for you to set reminders and alarms for assets that require regular maintenance, assets that are past due, contracts and licenses that are about to expire, and a multitude of other helpful features. Avoid risks and handle problems before they have a chance to impact your network, and never miss a deadline by enabling our customizable email alerts.
24. mobile barcode scanning/printing function. Instantly update an asset in the field by scanning its barcode and entering the necessary information.
25. Safeguard your assets by getting them into a scheduled maintenance program. MixedAssets gives you the ability to establish a routine based on your schedule and your assets' needs. Monitor your assets' maintenance through the various reports that look at asset tags, people, and maintenance history.
26. Watch your assets travel through your company every step of the way including all the people that come in contact with them. Every interaction will be recorded in the appropriate asset's Events tab. Get comprehensive Check-Out Reports that analyze employees, asset tags, due dates, and assets that are past due at any time.
27. Use our software to keep tabs on your assets. Make a detailed log of equipment check-out, create an unlimited number of custom fields, and download our Excel template. Upload it to start managing your assets in minutes.
28. We provide you with the perfect platform to keep your contracts and licenses in the same place, organized in a way that works for you. We'll let you know when a contract or license is about to expire so you can update it in a timely manner. You can even set up email alerts to stay informed about your account at all times.
29. View predefined reports which include statements about your assets to help clean up your workload. Add information about status, depreciation, maintenance, check-out times, and more. Save and reuse an unlimited number of custom reports to get the most out of your data.
30. Use your smartphone or tablet to scan barcodes, take pictures, tag assets, and even establish parent-child relationships. Easily add and edit any asset you need, in your home, office, or warehouse.
31. Set reminders and alarms for assets that require regular maintenance, assets that are past due, contracts and licenses that are about to expire, and many other features.
32. Add as many users as you need for your accounts. Make your system as broad, or as narrow, as you need. Each user has a unique level of access ranging from a limited viewer to full administrator. Our system is a convenient solution for everyone in your organization.
33. Reserve your assets in advance to inform who needs them and when. Have them easily checked out to a person or a place. Helps you know exactly where your assets are when they are due back. Check them back in so others see they are now available and ready to check out.
34. Track your business operations in real time with MixedAsset's compliance management system. Whether youre managing physical assets or electronic records, you can easily monitor your compliance, assign tasks, and track audit progress in our highly configurable platform. Plus, create custom automated alerts to notify you of recently completed tasks, incidents, or upcoming audit dates to ensure you stay on track.
35. compliance management software helps you customize and standardize your processes accordingly. Enforce your workflows with required fields like signature capture and notes or photos.
36. Maintain detailed asset records with full lifecycle history, warranty information, and relevant attachments, such as user and safety manuals. Our compliance management system even allows you to track individual employees training progress to further boost safety and efficiency.
37. Empower your team to conduct audits and inspections even faster using our mobile app with built-in barcode technology. Generate and print your own unique barcode labels and instantly update asset records by scanning barcodes with your mobile devices camera. With barcode asset tracking, your team will not only save time but also improve data accuracy.
38. centralized compliance management software. With the ability to customize your naming conventions and workflows, you can easily track different asset types, unlimited workflows, and an up-to-date employee directory (workday integration) in one cohesive place.
Standard Reports:
Monthly depreciation expense (one month at time)
12-Month depreciation expense (all months in the selected year)
Journal entry: monthly depreciation
Quarterly depreciation expense
Annual acquisitions
Journal entry: acquisitions
Annual disposals and gain/loss
Journal entry: disposals and gain/loss
Annual and year-to-date depreciation expense
Net book value as of any month or year
Annual rollforward
Annual adjustments
Lifetime depreciation
Projection
Construction-in-progress
Fixed asset card (detail on individual fixed asset records)
Federal tax form 4562 report (Depreciation and Amortization)
Federal tax form 4797 report (Sales of Business Property)
AMT (Alternative Minimum Tax)
Personal property tax
UBIA for Section 199A limitation for pass-through entities
ACE (Adjusted Current Earnings)
MUST include a template driven (json) system for improvements and updates for any changes to the federal, state, or local depreciation laws.
RESOURCES FOR TAX LAWS YOU DO NOT KNOW
https://www.irs.gov/publications/p946
https://www.irs.gov/taxtopics/tc704
https://www.irs.gov/publications/p534
https://www.irs.gov/forms-pubs/about-publication-527
https://www.irs.gov/forms-pubs/about-publication-463
https://www.irs.gov/forms-pubs/about-publication-587
https://www.irs.gov/forms-pubs/about-publication-225
https://www.irs.gov/forms-pubs/about-publication-946
https://www.irs.gov/forms-pubs/about-publication-527
https://www.irs.gov/faqs/sale-or-trade-of-business-depreciation-rentals/depreciation-recapture/depreciation-recapture-4
https://www.nolo.com/legal-encyclopedia/new-irs-de-minimis-rule-deducting-business-property.html
https://www.youtube.com/watch?v=DpDZOf5X0z8&autoplay=1&rel=0
https://www.netsuite.com/blog/overcoming-the-challenges-of-fixed-asset-accounting
https://www.netsuite.com/portal/resource/articles/business-strategy/industrial-machinery-trends.shtml
Primary Depreciation Methods1. 100% Bonus Depreciation (IRC Section 168(k))Instant Write-Off: The One Big Beautiful Bill (OBBB) Act permanently restored 100% bonus depreciation.Eligibility: Applies to qualified property (machinery, equipment, computers, vehicles) acquired and placed in service after January 19, 2025.Nuance: Property acquired before this date but placed in service during 2025 is constrained by the older phase-down rate of 40%.Impact: Allows businesses to generate a net operating loss for the tax year if the deduction exceeds business income.2. Section 179 ExpensingDollar Limit: Businesses can choose to immediately expense up to $2,500,000 of the cost of qualifying equipment and software.Phase-Out Cap: The deduction begins to reduce dollar-for-dollar once total equipment purchases for the tax year exceed $4,000,000.Income Limit: Section 179 cannot exceed your total taxable business income for the year (it cannot create a financial loss).3. Standard MACRS DepreciationFramework: If a business elects out of immediate expensing, it must use the Modified Accelerated Cost Recovery System (MACRS).Timelines: Costs are spread out evenly over rigid "class lives" designated by the IRS (e.g., 5 years for computers, 7 years for office furniture, and 27.5 years for residential rental buildings).4. The De Minimis Safe Harbor RuleExpense Threshold: Under the IRS De Minimis Safe Harbor Guidance, small purchases under $2,500 per invoice do not need to be depreciated at all.Simplification: They can be completely written off as standard operating supplies in the year of purchase.
Here is some some sample reports you can reference
https://moneysoft.com/wp-content/uploads/2016/02/FAP-Sample-Report.pdf
Fixed assets Guidebook for more knowledge
https://finance.charlotte.edu/wp-content/uploads/sites/347/2023/05/Fixed_Assets_Guidebook.pdf

BIN
public/asset-workbench.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

65
server/app.js Normal file
View File

@@ -0,0 +1,65 @@
require('dotenv').config();
const express = require('express');
const path = require('path');
const { DB_PATH, initialize } = require('./db');
const { createCorsMiddleware } = require('./middleware/cors');
const { requireAuth } = require('./middleware/auth');
const adminRoutes = require('./routes/admin');
const assetRoutes = require('./routes/assets');
const assignmentRoutes = require('./routes/assignments');
const authRoutes = require('./routes/auth');
const dashboardRoutes = require('./routes/dashboard');
const dataPortabilityRoutes = require('./routes/dataPortability');
const profileRoutes = require('./routes/profile');
const referenceRoutes = require('./routes/reference');
const reportRoutes = require('./routes/reports');
const taxRuleRoutes = require('./routes/taxRules');
const templateRoutes = require('./routes/templates');
const workdayRoutes = require('./routes/workday');
function createApp() {
initialize();
const app = express();
app.use(createCorsMiddleware());
app.use(express.json({ limit: '12mb' }));
app.get('/api/health', (req, res) => {
res.json({ ok: true, app: 'MixedAssets', database: DB_PATH });
});
app.use('/api', authRoutes);
app.use('/api', requireAuth);
app.use('/api', profileRoutes);
app.use('/api', dashboardRoutes);
app.use('/api', referenceRoutes);
app.use('/api', assetRoutes);
app.use('/api', assignmentRoutes);
app.use('/api', templateRoutes);
app.use('/api', taxRuleRoutes);
app.use('/api', reportRoutes);
app.use('/api', dataPortabilityRoutes);
app.use('/api', adminRoutes);
app.use('/api', workdayRoutes);
const distPath = path.join(process.cwd(), 'dist');
app.use(express.static(distPath));
app.get(/.*/, (req, res, next) => {
if (req.path.startsWith('/api')) return next();
return res.sendFile(path.join(distPath, 'index.html'));
});
app.use((error, req, res, next) => {
if (res.headersSent) return next(error);
const status = error.status || 500;
return res.status(status).json({ error: error.message || 'MixedAssets server error' });
});
return app;
}
module.exports = {
createApp
};

492
server/db.js Normal file
View File

@@ -0,0 +1,492 @@
const fs = require('fs');
const path = require('path');
const { DatabaseSync } = require('node:sqlite');
const bcrypt = require('bcryptjs');
const { expandRuleSet } = require('./rule-catalog');
const DATA_DIR = process.env.MIXEDASSETS_DATA_DIR || path.join(process.cwd(), 'data');
const DB_PATH = process.env.MIXEDASSETS_DB_PATH || path.join(DATA_DIR, 'mixedassets.sqlite');
fs.mkdirSync(DATA_DIR, { recursive: true });
const db = new DatabaseSync(DB_PATH);
db.exec('PRAGMA foreign_keys = ON;');
db.exec('PRAGMA journal_mode = WAL;');
function now() {
return new Date().toISOString();
}
function json(value, fallback = null) {
if (value === undefined) return fallback === null ? null : JSON.stringify(fallback);
return JSON.stringify(value);
}
function parseJson(value, fallback = null) {
if (value === null || value === undefined || value === '') return fallback;
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function bind(statement, params) {
if (Array.isArray(params)) return statement;
return statement;
}
function args(params) {
if (Array.isArray(params)) return params;
if (params && Object.keys(params).length > 0) return [params];
return [];
}
function one(sql, params = {}) {
const statement = bind(db.prepare(sql), params);
return statement.get(...args(params));
}
function all(sql, params = {}) {
const statement = bind(db.prepare(sql), params);
return statement.all(...args(params));
}
function run(sql, params = {}) {
const statement = bind(db.prepare(sql), params);
return statement.run(...args(params));
}
function tx(fn) {
db.exec('BEGIN IMMEDIATE');
try {
const result = fn();
db.exec('COMMIT');
return result;
} catch (error) {
db.exec('ROLLBACK');
throw error;
}
}
function initialize() {
db.exec(`
CREATE TABLE IF NOT EXISTS teams (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id INTEGER REFERENCES teams(id),
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','finance','operations','viewer')),
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_preferences (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
preferences_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS entities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
legal_name TEXT,
tax_id TEXT,
fiscal_year_end_month INTEGER NOT NULL DEFAULT 12,
base_currency TEXT NOT NULL DEFAULT 'USD',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS reference_values (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
name TEXT NOT NULL,
code TEXT,
metadata TEXT,
UNIQUE(type, name)
);
CREATE TABLE IF NOT EXISTS employees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workday_worker_id TEXT UNIQUE,
employee_number TEXT,
name TEXT NOT NULL,
email TEXT,
department TEXT,
location TEXT,
manager_name TEXT,
status TEXT NOT NULL DEFAULT 'active',
source TEXT NOT NULL DEFAULT 'manual',
source_payload TEXT,
last_synced_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id TEXT NOT NULL UNIQUE,
entity_id INTEGER REFERENCES entities(id),
template_id INTEGER,
description TEXT NOT NULL,
category TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'in_service',
acquisition_cost REAL NOT NULL DEFAULT 0,
acquired_date TEXT,
in_service_date TEXT,
useful_life_months INTEGER NOT NULL DEFAULT 60,
salvage_value REAL NOT NULL DEFAULT 0,
land_value REAL NOT NULL DEFAULT 0,
prior_depreciation REAL NOT NULL DEFAULT 0,
location TEXT,
department TEXT,
group_name TEXT,
custodian TEXT,
vendor TEXT,
serial_number TEXT,
invoice_number TEXT,
gl_asset_account TEXT,
gl_expense_account TEXT,
gl_accumulated_account TEXT,
barcode_value TEXT,
condition TEXT,
new_or_used TEXT DEFAULT 'new',
listed_property INTEGER NOT NULL DEFAULT 0,
business_use_percent REAL NOT NULL DEFAULT 100,
investment_use_percent REAL NOT NULL DEFAULT 0,
disposal_date TEXT,
disposal_price REAL,
disposal_expense REAL,
disposal_type TEXT,
property_type TEXT,
notes TEXT,
custom_fields TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS asset_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
book_type TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
cost REAL,
depreciation_method TEXT NOT NULL DEFAULT 'straight_line',
convention TEXT NOT NULL DEFAULT 'half_year',
useful_life_months INTEGER,
section_179_amount REAL NOT NULL DEFAULT 0,
bonus_percent REAL NOT NULL DEFAULT 0,
business_use_percent REAL,
investment_use_percent REAL,
manual_depreciation TEXT,
UNIQUE(asset_id, book_type)
);
CREATE TABLE IF NOT EXISTS asset_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
defaults TEXT NOT NULL,
custom_fields TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tax_rule_sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
jurisdiction TEXT NOT NULL,
name TEXT NOT NULL,
effective_date TEXT NOT NULL,
version TEXT NOT NULL,
rules_json TEXT NOT NULL,
active INTEGER NOT NULL DEFAULT 1,
source_note TEXT,
created_at TEXT NOT NULL,
UNIQUE(jurisdiction, version)
);
CREATE TABLE IF NOT EXISTS leases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER REFERENCES assets(id) ON DELETE CASCADE,
lessor TEXT,
contract_value REAL NOT NULL DEFAULT 0,
start_date TEXT,
end_date TEXT,
discount_rate REAL NOT NULL DEFAULT 0,
payment_frequency TEXT NOT NULL DEFAULT 'monthly',
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS warranties (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
provider TEXT,
phone TEXT,
start_date TEXT,
expiration_date TEXT,
warranty_limit TEXT,
actual_usage TEXT,
coverage_details TEXT,
document_name TEXT,
document_base64 TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS asset_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
content_base64 TEXT NOT NULL,
uploaded_by INTEGER REFERENCES users(id),
uploaded_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER REFERENCES assets(id) ON DELETE CASCADE,
title TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'maintenance',
status TEXT NOT NULL DEFAULT 'open',
due_date TEXT,
assigned_to INTEGER REFERENCES users(id),
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS checkouts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
checked_out_to TEXT NOT NULL,
destination TEXT,
due_date TEXT,
checked_out_at TEXT NOT NULL,
checked_in_at TEXT,
notes TEXT
);
CREATE TABLE IF NOT EXISTS asset_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
employee_id INTEGER REFERENCES employees(id),
department TEXT,
location TEXT,
status TEXT NOT NULL DEFAULT 'assigned',
assigned_at TEXT NOT NULL,
released_at TEXT,
assigned_by INTEGER REFERENCES users(id),
release_reason TEXT,
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS workday_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
tenant TEXT,
base_url TEXT,
token_url TEXT,
client_id TEXT,
client_secret TEXT,
workers_path TEXT NOT NULL DEFAULT '/workers',
enabled INTEGER NOT NULL DEFAULT 0,
last_sync_at TEXT,
last_sync_status TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS saved_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
report_type TEXT NOT NULL,
options_json TEXT NOT NULL,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_id INTEGER REFERENCES users(id),
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
before_json TEXT,
after_json TEXT,
created_at TEXT NOT NULL
);
`);
seed();
}
function seed() {
const createdAt = now();
if (!one('SELECT id FROM teams LIMIT 1')) {
run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
'Finance Operations',
'Default MixedAssets team',
createdAt
]);
}
const team = one('SELECT id FROM teams WHERE name = ?', ['Finance Operations']);
if (!one('SELECT id FROM users LIMIT 1')) {
run(
'INSERT INTO users (team_id, name, email, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[
team.id,
'MixedAssets Admin',
'admin@mixedassets.local',
bcrypt.hashSync(process.env.MIXEDASSETS_ADMIN_PASSWORD || 'ChangeMe123!', 12),
'admin',
createdAt,
createdAt
]
);
}
if (!one('SELECT id FROM entities LIMIT 1')) {
run(
'INSERT INTO entities (name, legal_name, fiscal_year_end_month, base_currency, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
['Demo Company', 'Demo Company LLC', 12, 'USD', createdAt, createdAt]
);
}
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']
];
for (const item of refs) {
run('INSERT OR IGNORE INTO reference_values (type, name, code, metadata) VALUES (?, ?, ?, ?)', [
item[0],
item[1],
item[2],
'{}'
]);
}
run(
`INSERT OR IGNORE INTO employees (
employee_number, name, email, department, location, manager_name, status, source, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
['E-1001', 'Jordan Avery', 'jordan.avery@example.com', 'Operations', 'Headquarters', 'MixedAssets Admin', 'active', 'seed', createdAt, createdAt]
);
run(
`INSERT OR IGNORE INTO workday_connections (
name, tenant, base_url, token_url, workers_path, enabled, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
['Primary Workday', '', '', '', '/workers', 0, createdAt, createdAt]
);
const starterTemplate = {
category: 'Computer Equipment',
useful_life_months: 60,
depreciation_method: 'macrs_gds_200db_5',
convention: 'half_year',
gl_asset_account: '1500',
gl_expense_account: '6400',
gl_accumulated_account: '1590',
business_use_percent: 100
};
run(
'INSERT OR IGNORE INTO asset_templates (name, description, defaults, custom_fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
[
'Laptop or workstation',
'Standard computer equipment template with 5-year tax life.',
json(starterTemplate),
json([
{ key: 'assigned_employee_id', label: 'Employee ID', type: 'text', required: false },
{ key: 'software_profile', label: 'Software profile', type: 'text', required: false }
]),
createdAt,
createdAt
]
);
const federalPath = path.join(process.cwd(), 'data', 'tax-rules', 'federal-2026.json');
if (fs.existsSync(federalPath)) {
const rules = expandRuleSet(JSON.parse(fs.readFileSync(federalPath, 'utf8')));
run(
`INSERT INTO tax_rule_sets (jurisdiction, name, effective_date, version, rules_json, active, source_note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(jurisdiction, version) DO UPDATE SET
name = excluded.name,
effective_date = excluded.effective_date,
rules_json = excluded.rules_json,
active = excluded.active,
source_note = excluded.source_note`,
[
rules.jurisdiction || 'US-FED',
rules.name || 'Federal starter depreciation rules',
rules.effectiveDate || rules.effective_date || '2026-01-01',
rules.version || '2026.2',
json(rules),
1,
rules.sourceNote || 'Starter rule template. Confirm current tax law before production filing.',
createdAt
]
);
}
const settings = {
server_fqdn: process.env.MIXEDASSETS_SERVER_FQDN || 'localhost',
cors_allowed_domains: process.env.MIXEDASSETS_CORS_ORIGINS || 'http://localhost:5173',
database_driver: 'node:sqlite',
database_path: DB_PATH,
default_books: 'GAAP,FEDERAL,STATE,AMT,ACE,USER'
};
for (const [key, value] of Object.entries(settings)) {
run('INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)', [key, String(value), createdAt]);
}
}
function audit(actorId, action, entityType, entityId, beforeValue, afterValue) {
run(
'INSERT INTO audit_logs (actor_id, action, entity_type, entity_id, before_json, after_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[actorId || null, action, entityType, entityId === undefined ? null : String(entityId), json(beforeValue), json(afterValue), now()]
);
}
module.exports = {
DB_PATH,
all,
audit,
db,
initialize,
json,
now,
one,
parseJson,
run,
tx
};

208
server/depreciation.js Normal file
View File

@@ -0,0 +1,208 @@
const { parseJson } = require('./db');
function money(value) {
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
}
function yearFromDate(value) {
if (!value) return new Date().getFullYear();
return new Date(`${value}T00:00:00`).getFullYear();
}
function monthFromDate(value) {
if (!value) return 1;
return new Date(`${value}T00:00:00`).getMonth() + 1;
}
function parseMaybeJson(value, fallback = null) {
if (typeof value === 'string') return parseJson(value, fallback);
return value ?? fallback;
}
function totalDepreciableBasis(asset, book) {
const cost = Number(book.cost ?? asset.acquisition_cost ?? 0);
const land = Number(asset.land_value || 0);
const salvage = Number(asset.salvage_value || 0);
const businessUse = Number(book.business_use_percent ?? asset.business_use_percent ?? 100) / 100;
return Math.max(0, (cost - land - salvage) * businessUse);
}
function section179Amount(asset, book) {
return Math.min(totalDepreciableBasis(asset, book), Number(book.section_179_amount || 0));
}
function bonusAmount(asset, book) {
const after179 = Math.max(0, totalDepreciableBasis(asset, book) - section179Amount(asset, book));
return after179 * (Number(book.bonus_percent || 0) / 100);
}
function depreciableBasis(asset, book) {
return Math.max(0, totalDepreciableBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book));
}
function conventionFor(book, methodRule) {
return methodRule.defaultConvention || book.convention || 'none';
}
function yearFraction(index, convention, asset) {
if (index < 0) return 0;
const month = monthFromDate(asset.in_service_date || asset.acquired_date);
const quarter = Math.ceil(month / 3);
if (convention === 'none') return 1;
if (convention === 'full_month') {
const first = (13 - month) / 12;
if (index === 0) return first;
return null;
}
if (convention === 'mid_month') {
const first = (12 - month + 0.5) / 12;
if (index === 0) return first;
return null;
}
if (convention === 'mid_quarter') {
const firstByQuarter = { 1: 0.875, 2: 0.625, 3: 0.375, 4: 0.125 };
if (index === 0) return firstByQuarter[quarter] || 0.5;
return null;
}
if (convention === 'half_year') {
if (index === 0) return 0.5;
return null;
}
return 1;
}
function fractionForIndex(index, convention, asset, totalYears) {
const first = yearFraction(0, convention, asset);
const explicit = yearFraction(index, convention, asset);
if (explicit !== null) return explicit;
const finalIndex = Math.ceil(totalYears + (1 - first)) - 1;
if (index > finalIndex) return 0;
if (index === finalIndex) return Math.max(0, 1 - first);
return 1;
}
function straightLine(asset, book, index, totalYears, methodRule) {
const basis = depreciableBasis(asset, book);
return (basis / totalYears) * fractionForIndex(index, conventionFor(book, methodRule), asset, totalYears);
}
function sumOfYearsDigits(asset, book, index, totalYears) {
const basis = depreciableBasis(asset, book);
const denominator = (totalYears * (totalYears + 1)) / 2;
return basis * ((totalYears - index) / denominator);
}
function decliningBalance(asset, book, index, totalYears, multiplier, methodRule) {
const basis = depreciableBasis(asset, book);
let bookValue = basis;
let elapsed = 0;
const convention = conventionFor(book, methodRule);
for (let i = 0; i <= index; i += 1) {
const fraction = fractionForIndex(i, convention, asset, totalYears);
if (fraction <= 0 || bookValue <= 0) return 0;
const dbAmount = bookValue * (multiplier / totalYears) * fraction;
const remainingLife = Math.max(0.0001, totalYears - elapsed);
const slAmount = (bookValue / remainingLife) * fraction;
const selected = methodRule.switchToStraightLine ? Math.max(dbAmount, slAmount) : dbAmount;
const amount = Math.min(bookValue, selected);
if (i === index) return amount;
bookValue -= amount;
elapsed += fraction;
}
return 0;
}
function rateTable(asset, book, index, methodRule) {
const basis = depreciableBasis(asset, book);
const rates = methodRule.rates || [];
if (!rates.length && methodRule.fallbackFormula === 'straight_line') {
return straightLine(asset, book, index, Math.max(1, Math.ceil((methodRule.lifeMonths || book.useful_life_months || 60) / 12)), methodRule);
}
return basis * Number(rates[index] || 0);
}
function getMethodRule(ruleSet, code) {
const rules = typeof ruleSet === 'string' ? parseJson(ruleSet, {}) : ruleSet || {};
return (rules.methods || []).find((method) => method.code === code) || {
code,
formula: code,
label: code
};
}
function annualSchedule(asset, book, ruleSet, options = {}) {
const methodRule = getMethodRule(ruleSet, book.depreciation_method || 'straight_line');
const usefulLifeMonths = Number(methodRule.lifeMonths || book.useful_life_months || asset.useful_life_months || 60);
const totalYears = Math.max(1, Math.ceil(usefulLifeMonths / 12));
const placedInServiceYear = yearFromDate(asset.in_service_date || asset.acquired_date);
const startYear = Number(options.startYear || placedInServiceYear);
const endYear = Number(options.endYear || startYear + totalYears + 1);
const manual = parseMaybeJson(book.manual_depreciation, {});
const rows = [];
let accumulated = Number(asset.prior_depreciation || 0);
for (let year = placedInServiceYear; year <= endYear; year += 1) {
const index = year - placedInServiceYear;
let depreciation = 0;
if (index >= 0) {
if (index === 0) {
depreciation += section179Amount(asset, book) + bonusAmount(asset, book);
}
if (methodRule.formula === 'rate_table') {
depreciation += rateTable(asset, book, index, methodRule);
} else if (methodRule.formula === 'sum_of_years_digits') {
depreciation += sumOfYearsDigits(asset, book, index, totalYears);
} else if (methodRule.formula === 'declining_balance' || methodRule.formula === 'macrs_declining_balance') {
depreciation += decliningBalance(asset, book, index, totalYears, Number(methodRule.rateMultiplier || 2), methodRule);
} else if (methodRule.formula === 'acrs_alternate_straight_line') {
depreciation += straightLine(asset, book, index, totalYears, { ...methodRule, defaultConvention: 'half_year' });
} else if (methodRule.formula === 'manual') {
depreciation = Number(manual[year] || 0);
} else {
depreciation += straightLine(asset, book, index, totalYears, methodRule);
}
}
const basis = totalDepreciableBasis(asset, book);
depreciation = Math.max(0, Math.min(depreciation, Math.max(0, basis - accumulated)));
accumulated += depreciation;
if (year >= startYear) {
rows.push({
year,
book: book.book_type,
method: methodRule.code,
methodLabel: methodRule.label || methodRule.code,
depreciation: money(depreciation),
accumulated: money(accumulated),
netBookValue: money(Math.max(0, Number(book.cost ?? asset.acquisition_cost ?? 0) - accumulated))
});
}
}
return rows;
}
function monthlyFromAnnual(row) {
const monthly = money(row.depreciation / 12);
return Array.from({ length: 12 }, (_, index) => ({
month: index + 1,
year: row.year,
book: row.book,
depreciation: monthly
}));
}
module.exports = {
annualSchedule,
depreciableBasis,
totalDepreciableBasis,
getMethodRule,
monthlyFromAnnual,
money
};

8
server/index.js Normal file
View File

@@ -0,0 +1,8 @@
const { createApp } = require('./app');
const PORT = Number(process.env.PORT || process.env.MIXEDASSETS_PORT || 3000);
const app = createApp();
app.listen(PORT, () => {
console.log(`MixedAssets API listening on http://localhost:${PORT}`);
});

49
server/middleware/auth.js Normal file
View File

@@ -0,0 +1,49 @@
const jwt = require('jsonwebtoken');
const { one } = require('../db');
const JWT_SECRET = process.env.MIXEDASSETS_JWT_SECRET || 'mixedassets-local-development-secret';
function publicUser(user) {
if (!user) return null;
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
teamId: user.team_id
};
}
function tokenFor(user) {
return jwt.sign({ sub: user.id, role: user.role, teamId: user.team_id }, JWT_SECRET, { expiresIn: '12h' });
}
function requireAuth(req, res, next) {
const header = req.headers.authorization || '';
const [, token] = header.split(' ');
if (!token) return res.status(401).json({ error: 'Authentication required' });
try {
const payload = jwt.verify(token, JWT_SECRET);
const user = one('SELECT * FROM users WHERE id = ? AND status = ?', [payload.sub, 'active']);
if (!user) return res.status(401).json({ error: 'User is inactive or missing' });
req.user = user;
return next();
} catch {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
function requireRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) return res.status(403).json({ error: 'Insufficient access' });
return next();
};
}
module.exports = {
publicUser,
requireAuth,
requireRole,
tokenFor
};

31
server/middleware/cors.js Normal file
View File

@@ -0,0 +1,31 @@
const cors = require('cors');
const { one } = require('../db');
function isLocalDevOrigin(origin) {
try {
const url = new URL(origin);
return ['localhost', '127.0.0.1'].includes(url.hostname);
} catch {
return false;
}
}
function createCorsMiddleware() {
const persistedCors = one('SELECT value FROM app_settings WHERE key = ?', ['cors_allowed_domains'])?.value || '';
const allowedOrigins = (process.env.MIXEDASSETS_CORS_ORIGINS || persistedCors || 'http://localhost:5173,http://127.0.0.1:5173')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean);
return cors({
origin(origin, callback) {
if (!origin || allowedOrigins.includes(origin) || isLocalDevOrigin(origin)) return callback(null, true);
return callback(new Error('Origin is not allowed by MixedAssets CORS settings'));
},
credentials: true
});
}
module.exports = {
createCorsMiddleware
};

131
server/routes/admin.js Normal file
View File

@@ -0,0 +1,131 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { all, audit, now, one, run } = require('../db');
const { requireRole } = require('../middleware/auth');
const { isValidRole, roleCapabilities, rolePermissions, roles } = require('../services/accessControl');
const router = express.Router();
const userSelect = `
SELECT u.id, u.team_id, t.name AS team_name, u.name, u.email, u.role, u.status, u.created_at, u.updated_at
FROM users u
LEFT JOIN teams t ON t.id = u.team_id
`;
function publicAdminUser(id) {
return one(`${userSelect} WHERE u.id = ?`, [id]);
}
function validateRole(role) {
if (!isValidRole(role)) throw Object.assign(new Error('Invalid role'), { status: 400 });
}
router.get('/settings', requireRole('admin'), (req, res) => {
const rows = all('SELECT * FROM app_settings ORDER BY key');
res.json({ settings: Object.fromEntries(rows.map((row) => [row.key, row.value])) });
});
router.put('/settings', requireRole('admin'), (req, res) => {
for (const [key, value] of Object.entries(req.body.settings || req.body)) {
run('INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at', [
key,
String(value),
now()
]);
}
audit(req.user.id, 'update', 'settings', 'app', null, req.body);
res.json({ settings: Object.fromEntries(all('SELECT * FROM app_settings ORDER BY key').map((row) => [row.key, row.value])) });
});
router.get('/users', requireRole('admin'), (req, res) => {
res.json({ users: all(`${userSelect} ORDER BY u.name`) });
});
router.post('/users', requireRole('admin'), (req, res) => {
validateRole(req.body.role || 'viewer');
if (req.body.status && !['active', 'inactive'].includes(req.body.status)) return res.status(400).json({ error: 'Invalid status' });
const created = now();
const result = run(
'INSERT INTO users (team_id, name, email, password_hash, role, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
req.body.team_id || one('SELECT id FROM teams ORDER BY id LIMIT 1')?.id,
req.body.name,
req.body.email,
bcrypt.hashSync(req.body.password || 'ChangeMe123!', 12),
req.body.role || 'viewer',
req.body.status || 'active',
created,
created
]
);
audit(req.user.id, 'create', 'user', result.lastInsertRowid, null, { ...req.body, password: '[redacted]' });
res.status(201).json({ user: publicAdminUser(result.lastInsertRowid) });
});
router.put('/users/:id', requireRole('admin'), (req, res) => {
const before = publicAdminUser(req.params.id);
if (!before) return res.status(404).json({ error: 'User not found' });
const nextRole = req.body.role || before.role;
validateRole(nextRole);
const nextStatus = req.body.status || before.status;
if (!['active', 'inactive'].includes(nextStatus)) return res.status(400).json({ error: 'Invalid status' });
const activeAdmins = one("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND status = 'active'").count;
if (before.role === 'admin' && before.status === 'active' && activeAdmins <= 1 && (nextRole !== 'admin' || nextStatus !== 'active')) {
return res.status(400).json({ error: 'At least one active admin is required' });
}
const updated = now();
run(
`UPDATE users SET
team_id = ?,
name = ?,
email = ?,
role = ?,
status = ?,
updated_at = ?
WHERE id = ?`,
[
req.body.team_id ?? before.team_id,
req.body.name || before.name,
req.body.email || before.email,
nextRole,
nextStatus,
updated,
req.params.id
]
);
if (req.body.password) {
run('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?', [
bcrypt.hashSync(req.body.password, 12),
updated,
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 });
return res.json({ user });
});
router.get('/teams', requireRole('admin'), (req, res) => {
res.json({ teams: all('SELECT * FROM teams ORDER BY name') });
});
router.post('/teams', requireRole('admin'), (req, res) => {
const result = run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
req.body.name,
req.body.description || null,
now()
]);
audit(req.user.id, 'create', 'team', result.lastInsertRowid, null, req.body);
res.status(201).json({ team: one('SELECT * FROM teams WHERE id = ?', [result.lastInsertRowid]) });
});
router.get('/access-control', requireRole('admin'), (req, res) => {
res.json({ roles, rolePermissions, roleCapabilities });
});
module.exports = router;

83
server/routes/assets.js Normal file
View File

@@ -0,0 +1,83 @@
const express = require('express');
const multer = require('multer');
const { audit, now, one, run } = require('../db');
const { annualSchedule } = require('../depreciation');
const { requireRole } = require('../middleware/auth');
const {
createAsset,
deleteAsset,
hydrateAsset,
listAssets,
massUpdateAssets,
updateAsset
} = require('../services/assets');
const { activeRuleSet } = require('../services/reports');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
const router = express.Router();
router.get('/assets', (req, res) => {
res.json({ assets: listAssets(req.query) });
});
router.post('/assets', requireRole('admin', 'finance', 'operations'), (req, res) => {
res.status(201).json({ asset: createAsset(req.body, req.user.id) });
});
router.get('/assets/:id', (req, res) => {
const asset = hydrateAsset(req.params.id);
if (!asset) return res.status(404).json({ error: 'Asset not found' });
return res.json({ asset });
});
router.put('/assets/:id', requireRole('admin', 'finance', 'operations'), (req, res) => {
const asset = updateAsset(req.params.id, req.body, req.user.id);
if (!asset) return res.status(404).json({ error: 'Asset not found' });
return res.json({ asset });
});
router.delete('/assets/:id', requireRole('admin', 'finance'), (req, res) => {
if (!deleteAsset(req.params.id, req.user.id)) return res.status(404).json({ error: 'Asset not found' });
return res.status(204).end();
});
router.post('/assets/mass-update', requireRole('admin', 'finance'), (req, res) => {
const ids = Array.isArray(req.body.ids) ? req.body.ids : [];
const updated = massUpdateAssets(ids, req.body.changes || {}, req.user.id);
if (!updated) return res.status(400).json({ error: 'Choose assets and editable fields' });
return res.json({ updated });
});
router.get('/assets/:id/depreciation', (req, res) => {
const asset = hydrateAsset(req.params.id);
if (!asset) return res.status(404).json({ error: 'Asset not found' });
const rules = activeRuleSet();
const startYear = Number(req.query.startYear || new Date().getFullYear());
const endYear = Number(req.query.endYear || startYear + 5);
const schedules = asset.books
.filter((book) => book.active)
.flatMap((book) => annualSchedule(asset, book, rules, { startYear, endYear }));
res.json({ assetId: asset.asset_id, schedules });
});
router.post('/assets/:id/files', upload.single('file'), requireRole('admin', 'finance', 'operations'), (req, res) => {
if (!req.file) return res.status(400).json({ error: 'File is required' });
const result = run(
'INSERT INTO asset_files (asset_id, file_name, mime_type, size, content_base64, uploaded_by, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[req.params.id, req.file.originalname, req.file.mimetype, req.file.size, req.file.buffer.toString('base64'), req.user.id, now()]
);
audit(req.user.id, 'upload_file', 'asset', req.params.id, null, { file: req.file.originalname });
res.status(201).json({ file: one('SELECT id, file_name, mime_type, size, uploaded_at FROM asset_files WHERE id = ?', [result.lastInsertRowid]) });
});
router.post('/assets/:id/tasks', requireRole('admin', 'finance', 'operations'), (req, res) => {
const created = now();
const result = run(
'INSERT INTO tasks (asset_id, title, type, status, due_date, assigned_to, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[req.params.id, req.body.title, req.body.type || 'maintenance', req.body.status || 'open', req.body.due_date || null, req.body.assigned_to || null, req.body.notes || null, created, created]
);
audit(req.user.id, 'create', 'task', result.lastInsertRowid, null, req.body);
res.status(201).json({ task: one('SELECT * FROM tasks WHERE id = ?', [result.lastInsertRowid]) });
});
module.exports = router;

View File

@@ -0,0 +1,47 @@
const express = require('express');
const { requireRole } = require('../middleware/auth');
const {
assignAsset,
assignmentRows,
assignmentSummary,
listEmployees,
releaseAssignment,
upsertEmployee
} = require('../services/assignments');
const router = express.Router();
router.get('/employees', (req, res) => {
res.json({ employees: listEmployees(req.query) });
});
router.post('/employees', requireRole('admin', 'finance', 'operations'), (req, res) => {
res.status(201).json({ employee: upsertEmployee({ ...req.body, source: req.body.source || 'manual' }) });
});
router.get('/assignments', (req, res) => {
res.json({
summary: assignmentSummary(),
assignments: assignmentRows({
asset_id: req.query.asset_id || null,
employee_id: req.query.employee_id || null,
active: req.query.active === 'true'
})
});
});
router.post('/assignments', requireRole('admin', 'finance', 'operations'), (req, res, next) => {
try {
res.status(201).json({ assignment: assignAsset(req.body, req.user.id) });
} catch (error) {
next(error);
}
});
router.put('/assignments/:id/release', requireRole('admin', 'finance', 'operations'), (req, res) => {
const assignment = releaseAssignment(req.params.id, req.body, req.user.id);
if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
return res.json({ assignment });
});
module.exports = router;

26
server/routes/auth.js Normal file
View File

@@ -0,0 +1,26 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const { audit, one } = require('../db');
const { publicUser, tokenFor } = require('../middleware/auth');
const router = express.Router();
router.get('/public/bootstrap', (req, res) => {
res.json({
appName: 'MixedAssets',
setupComplete: Boolean(one('SELECT id FROM users LIMIT 1')),
database: 'sqlite'
});
});
router.post('/auth/login', (req, res) => {
const { email, password } = req.body;
const user = one('SELECT * FROM users WHERE lower(email) = lower(?) AND status = ?', [email || '', 'active']);
if (!user || !bcrypt.compareSync(password || '', user.password_hash)) {
return res.status(401).json({ error: 'Invalid email or password' });
}
audit(user.id, 'login', 'user', user.id, null, { email: user.email });
return res.json({ token: tokenFor(user), user: publicUser(user) });
});
module.exports = router;

View File

@@ -0,0 +1,29 @@
const express = require('express');
const { all, one } = require('../db');
const router = express.Router();
router.get('/dashboard', (req, res) => {
const stats = one(`
SELECT
COUNT(*) AS asset_count,
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 = 'in_service' THEN 1 ELSE 0 END), 0) AS in_service_count
FROM assets
`);
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 byStatus = all('SELECT status, COUNT(*) AS count FROM assets GROUP BY status ORDER BY count DESC');
const upcomingTasks = all(`
SELECT t.*, a.asset_id, a.description
FROM tasks t
LEFT JOIN assets a ON a.id = t.asset_id
WHERE t.status != 'complete'
ORDER BY t.due_date IS NULL, t.due_date
LIMIT 8
`);
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 });
});
module.exports = router;

View File

@@ -0,0 +1,27 @@
const express = require('express');
const multer = require('multer');
const { audit } = require('../db');
const { requireRole } = require('../middleware/auth');
const { upsertImportedAssets } = require('../services/assets');
const { assetExport, extensionForUpload, rowsFromImport } = require('../services/importExport');
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
const router = express.Router();
router.get('/export/assets', async (req, res) => {
const format = String(req.query.format || 'json').toLowerCase();
const output = await assetExport(format);
res.setHeader('Content-Type', output.contentType);
res.setHeader('Content-Disposition', `attachment; filename="${output.filename}"`);
return res.send(output.body);
});
router.post('/import/assets', requireRole('admin', 'finance'), upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'File is required' });
const rows = await rowsFromImport(extensionForUpload(req.file.originalname), req.file.buffer);
const imported = upsertImportedAssets(rows, req.user.id);
audit(req.user.id, 'import', 'asset', null, null, { file: req.file.originalname, imported });
res.json({ imported });
});
module.exports = router;

17
server/routes/profile.js Normal file
View File

@@ -0,0 +1,17 @@
const express = require('express');
const { audit } = require('../db');
const { getProfile, savePreferences } = require('../services/profile');
const router = express.Router();
router.get('/profile', (req, res) => {
res.json(getProfile(req.user));
});
router.put('/profile', (req, res) => {
const preferences = savePreferences(req.user.id, req.body.preferences || {});
audit(req.user.id, 'update', 'profile_preferences', req.user.id, null, { preferences });
res.json({ user: getProfile(req.user).user, preferences });
});
module.exports = router;

View File

@@ -0,0 +1,34 @@
const express = require('express');
const { all, audit, now, one, parseJson, run } = require('../db');
const { requireRole } = require('../middleware/auth');
const router = express.Router();
router.get('/entities', (req, res) => {
res.json({ entities: all('SELECT * FROM entities ORDER BY name') });
});
router.post('/entities', requireRole('admin', 'finance'), (req, res) => {
const created = now();
const result = run(
'INSERT INTO entities (name, legal_name, tax_id, fiscal_year_end_month, base_currency, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[
req.body.name,
req.body.legal_name || null,
req.body.tax_id || null,
Number(req.body.fiscal_year_end_month || 12),
req.body.base_currency || 'USD',
created,
created
]
);
audit(req.user.id, 'create', 'entity', result.lastInsertRowid, null, req.body);
res.status(201).json({ entity: one('SELECT * FROM entities WHERE id = ?', [result.lastInsertRowid]) });
});
router.get('/references', (req, res) => {
const rows = all('SELECT * FROM reference_values ORDER BY type, name');
res.json({ references: rows.map((row) => ({ ...row, metadata: parseJson(row.metadata, {}) })) });
});
module.exports = router;

28
server/routes/reports.js Normal file
View File

@@ -0,0 +1,28 @@
const express = require('express');
const {
depreciationReport,
monthlyDepreciationReport,
writeDepreciationPdf
} = require('../services/reports');
const router = express.Router();
router.get('/reports/depreciation', (req, res) => {
const year = Number(req.query.year || new Date().getFullYear());
const bookType = req.query.book || 'GAAP';
res.json(depreciationReport(year, bookType));
});
router.get('/reports/monthly-depreciation', (req, res) => {
const year = Number(req.query.year || new Date().getFullYear());
const bookType = req.query.book || 'GAAP';
res.json(monthlyDepreciationReport(year, bookType));
});
router.get('/reports/depreciation.pdf', (req, res) => {
const year = Number(req.query.year || new Date().getFullYear());
const book = req.query.book || 'GAAP';
writeDepreciationPdf(res, year, book);
});
module.exports = router;

34
server/routes/taxRules.js Normal file
View File

@@ -0,0 +1,34 @@
const express = require('express');
const { all, audit, now, one, parseJson, run } = require('../db');
const { requireRole } = require('../middleware/auth');
const router = express.Router();
router.get('/tax-rules', (req, res) => {
const ruleSets = all('SELECT * FROM tax_rule_sets ORDER BY active DESC, effective_date DESC, id DESC').map((row) => ({
...row,
active: Boolean(row.active),
rules_json: parseJson(row.rules_json, {})
}));
res.json({ ruleSets });
});
router.post('/tax-rules', requireRole('admin', 'finance'), (req, res) => {
const result = run(
'INSERT INTO tax_rule_sets (jurisdiction, name, effective_date, version, rules_json, active, source_note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[
req.body.jurisdiction,
req.body.name,
req.body.effective_date,
req.body.version,
JSON.stringify(req.body.rules_json || req.body.rules || {}),
req.body.active === false ? 0 : 1,
req.body.source_note || null,
now()
]
);
audit(req.user.id, 'create', 'tax_rule_set', result.lastInsertRowid, null, req.body);
res.status(201).json({ ruleSet: one('SELECT * FROM tax_rule_sets WHERE id = ?', [result.lastInsertRowid]) });
});
module.exports = router;

View File

@@ -0,0 +1,26 @@
const express = require('express');
const { all, audit, json, now, one, parseJson, run } = require('../db');
const { requireRole } = require('../middleware/auth');
const router = express.Router();
router.get('/templates', (req, res) => {
const templates = all('SELECT * FROM asset_templates ORDER BY name').map((row) => ({
...row,
defaults: parseJson(row.defaults, {}),
custom_fields: parseJson(row.custom_fields, [])
}));
res.json({ templates });
});
router.post('/templates', requireRole('admin', 'finance'), (req, res) => {
const created = now();
const result = run(
'INSERT INTO asset_templates (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]
);
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]) });
});
module.exports = router;

32
server/routes/workday.js Normal file
View File

@@ -0,0 +1,32 @@
const express = require('express');
const { requireRole } = require('../middleware/auth');
const {
getConnection,
importWorkersFromPayload,
saveConnection,
syncWorkers
} = require('../services/workday');
const router = express.Router();
router.get('/workday/connection', requireRole('admin'), (req, res) => {
res.json({ connection: getConnection() });
});
router.put('/workday/connection', requireRole('admin'), (req, res) => {
res.json({ connection: saveConnection(req.body, req.user.id) });
});
router.post('/workday/sync-workers', requireRole('admin'), async (req, res, next) => {
try {
res.json(await syncWorkers(req.user.id));
} catch (error) {
next(error);
}
});
router.post('/workday/import-workers', requireRole('admin'), (req, res) => {
res.json(importWorkersFromPayload(req.body.workers || req.body, req.user.id));
});
module.exports = router;

142
server/rule-catalog.js Normal file
View File

@@ -0,0 +1,142 @@
function months(years) {
return Math.round(Number(years) * 12);
}
function method(code, family, label, formula, options = {}) {
return {
code,
family,
label,
formula,
...options
};
}
function macrsDeclining(prefix, labelPrefix, system, multiplier, lives, options = {}) {
return lives.map((life) => method(
`${prefix}_${String(life).replace('.', '_')}`,
'MACRS',
`${labelPrefix} ${life}-year`,
'macrs_declining_balance',
{
system,
lifeMonths: months(life),
rateMultiplier: multiplier,
switchToStraightLine: true,
defaultConvention: life >= 27.5 ? 'mid_month' : 'half_year',
...options
}
));
}
function straightLineMethods(prefix, family, labelPrefix, system, lives, options = {}) {
return lives.map((life) => method(
`${prefix}_${String(life).replace('.', '_')}`,
family,
`${labelPrefix} ${life}-year`,
'straight_line',
{
system,
lifeMonths: months(life),
defaultConvention: life >= 27.5 ? 'mid_month' : 'half_year',
...options
}
));
}
function acrsRateMethod(code, label, life, rates, options = {}) {
return method(code, 'ACRS', label, 'rate_table', {
system: 'ACRS',
lifeMonths: months(life),
defaultConvention: 'none',
rates,
...options
});
}
function buildMacrsMethods() {
return [
...macrsDeclining('macrs_gds_200db', 'MACRS GDS 200% DB', 'GDS', 2, [3, 5, 7, 10]),
...macrsDeclining('macrs_gds_150db', 'MACRS GDS 150% DB', 'GDS', 1.5, [3, 5, 7, 10, 15, 20]),
...straightLineMethods('macrs_gds_sl', 'MACRS', 'MACRS GDS straight-line', 'GDS', [3, 5, 7, 10, 15, 20, 27.5, 39]),
...straightLineMethods('macrs_ads_sl', 'MACRS', 'MACRS ADS straight-line', 'ADS', [3, 5, 7, 10, 12, 15, 20, 30, 40]),
...macrsDeclining('macrs_ads_150db_legacy', 'Legacy MACRS ADS 150% DB', 'ADS', 1.5, [3, 5, 7, 10, 12, 15, 20, 30, 40], {
legacy: true,
sourceNote: 'For property where an older ADS 150% DB election must continue.'
})
];
}
function buildAcrsMethods() {
const regular = [
acrsRateMethod('acrs_regular_3', 'ACRS regular 3-year', 3, [0.25, 0.38, 0.37]),
acrsRateMethod('acrs_regular_5', 'ACRS regular 5-year', 5, [0.15, 0.22, 0.21, 0.21, 0.21]),
acrsRateMethod('acrs_regular_10', 'ACRS regular 10-year', 10, [0.08, 0.14, 0.12, 0.10, 0.10, 0.10, 0.09, 0.09, 0.09, 0.09]),
acrsRateMethod('acrs_regular_15_real', 'ACRS regular 15-year real property', 15, [], { requiresMonthTable: true, fallbackFormula: 'straight_line' }),
acrsRateMethod('acrs_regular_15_low_income', 'ACRS regular 15-year low-income housing', 15, [], { requiresMonthTable: true, fallbackFormula: 'straight_line' }),
acrsRateMethod('acrs_regular_18_real', 'ACRS regular 18-year real property', 18, [], { requiresMonthTable: true, fallbackFormula: 'straight_line' }),
acrsRateMethod('acrs_regular_19_real', 'ACRS regular 19-year real property', 19, [], { requiresMonthTable: true, fallbackFormula: 'straight_line' }),
acrsRateMethod('acrs_regular_listed_property', 'ACRS listed property predominant-use table', 5, [], { requiresListedPropertyTable: true, fallbackFormula: 'straight_line' })
];
const alternate = straightLineMethods('acrs_alternate_sl', 'ACRS', 'ACRS alternate modified straight-line', 'ACRS', [3, 5, 10, 12, 15, 18, 19, 25], {
formula: 'acrs_alternate_straight_line',
defaultConvention: 'half_year'
});
const straightLine = straightLineMethods('acrs_required_sl', 'ACRS', 'ACRS required straight-line', 'ACRS', [3, 5, 10, 15, 18, 19, 35, 45], {
formula: 'straight_line',
defaultConvention: 'half_year',
legacy: true
});
return [...regular, ...alternate, ...straightLine];
}
function buildGaapMethods() {
return [
method('straight_line', 'GAAP', 'Straight-line', 'straight_line'),
method('sum_of_years_digits', 'GAAP', 'Sum-of-years-digits', 'sum_of_years_digits'),
method('declining_balance_200', 'GAAP', '200% declining balance', 'declining_balance', { rateMultiplier: 2 }),
method('declining_balance_175', 'GAAP', '175% declining balance', 'declining_balance', { rateMultiplier: 1.75 }),
method('declining_balance_150', 'GAAP', '150% declining balance', 'declining_balance', { rateMultiplier: 1.5 }),
method('declining_balance_125', 'GAAP', '125% declining balance', 'declining_balance', { rateMultiplier: 1.25 })
];
}
function buildSpecialMethods() {
return [
method('manual', 'Manual', 'Manual depreciation entry', 'manual'),
method('amortization', 'Lease', 'Straight-line amortization', 'straight_line')
];
}
function buildDepreciationMethods() {
return [
...buildMacrsMethods(),
...buildAcrsMethods(),
...buildGaapMethods(),
...buildSpecialMethods()
];
}
function expandRuleSet(ruleSet) {
const methods = buildDepreciationMethods();
return {
...ruleSet,
methodCatalog: ruleSet.methodCatalog || 'mixedassets-68-us-tax-and-accounting-v1',
methodCounts: {
macrs: methods.filter((item) => item.family === 'MACRS').length,
acrs: methods.filter((item) => item.family === 'ACRS').length,
gaap: methods.filter((item) => item.family === 'GAAP').length,
supplemental: methods.filter((item) => !['MACRS', 'ACRS', 'GAAP'].includes(item.family)).length,
total: methods.length
},
methods
};
}
module.exports = {
buildDepreciationMethods,
expandRuleSet
};

View File

@@ -0,0 +1,47 @@
const roles = ['admin', 'finance', 'operations', 'viewer'];
const rolePermissions = {
admin: [
'Manage users, teams, roles, settings, and integrations',
'Create, edit, delete, import, export, and assign assets',
'Manage templates, tax rules, reports, and Workday sync'
],
finance: [
'Create, edit, delete, import, and export assets',
'Manage templates, tax rules, reports, and depreciation workflows',
'Assign and release assets'
],
operations: [
'Create and edit assets',
'Assign and release assets',
'Manage asset tasks, files, employees, and custody details'
],
viewer: [
'View assets, assignments, employees, templates, tax rules, and reports',
'No create, update, delete, import, export, or integration access'
]
};
const roleCapabilities = [
{ key: 'view_assets', label: 'View assets', roles: ['admin', 'finance', 'operations', 'viewer'] },
{ key: 'edit_assets', label: 'Create/edit assets', roles: ['admin', 'finance', 'operations'] },
{ key: 'delete_assets', label: 'Delete assets', roles: ['admin', 'finance'] },
{ key: 'import_export', label: 'Import/export data', roles: ['admin', 'finance'] },
{ key: 'assign_assets', label: 'Assign assets', roles: ['admin', 'finance', 'operations'] },
{ key: 'templates', label: 'Manage templates', roles: ['admin', 'finance'] },
{ key: 'tax_rules', label: 'Manage tax rules', roles: ['admin', 'finance'] },
{ key: 'workday', label: 'Manage Workday connector', roles: ['admin'] },
{ key: 'users', label: 'Manage users and teams', roles: ['admin'] },
{ key: 'settings', label: 'Manage app settings', roles: ['admin'] }
];
function isValidRole(role) {
return roles.includes(role);
}
module.exports = {
isValidRole,
roleCapabilities,
rolePermissions,
roles
};

260
server/services/assets.js Normal file
View File

@@ -0,0 +1,260 @@
const { all, audit, json, now, one, parseJson, run, tx } = require('../db');
const assetColumns = [
'asset_id',
'entity_id',
'template_id',
'description',
'category',
'status',
'acquisition_cost',
'acquired_date',
'in_service_date',
'useful_life_months',
'salvage_value',
'land_value',
'prior_depreciation',
'location',
'department',
'group_name',
'custodian',
'vendor',
'serial_number',
'invoice_number',
'gl_asset_account',
'gl_expense_account',
'gl_accumulated_account',
'barcode_value',
'condition',
'new_or_used',
'listed_property',
'business_use_percent',
'investment_use_percent',
'disposal_date',
'disposal_price',
'disposal_expense',
'disposal_type',
'property_type',
'notes',
'custom_fields'
];
const defaultBooks = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
function assetFromRow(row) {
if (!row) return null;
return {
...row,
listed_property: Boolean(row.listed_property),
custom_fields: parseJson(row.custom_fields, {}),
books: row.books ? parseJson(row.books, []) : undefined
};
}
function hydrateAsset(id) {
const asset = assetFromRow(one('SELECT * FROM assets WHERE id = ?', [id]));
if (!asset) return null;
asset.books = all('SELECT * FROM asset_books WHERE asset_id = ? ORDER BY book_type', [id]).map((book) => ({
...book,
active: Boolean(book.active),
manual_depreciation: parseJson(book.manual_depreciation, {})
}));
asset.leases = all('SELECT * FROM leases WHERE asset_id = ? ORDER BY start_date DESC', [id]);
asset.tasks = all('SELECT * FROM tasks WHERE asset_id = ? ORDER BY due_date IS NULL, due_date', [id]);
asset.warranties = all('SELECT id, provider, phone, start_date, expiration_date, warranty_limit, actual_usage, coverage_details, document_name, created_at FROM warranties WHERE asset_id = ?', [id]);
asset.files = all('SELECT id, file_name, mime_type, size, uploaded_at FROM asset_files WHERE asset_id = ?', [id]);
return asset;
}
function nextAssetId() {
const latest = one("SELECT asset_id FROM assets WHERE asset_id LIKE 'MA-%' ORDER BY id DESC LIMIT 1");
const number = latest ? Number(String(latest.asset_id).replace('MA-', '')) + 1 : 1;
return `MA-${String(number).padStart(5, '0')}`;
}
function normalizeAssetInput(body) {
const input = {};
for (const column of assetColumns) {
if (Object.prototype.hasOwnProperty.call(body, column)) input[column] = body[column];
}
input.description = input.description || body.name || 'Untitled asset';
input.category = input.category || 'Uncategorized';
input.status = input.status || 'in_service';
input.asset_id = input.asset_id || nextAssetId();
input.entity_id = input.entity_id || one('SELECT id FROM entities ORDER BY id LIMIT 1')?.id;
input.acquisition_cost = Number(input.acquisition_cost || 0);
input.useful_life_months = Number(input.useful_life_months || 60);
input.salvage_value = Number(input.salvage_value || 0);
input.land_value = Number(input.land_value || 0);
input.prior_depreciation = Number(input.prior_depreciation || 0);
input.business_use_percent = Number(input.business_use_percent || 100);
input.investment_use_percent = Number(input.investment_use_percent || 0);
input.custom_fields = json(input.custom_fields || {});
input.listed_property = input.listed_property ? 1 : 0;
return input;
}
function createBooks(assetId, asset, books = []) {
const selectedBooks = books.length ? books : defaultBooks.map((book) => ({ book_type: book }));
for (const book of selectedBooks) {
run(
`INSERT OR REPLACE INTO asset_books (
asset_id, book_type, active, cost, depreciation_method, convention, useful_life_months,
section_179_amount, bonus_percent, business_use_percent, investment_use_percent, manual_depreciation
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
assetId,
book.book_type || book.book || 'GAAP',
book.active === false ? 0 : 1,
book.cost ?? asset.acquisition_cost,
book.depreciation_method || asset.depreciation_method || 'straight_line',
book.convention || asset.convention || 'half_year',
book.useful_life_months || asset.useful_life_months,
Number(book.section_179_amount || 0),
Number(book.bonus_percent || 0),
book.business_use_percent ?? asset.business_use_percent ?? 100,
book.investment_use_percent ?? asset.investment_use_percent ?? 0,
json(book.manual_depreciation || {})
]
);
}
}
function listAssets(query = {}) {
return all(`
SELECT a.*, e.name AS entity_name
FROM assets a
LEFT JOIN entities e ON e.id = a.entity_id
WHERE (? IS NULL OR a.status = ?)
AND (? IS NULL OR a.entity_id = ?)
AND (? IS NULL OR a.category = ?)
AND (? IS NULL OR a.description LIKE '%' || ? || '%' OR a.asset_id LIKE '%' || ? || '%')
ORDER BY a.updated_at DESC
LIMIT 500
`, [
query.status || null,
query.status || null,
query.entity_id || null,
query.entity_id || null,
query.category || null,
query.category || null,
query.search || null,
query.search || null,
query.search || null
]).map(assetFromRow);
}
function createAsset(body, userId) {
const created = now();
const input = normalizeAssetInput(body);
const result = tx(() => {
const insert = run(
`INSERT INTO assets (${assetColumns.join(', ')}, created_by, created_at, updated_at)
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
[...assetColumns.map((column) => input[column] ?? null), userId, created, created]
);
createBooks(insert.lastInsertRowid, input, body.books || []);
return insert;
});
const asset = hydrateAsset(result.lastInsertRowid);
audit(userId, 'create', 'asset', asset.id, null, asset);
return asset;
}
function updateAsset(id, body, userId) {
const before = hydrateAsset(id);
if (!before) return null;
const input = normalizeAssetInput({ ...before, ...body });
const updated = now();
tx(() => {
run(
`UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
[...assetColumns.map((column) => input[column] ?? null), updated, id]
);
if (Array.isArray(body.books)) createBooks(id, input, body.books);
});
const asset = hydrateAsset(id);
audit(userId, 'update', 'asset', asset.id, before, asset);
return asset;
}
function deleteAsset(id, userId) {
const before = hydrateAsset(id);
if (!before) return false;
run('DELETE FROM assets WHERE id = ?', [id]);
audit(userId, 'delete', 'asset', id, before, null);
return true;
}
function massUpdateAssets(ids, changes, userId) {
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));
if (!ids.length || !columns.length) return 0;
tx(() => {
for (const id of ids) {
run(
`UPDATE assets SET ${columns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
[...columns.map((column) => changes[column]), now(), id]
);
}
});
audit(userId, 'mass_update', 'asset', ids.join(','), null, { ids, changes });
return ids.length;
}
function assetExportRows() {
return all(`
SELECT a.*, e.name AS entity_name
FROM assets a
LEFT JOIN entities e ON e.id = a.entity_id
ORDER BY a.asset_id
`).map((asset) => ({
...asset,
listed_property: Boolean(asset.listed_property),
custom_fields: parseJson(asset.custom_fields, {})
}));
}
function upsertImportedAssets(rows, userId) {
let imported = 0;
tx(() => {
for (const row of rows) {
const input = normalizeAssetInput(row);
const existing = one('SELECT id FROM assets WHERE asset_id = ?', [input.asset_id]);
if (existing) {
run(
`UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
[...assetColumns.map((column) => input[column] ?? null), now(), existing.id]
);
} else {
const result = run(
`INSERT INTO assets (${assetColumns.join(', ')}, created_by, created_at, updated_at)
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
[...assetColumns.map((column) => input[column] ?? null), userId, now(), now()]
);
createBooks(result.lastInsertRowid, input, []);
}
imported += 1;
}
});
return imported;
}
module.exports = {
assetColumns,
assetExportRows,
assetFromRow,
createAsset,
createBooks,
defaultBooks,
deleteAsset,
hydrateAsset,
listAssets,
massUpdateAssets,
normalizeAssetInput,
upsertImportedAssets,
updateAsset
};

View File

@@ -0,0 +1,205 @@
const { all, audit, json, now, one, parseJson, run, tx } = require('../db');
function employeeFromRow(row) {
if (!row) return null;
return {
...row,
source_payload: parseJson(row.source_payload, null)
};
}
function assignmentFromRow(row) {
if (!row) return null;
return {
...row,
active: !row.released_at
};
}
function listEmployees(query = {}) {
return all(`
SELECT *
FROM employees
WHERE (? IS NULL OR status = ?)
AND (? IS NULL OR name LIKE '%' || ? || '%' OR email LIKE '%' || ? || '%' OR employee_number LIKE '%' || ? || '%')
ORDER BY name
LIMIT 500
`, [
query.status || null,
query.status || null,
query.search || null,
query.search || null,
query.search || null,
query.search || null
]).map(employeeFromRow);
}
function upsertEmployee(employee) {
const timestamp = now();
const workdayId = employee.workday_worker_id || employee.workdayWorkerId || employee.workerId || null;
const existing = workdayId
? one('SELECT id FROM employees WHERE workday_worker_id = ?', [workdayId])
: one('SELECT id FROM employees WHERE employee_number = ? OR lower(email) = lower(?)', [employee.employee_number || '', employee.email || '']);
const values = [
workdayId,
employee.employee_number || employee.employeeNumber || employee.workerNumber || null,
employee.name || employee.fullName || [employee.firstName, employee.lastName].filter(Boolean).join(' ') || 'Unnamed employee',
employee.email || employee.workEmail || null,
employee.department || employee.organization || null,
employee.location || employee.workLocation || null,
employee.manager_name || employee.managerName || null,
employee.status || 'active',
employee.source || 'manual',
json(employee.source_payload || employee),
employee.last_synced_at || null,
timestamp
];
if (existing) {
run(
`UPDATE employees SET
workday_worker_id = COALESCE(?, workday_worker_id),
employee_number = ?,
name = ?,
email = ?,
department = ?,
location = ?,
manager_name = ?,
status = ?,
source = ?,
source_payload = ?,
last_synced_at = COALESCE(?, last_synced_at),
updated_at = ?
WHERE id = ?`,
[...values, existing.id]
);
return one('SELECT * FROM employees WHERE id = ?', [existing.id]);
}
const result = run(
`INSERT INTO employees (
workday_worker_id, employee_number, name, email, department, location, manager_name,
status, source, source_payload, last_synced_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[...values, timestamp]
);
return one('SELECT * FROM employees WHERE id = ?', [result.lastInsertRowid]);
}
function assignmentRows(query = {}) {
return all(`
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
FROM asset_assignments aa
JOIN assets a ON a.id = aa.asset_id
LEFT JOIN employees e ON e.id = aa.employee_id
WHERE (? IS NULL OR aa.asset_id = ?)
AND (? IS NULL OR aa.employee_id = ?)
AND (? IS NULL OR aa.released_at IS NULL)
ORDER BY aa.assigned_at DESC, aa.id DESC
LIMIT 500
`, [
query.asset_id || null,
query.asset_id || null,
query.employee_id || null,
query.employee_id || null,
query.active ? 1 : null
]).map(assignmentFromRow);
}
function assignAsset(body, userId) {
const timestamp = now();
const asset = one('SELECT * FROM assets WHERE id = ?', [body.asset_id]);
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 department = body.department ?? employee?.department ?? asset.department ?? null;
const location = body.location ?? employee?.location ?? asset.location ?? null;
return tx(() => {
run(
`UPDATE asset_assignments
SET released_at = ?, status = 'released', release_reason = 'Reassigned', updated_at = ?
WHERE asset_id = ? AND released_at IS NULL`,
[timestamp, timestamp, body.asset_id]
);
const result = run(
`INSERT INTO asset_assignments (
asset_id, employee_id, department, location, status, assigned_at, assigned_by, notes, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
body.asset_id,
body.employee_id || null,
department,
location,
'assigned',
body.assigned_at || timestamp,
userId,
body.notes || null,
timestamp,
timestamp
]
);
run(
`UPDATE assets SET
custodian = ?,
department = ?,
location = ?,
status = 'in_service',
updated_at = ?
WHERE id = ?`,
[employee?.name || asset.custodian || null, department, location, timestamp, body.asset_id]
);
const assignment = assignmentRows({ asset_id: body.asset_id, active: true })[0];
audit(userId, 'assign', 'asset', body.asset_id, null, assignment);
return assignment || one('SELECT * FROM asset_assignments WHERE id = ?', [result.lastInsertRowid]);
});
}
function releaseAssignment(id, body, userId) {
const before = one('SELECT * FROM asset_assignments WHERE id = ?', [id]);
if (!before) return null;
const timestamp = now();
run(
`UPDATE asset_assignments
SET released_at = ?, status = 'released', release_reason = ?, notes = COALESCE(?, notes), updated_at = ?
WHERE id = ?`,
[timestamp, body.release_reason || 'Released', body.notes || null, timestamp, id]
);
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));
}
function assignmentSummary() {
const stats = one(`
SELECT
COUNT(*) AS active_assignments,
COUNT(DISTINCT employee_id) AS assigned_employees,
COUNT(DISTINCT location) AS assigned_locations
FROM asset_assignments
WHERE released_at IS NULL
`);
const unassigned = one(`
SELECT COUNT(*) AS count
FROM assets a
WHERE NOT EXISTS (
SELECT 1 FROM asset_assignments aa
WHERE aa.asset_id = a.id AND aa.released_at IS NULL
)
`);
return { ...stats, unassigned_assets: unassigned.count };
}
module.exports = {
assignAsset,
assignmentRows,
assignmentSummary,
employeeFromRow,
listEmployees,
releaseAssignment,
upsertEmployee
};

View File

@@ -0,0 +1,80 @@
const path = require('path');
const readXlsxFile = require('read-excel-file/node');
const writeXlsxFile = require('write-excel-file/node');
const Papa = require('papaparse');
const { XMLBuilder, XMLParser } = require('fast-xml-parser');
const { now } = require('../db');
const { assetExportRows } = require('./assets');
async function rowsFromImport(format, buffer) {
const text = buffer.toString('utf8');
if (format === 'json') {
const parsed = JSON.parse(text);
return Array.isArray(parsed) ? parsed : parsed.assets || [];
}
if (format === 'xml') {
const parsed = new XMLParser({ ignoreAttributes: false }).parse(text);
const assets = parsed.assets?.asset || parsed.MixedAssets?.assets?.asset || [];
return Array.isArray(assets) ? assets : [assets];
}
if (format === 'xlsx' || format === 'xls') {
const rows = await readXlsxFile(buffer);
const headers = (rows.shift() || []).map((header) => String(header || '').trim());
return rows.map((row) => Object.fromEntries(headers.map((header, index) => [header, row[index]])));
}
return Papa.parse(text, { header: true, skipEmptyLines: true }).data;
}
async function assetExport(format) {
const rows = assetExportRows();
if (format === 'csv') {
return {
contentType: 'text/csv',
filename: 'mixedassets-assets.csv',
body: Papa.unparse(rows)
};
}
if (format === 'xlsx') {
const columns = Object.keys(rows[0] || {
asset_id: '',
description: '',
category: '',
status: '',
acquisition_cost: '',
in_service_date: ''
});
const sheetData = [
columns.map((column) => ({ value: column, fontWeight: 'bold' })),
...rows.map((row) => columns.map((column) => ({
value: typeof row[column] === 'object' && row[column] !== null ? JSON.stringify(row[column]) : row[column]
})))
];
return {
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
filename: 'mixedassets-assets.xlsx',
body: await writeXlsxFile(sheetData).toBuffer()
};
}
if (format === 'xml') {
return {
contentType: 'application/xml',
filename: 'mixedassets-assets.xml',
body: new XMLBuilder({ format: true }).build({ assets: { asset: rows } })
};
}
return {
contentType: 'application/json',
filename: 'mixedassets-assets.json',
body: JSON.stringify({ exportedAt: now(), assets: rows })
};
}
function extensionForUpload(fileName) {
return path.extname(fileName).replace('.', '').toLowerCase() || 'csv';
}
module.exports = {
assetExport,
extensionForUpload,
rowsFromImport
};

View File

@@ -0,0 +1,44 @@
const { json, now, one, parseJson, run } = require('../db');
const { publicUser } = require('../middleware/auth');
const defaultPreferences = {
theme: 'mixedAssetsDark'
};
function getPreferences(userId) {
const row = one('SELECT preferences_json FROM user_preferences WHERE user_id = ?', [userId]);
return {
...defaultPreferences,
...parseJson(row?.preferences_json, {})
};
}
function getProfile(user) {
return {
user: publicUser(user),
preferences: getPreferences(user.id)
};
}
function savePreferences(userId, preferences) {
const merged = {
...getPreferences(userId),
...(preferences || {})
};
run(
`INSERT INTO user_preferences (user_id, preferences_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
preferences_json = excluded.preferences_json,
updated_at = excluded.updated_at`,
[userId, json(merged), now()]
);
return merged;
}
module.exports = {
defaultPreferences,
getProfile,
getPreferences,
savePreferences
};

107
server/services/reports.js Normal file
View File

@@ -0,0 +1,107 @@
const PDFDocument = require('pdfkit');
const { all, one, parseJson } = require('../db');
const { annualSchedule, money, monthlyFromAnnual } = require('../depreciation');
const { assetExportRows, assetFromRow } = require('./assets');
function activeRuleSet() {
const row = one('SELECT * FROM tax_rule_sets WHERE active = 1 ORDER BY effective_date DESC, id DESC LIMIT 1');
return row ? parseJson(row.rules_json, {}) : {};
}
function depreciationReport(year, bookType) {
const rules = activeRuleSet();
const rows = all(`
SELECT a.*, b.book_type, b.active, b.cost, b.depreciation_method, b.convention, b.useful_life_months AS book_life,
b.section_179_amount, b.bonus_percent, b.business_use_percent AS book_business_use_percent,
b.investment_use_percent AS book_investment_use_percent, b.manual_depreciation, e.name AS entity_name
FROM assets a
JOIN asset_books b ON b.asset_id = a.id
LEFT JOIN entities e ON e.id = a.entity_id
WHERE b.active = 1 AND b.book_type = ?
ORDER BY a.asset_id
`, [bookType]);
const reportRows = rows.map((row) => {
const asset = assetFromRow(row);
const book = {
...row,
useful_life_months: row.book_life,
business_use_percent: row.book_business_use_percent,
investment_use_percent: row.book_investment_use_percent
};
const schedule = annualSchedule(asset, book, rules, { startYear: year, endYear: year })[0] || {};
return {
asset_id: row.asset_id,
description: row.description,
entity_name: row.entity_name,
category: row.category,
book: bookType,
method: row.depreciation_method,
methodLabel: schedule.methodLabel || row.depreciation_method,
cost: money(row.cost ?? row.acquisition_cost),
depreciation: money(schedule.depreciation || 0),
accumulated: money(schedule.accumulated || 0),
net_book_value: money(schedule.netBookValue ?? row.acquisition_cost)
};
});
return {
year,
book: bookType,
totals: {
cost: money(reportRows.reduce((sum, row) => sum + row.cost, 0)),
depreciation: money(reportRows.reduce((sum, row) => sum + row.depreciation, 0)),
accumulated: money(reportRows.reduce((sum, row) => sum + row.accumulated, 0)),
netBookValue: money(reportRows.reduce((sum, row) => sum + row.net_book_value, 0))
},
rows: reportRows
};
}
function monthlyDepreciationReport(year, bookType) {
const rules = activeRuleSet();
const assets = all('SELECT * FROM assets ORDER BY asset_id').map(assetFromRow);
const rows = assets.flatMap((asset) => {
const book = one('SELECT * FROM asset_books WHERE asset_id = ? AND book_type = ? AND active = 1', [asset.id, bookType]);
if (!book) return [];
return annualSchedule(asset, book, rules, { startYear: year, endYear: year }).flatMap(monthlyFromAnnual);
});
const months = Array.from({ length: 12 }, (_, index) => {
const month = index + 1;
return {
month,
depreciation: money(rows.filter((row) => row.month === month).reduce((sum, row) => sum + row.depreciation, 0))
};
});
return { year, book: bookType, months };
}
function writeDepreciationPdf(res, year, book) {
const rows = assetExportRows();
const doc = new PDFDocument({ margin: 36, size: 'LETTER' });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="mixedassets-${book}-${year}.pdf"`);
doc.pipe(res);
doc.fontSize(18).text('MixedAssets Depreciation Report');
doc.fontSize(10).text(`Book: ${book} Year: ${year} Generated: ${new Date().toLocaleString()}`);
doc.moveDown();
doc.fontSize(9).text('Asset ID', 36, doc.y, { continued: true, width: 80 });
doc.text('Description', 100, doc.y, { continued: true, width: 210 });
doc.text('Category', 300, doc.y, { continued: true, width: 110 });
doc.text('Cost', 430, doc.y, { align: 'right' });
doc.moveDown(0.5);
rows.slice(0, 80).forEach((row) => {
doc.text(row.asset_id, 36, doc.y, { continued: true, width: 80 });
doc.text(String(row.description).slice(0, 36), 100, doc.y, { continued: true, width: 210 });
doc.text(String(row.category).slice(0, 20), 300, doc.y, { continued: true, width: 110 });
doc.text(money(row.acquisition_cost).toLocaleString(), 430, doc.y, { align: 'right' });
});
doc.end();
}
module.exports = {
activeRuleSet,
depreciationReport,
monthlyDepreciationReport,
writeDepreciationPdf
};

148
server/services/workday.js Normal file
View File

@@ -0,0 +1,148 @@
const { audit, json, now, one, run } = require('../db');
const { upsertEmployee } = require('./assignments');
function publicConnection(row) {
if (!row) return null;
return {
id: row.id,
name: row.name,
tenant: row.tenant,
base_url: row.base_url,
token_url: row.token_url,
client_id: row.client_id,
workers_path: row.workers_path,
enabled: Boolean(row.enabled),
last_sync_at: row.last_sync_at,
last_sync_status: row.last_sync_status,
configured: Boolean(row.base_url && row.token_url && row.client_id && row.client_secret)
};
}
function getConnection() {
return publicConnection(one('SELECT * FROM workday_connections ORDER BY id LIMIT 1'));
}
function getPrivateConnection() {
return one('SELECT * FROM workday_connections ORDER BY id LIMIT 1');
}
function saveConnection(body, userId) {
const timestamp = now();
const existing = getPrivateConnection();
const payload = {
name: body.name || existing?.name || 'Primary Workday',
tenant: body.tenant ?? existing?.tenant ?? '',
base_url: body.base_url ?? existing?.base_url ?? '',
token_url: body.token_url ?? existing?.token_url ?? '',
client_id: body.client_id ?? existing?.client_id ?? '',
client_secret: body.client_secret ? body.client_secret : existing?.client_secret ?? '',
workers_path: body.workers_path ?? existing?.workers_path ?? '/workers',
enabled: body.enabled === undefined ? Number(existing?.enabled || 0) : (body.enabled ? 1 : 0)
};
if (existing) {
run(
`UPDATE workday_connections SET
name = ?, tenant = ?, base_url = ?, token_url = ?, client_id = ?, client_secret = ?,
workers_path = ?, enabled = ?, updated_at = ?
WHERE id = ?`,
[payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, timestamp, existing.id]
);
} else {
run(
`INSERT INTO workday_connections (
name, tenant, base_url, token_url, client_id, client_secret, workers_path, enabled, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, timestamp, timestamp]
);
}
const saved = getConnection();
audit(userId, 'update', 'workday_connection', saved.id, null, { ...saved, client_secret: '[redacted]' });
return saved;
}
async function fetchAccessToken(connection) {
const basic = Buffer.from(`${connection.client_id}:${connection.client_secret}`).toString('base64');
const response = await fetch(connection.token_url, {
method: 'POST',
headers: {
Authorization: `Basic ${basic}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({ grant_type: 'client_credentials' })
});
if (!response.ok) {
throw new Error(`Workday token request failed: ${response.status}`);
}
const body = await response.json();
if (!body.access_token) throw new Error('Workday token response did not include an access token');
return body.access_token;
}
function normalizeWorkers(payload) {
const rows = Array.isArray(payload)
? payload
: payload.workers || payload.Workers || payload.data || payload.value || [];
return rows.map((worker) => ({
workday_worker_id: worker.id || worker.workerId || worker.worker_id || worker.Worker_ID || worker.workerReference?.id,
employee_number: worker.employeeNumber || worker.employee_number || worker.Worker_ID || worker.workerNumber,
name: worker.name || worker.fullName || worker.Full_Name || worker.descriptor || [worker.firstName, worker.lastName].filter(Boolean).join(' '),
email: worker.email || worker.workEmail || worker.primaryWorkEmail || worker.Email,
department: worker.department || worker.organization || worker.Department || worker.supervisoryOrganization?.descriptor,
location: worker.location || worker.workLocation || worker.Location || worker.location?.descriptor,
manager_name: worker.managerName || worker.manager?.descriptor,
status: worker.status || (worker.active === false ? 'inactive' : 'active'),
source: 'workday',
source_payload: worker,
last_synced_at: now()
}));
}
async function syncWorkers(userId) {
const connection = getPrivateConnection();
if (!connection || !connection.enabled) throw Object.assign(new Error('Workday connection is not enabled'), { status: 400 });
if (!connection.base_url || !connection.token_url || !connection.client_id || !connection.client_secret) {
throw Object.assign(new Error('Workday connection is missing URL or OAuth credentials'), { status: 400 });
}
const token = await fetchAccessToken(connection);
const url = new URL(connection.workers_path || '/workers', connection.base_url);
const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json'
}
});
if (!response.ok) {
throw new Error(`Workday workers request failed: ${response.status}`);
}
const workers = normalizeWorkers(await response.json());
for (const worker of workers) upsertEmployee(worker);
const timestamp = now();
run('UPDATE workday_connections SET last_sync_at = ?, last_sync_status = ?, updated_at = ? WHERE id = ?', [
timestamp,
`Imported ${workers.length} workers`,
timestamp,
connection.id
]);
audit(userId, 'sync', 'workday_workers', connection.id, null, { imported: workers.length });
return { imported: workers.length, workers };
}
function importWorkersFromPayload(workers, userId) {
const normalized = normalizeWorkers(workers);
for (const worker of normalized) upsertEmployee(worker);
audit(userId, 'import', 'workday_workers', null, null, { imported: normalized.length, source: 'payload' });
return { imported: normalized.length };
}
module.exports = {
getConnection,
importWorkersFromPayload,
saveConnection,
syncWorkers
};

207
server/smoke-test.js Normal file
View File

@@ -0,0 +1,207 @@
const { spawn } = require('child_process');
const port = 3999;
const baseUrl = `http://127.0.0.1:${port}`;
const child = spawn(process.execPath, ['server/index.js'], {
cwd: process.cwd(),
env: { ...process.env, PORT: String(port) },
stdio: ['ignore', 'pipe', 'pipe']
});
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function request(path, options = {}) {
const response = await fetch(`${baseUrl}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
}
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`${path} failed: ${response.status} ${JSON.stringify(body)}`);
}
return body;
}
async function waitForServer() {
for (let attempt = 0; attempt < 60; attempt += 1) {
try {
await request('/api/health');
return;
} catch {
await wait(250);
}
}
throw new Error('Server did not start');
}
async function main() {
await waitForServer();
const login = await request('/api/auth/login', {
method: 'POST',
body: JSON.stringify({
email: 'admin@mixedassets.local',
password: process.env.MIXEDASSETS_ADMIN_PASSWORD || 'ChangeMe123!'
})
});
const auth = { Authorization: `Bearer ${login.token}` };
const profile = await request('/api/profile', { headers: auth });
if (profile.preferences.theme !== 'mixedAssetsDark') {
throw new Error(`Expected default dark theme, found ${profile.preferences.theme}`);
}
const updatedProfile = await request('/api/profile', {
method: 'PUT',
headers: auth,
body: JSON.stringify({ preferences: { theme: 'mixedAssetsDark' } })
});
if (updatedProfile.preferences.theme !== 'mixedAssetsDark') {
throw new Error('Profile theme preference did not persist');
}
const taxRules = await request('/api/tax-rules', { headers: auth });
const activeRule = taxRules.ruleSets.find((rule) => rule.active) || taxRules.ruleSets[0];
const methodCount = activeRule?.rules_json?.methods?.length || 0;
if (methodCount !== 68) {
throw new Error(`Expected 68 depreciation methods, found ${methodCount}`);
}
const suffix = Date.now().toString().slice(-6);
const accessControl = await request('/api/access-control', { headers: auth });
if (!accessControl.roles.includes('admin') || !accessControl.roleCapabilities.some((capability) => capability.key === 'users')) {
throw new Error('Access control role matrix was not returned correctly');
}
const teamResult = await request('/api/teams', {
method: 'POST',
headers: auth,
body: JSON.stringify({
name: `Smoke Team ${suffix}`,
description: 'Smoke test RBAC team'
})
});
const userResult = await request('/api/users', {
method: 'POST',
headers: auth,
body: JSON.stringify({
team_id: teamResult.team.id,
name: 'Smoke Test Viewer',
email: `viewer-${suffix}@example.com`,
password: 'ChangeMe123!',
role: 'viewer',
status: 'active'
})
});
const updatedUser = await request(`/api/users/${userResult.user.id}`, {
method: 'PUT',
headers: auth,
body: JSON.stringify({
team_id: teamResult.team.id,
name: 'Smoke Test Operator',
email: `operator-${suffix}@example.com`,
role: 'operations',
status: 'active'
})
});
if (updatedUser.user.role !== 'operations' || updatedUser.user.team_id !== teamResult.team.id) {
throw new Error('User role or team update did not persist');
}
await request('/api/templates', {
method: 'POST',
headers: auth,
body: JSON.stringify({
name: `Smoke template ${suffix}`,
description: 'Template custom field smoke test',
defaults: { category: 'Computer Equipment', useful_life_months: 60, depreciation_method: 'macrs_gds_200db_5' },
custom_fields: [
{ key: 'employee_id', label: 'Employee ID', type: 'text', required: true },
{ key: 'calibration_due', label: 'Calibration due', type: 'date', required: false }
]
})
});
const templates = await request('/api/templates', { headers: auth });
const smokeTemplate = templates.templates.find((template) => template.name === `Smoke template ${suffix}`);
if (!smokeTemplate || smokeTemplate.custom_fields.length !== 2) {
throw new Error('Template custom fields were not saved or returned correctly');
}
const assetResult = await request('/api/assets', {
method: 'POST',
headers: auth,
body: JSON.stringify({
asset_id: `T-${suffix}`,
description: 'Smoke test workstation',
category: 'Computer Equipment',
acquisition_cost: 2400,
acquired_date: '2026-01-05',
in_service_date: '2026-01-05',
useful_life_months: 60,
depreciation_method: 'macrs_gds_200db_5'
})
});
const employeeResult = await request('/api/employees', {
method: 'POST',
headers: auth,
body: JSON.stringify({
employee_number: `EMP-${suffix}`,
name: 'Smoke Test Employee',
email: `employee-${suffix}@example.com`,
department: 'Operations',
location: 'Headquarters'
})
});
await request('/api/assignments', {
method: 'POST',
headers: auth,
body: JSON.stringify({
asset_id: assetResult.asset.id,
employee_id: employeeResult.employee.id,
notes: 'Smoke assignment'
})
});
const assignments = await request('/api/assignments?active=true', { headers: auth });
if (!assignments.assignments.some((assignment) => assignment.asset_id === assetResult.asset.id && assignment.employee_id === employeeResult.employee.id)) {
throw new Error('Asset assignment was not saved or returned correctly');
}
const workday = await request('/api/workday/connection', {
method: 'PUT',
headers: auth,
body: JSON.stringify({
name: 'Smoke Workday',
tenant: 'smoke',
base_url: 'https://example.workday.com',
token_url: 'https://example.workday.com/oauth2/token',
client_id: 'client',
client_secret: 'secret',
workers_path: '/workers',
enabled: false
})
});
if (!workday.connection.configured) {
throw new Error('Workday connection configuration did not persist');
}
const report = await request('/api/reports/depreciation?year=2026&book=GAAP', { headers: auth });
if (!Array.isArray(report.rows) || !report.rows.length) {
throw new Error('Depreciation report returned no rows');
}
console.log('Smoke test passed');
}
main()
.catch((error) => {
console.error(error.message);
process.exitCode = 1;
})
.finally(() => {
child.kill('SIGTERM');
});

657
src/App.vue Normal file
View File

@@ -0,0 +1,657 @@
<template>
<fluent-design-system-provider accent-base-color="#2457a6" :base-layer-luminance="fluentLuminance">
<v-app :theme="activeTheme">
<LoginView v-if="!token" :error="error" :form="loginForm" :loading="loading" @login="login" />
<div v-else class="app-shell">
<v-navigation-drawer class="rail" width="254" permanent>
<div class="brand-lockup">
<span class="brand-mark">MA</span>
<span class="brand-name">MixedAssets</span>
</div>
<v-divider />
<v-list nav density="compact" class="pa-3">
<v-list-item
v-for="item in visibleNavItems"
:key="item.key"
:active="activeView === item.key"
:prepend-icon="item.icon"
:title="item.label"
rounded="sm"
@click="activeView = item.key"
/>
</v-list>
<template #append>
<div class="pa-4">
<div class="text-body-2 font-weight-bold">{{ user?.name }}</div>
<div class="text-caption quiet">{{ user?.role }}</div>
<v-btn class="mt-3" block variant="tonal" prepend-icon="mdi-logout" @click="logout">Sign out</v-btn>
</div>
</template>
</v-navigation-drawer>
<TopNav
:preferences="profilePreferences"
:saving="preferenceSaving"
:subtitle="currentSubtitle"
:theme-items="themeItems"
:title="currentTitle"
:user="user"
@logout="logout"
@save-preferences="saveProfilePreferences"
>
<template #actions>
<v-btn v-if="activeView === 'assets' && canEditAssets" color="primary" prepend-icon="mdi-plus" @click="newAsset">New asset</v-btn>
<v-btn v-if="activeView === 'reports'" color="secondary" prepend-icon="mdi-file-pdf-box" @click="downloadPdf">PDF</v-btn>
</template>
</TopNav>
<v-main>
<DashboardView
v-if="activeView === 'dashboard'"
:category-chart="categoryChart"
:chart-options="chartOptions"
:currency="currency"
:dashboard="dashboard"
:short-date="shortDate"
/>
<AssetsView
v-if="activeView === 'assets'"
:all-selected="allSelected"
:assets="assets"
:asset-filters="assetFilters"
:currency="currency"
:selected-asset-ids="selectedAssetIds"
:status-items="statusItems"
@edit-asset="editAsset"
@export-assets="exportAssets"
@filter-assets="filterAssets"
@import-assets="importAssets"
@open-mass-edit="massDialog = true"
@toggle-all="toggleAll"
@toggle-asset="toggleAsset"
/>
<AssignmentsView
v-if="activeView === 'assignments'"
:assignments="assignments"
:assets="assets"
:assignment-saving="assignmentSaving"
:employees="employees"
:short-date="shortDate"
:summary="assignmentSummary"
@add-employee="addEmployee"
@assign-asset="assignAsset"
@release-assignment="releaseAssignment"
@reload="loadAssignments"
/>
<ReportsView
v-if="activeView === 'reports'"
:book-items="bookItems"
:chart-options="chartOptions"
:currency="currency"
:depreciation-report="depreciationReport"
:monthly-chart="monthlyChart"
:report-filters="reportFilters"
@filter-reports="filterReports"
/>
<TemplatesView
v-if="activeView === 'templates'"
:category-items="categoryItems"
:custom-field-types="customFieldTypes"
:method-items="methodItems"
:template-form="templateForm"
:templates="templates"
@save-template="saveTemplate"
/>
<AdminView
v-if="activeView === 'admin'"
:role-capabilities="roleCapabilities"
:role-permissions="rolePermissions"
:roles="roles"
:short-date="shortDate"
:settings="settings"
:tax-rules="taxRules"
:team-saving="teamSaving"
:teams="teams"
:user-saving="userSaving"
:users="users"
:workday-connection="workdayConnection"
:workday-saving="workdaySaving"
:workday-syncing="workdaySyncing"
@create-team="createTeam"
@create-user="createUser"
@save-settings="saveSettings"
@save-workday="saveWorkdayConnection"
@sync-workday="syncWorkday"
@update-user="updateUser"
/>
</v-main>
<AssetDrawer
v-model="assetDrawer"
:asset-form="assetForm"
:category-items="categoryItems"
:drawer-error="drawerError"
:entity-options="entityOptions"
:method-items="methodItems"
:saving="saving"
:selected-template-fields="selectedTemplateFields"
:selected-template-id="selectedTemplateId"
:status-items="statusItems"
:template-options="templateOptions"
@save-asset="saveAsset"
@select-template="applyTemplate"
/>
<MassEditDialog
v-model="massDialog"
:changes="massChanges"
:status-items="statusItems"
@apply="massUpdate"
/>
</div>
</v-app>
</fluent-design-system-provider>
</template>
<script>
import AdminView from './views/AdminView.vue';
import AssignmentsView from './views/AssignmentsView.vue';
import AssetsView from './views/AssetsView.vue';
import DashboardView from './views/DashboardView.vue';
import LoginView from './views/LoginView.vue';
import ReportsView from './views/ReportsView.vue';
import TemplatesView from './views/TemplatesView.vue';
import AssetDrawer from './components/AssetDrawer.vue';
import MassEditDialog from './components/MassEditDialog.vue';
import TopNav from './components/TopNav.vue';
import { apiRequest, loginRequest, saveBlob } from './services/api';
import { blankAsset, defaultValueForField, normalizeCustomFields } from './utils/assets';
import { currency, shortDate } from './utils/format';
import { bookItems, customFieldTypes, navItems, statusItems, themeItems } from './constants';
const emptyStats = {
asset_count: 0,
total_cost: 0,
disposed_count: 0,
in_service_count: 0
};
export default {
components: {
AdminView,
AssignmentsView,
AssetDrawer,
AssetsView,
DashboardView,
LoginView,
MassEditDialog,
ReportsView,
TemplatesView,
TopNav
},
data() {
return {
activeView: 'dashboard',
assets: [],
assetDrawer: false,
assetFilters: { search: '', status: null },
assetForm: blankAsset(),
assignments: [],
assignmentSaving: false,
assignmentSummary: {},
bookItems,
chartOptions: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { callback: (value) => `$${Number(value).toLocaleString()}` } } }
},
customFieldTypes,
dashboard: { stats: { ...emptyStats }, byCategory: [], byStatus: [], upcomingTasks: [], auditTrail: [] },
depreciationReport: { totals: { depreciation: 0 }, rows: [] },
drawerError: '',
employees: [],
entities: [],
error: '',
loading: false,
loginForm: { email: 'admin@mixedassets.local', password: 'ChangeMe123!' },
massChanges: { status: null, location: '', department: '' },
massDialog: false,
monthlyReport: { months: [] },
navItems,
preferenceSaving: false,
profilePreferences: { theme: localStorage.getItem('mixedassets.theme') || 'mixedAssetsDark' },
references: [],
reportFilters: { book: 'GAAP', year: new Date().getFullYear() },
roleCapabilities: [],
rolePermissions: {},
roles: [],
saving: false,
selectedAssetIds: [],
selectedTemplateId: null,
settings: {},
statusItems,
taxRules: [],
teamSaving: false,
teams: [],
templateForm: {
name: '',
description: '',
defaults: { category: 'Computer Equipment', useful_life_months: 60, depreciation_method: 'straight_line' },
custom_fields: []
},
templates: [],
themeItems,
token: localStorage.getItem('mixedassets.token') || '',
user: JSON.parse(localStorage.getItem('mixedassets.user') || 'null'),
userSaving: false,
users: [],
workdayConnection: {},
workdaySaving: false,
workdaySyncing: false
};
},
computed: {
allSelected() {
return this.assets.length > 0 && this.selectedAssetIds.length === this.assets.length;
},
categoryChart() {
return {
labels: this.dashboard.byCategory.map((row) => row.category),
datasets: [{ data: this.dashboard.byCategory.map((row) => row.value), backgroundColor: '#2457a6' }]
};
},
categoryItems() {
const fromRefs = this.references.filter((item) => item.type === 'category').map((item) => item.name);
return [...new Set([...fromRefs, 'Computer Equipment', 'Office Furniture', 'Vehicles', 'Leasehold Improvements', 'Uncategorized'])];
},
canEditAssets() {
return ['admin', 'finance', 'operations'].includes(this.user?.role);
},
currentSubtitle() {
return {
dashboard: 'Portfolio value, lifecycle activity, and open work',
assets: 'Asset register, imports, exports, and lifecycle updates',
assignments: 'Assign assets to people, departments, and locations with full custody history',
reports: 'Depreciation schedules, monthly expense, and book values',
templates: 'JSON-driven entry defaults and custom fields',
admin: 'Users, database, CORS, and tax-rule configuration'
}[this.activeView];
},
currentTitle() {
return {
dashboard: 'Dashboard',
assets: 'Asset register',
assignments: 'Assignments',
reports: 'Reports',
templates: 'Templates',
admin: 'Configuration'
}[this.activeView];
},
entityOptions() {
return this.entities.map((entity) => ({ title: entity.name, value: entity.id }));
},
activeTheme() {
return this.profilePreferences.theme || 'mixedAssetsDark';
},
fluentLuminance() {
return this.activeTheme === 'mixedAssetsDark' ? '0.12' : '1';
},
methodItems() {
const methods = this.taxRules[0]?.rules_json?.methods || [];
const fallback = [
{ code: 'straight_line', family: 'GAAP', label: 'Straight-line' },
{ code: 'sum_of_years_digits', family: 'GAAP', label: 'Sum-of-years-digits' },
{ code: 'declining_balance_200', family: 'GAAP', label: '200% declining balance' },
{ code: 'manual', family: 'Manual', label: 'Manual depreciation entry' }
];
return (methods.length ? methods : fallback).map((method) => ({
title: `${method.family} · ${method.label}`,
value: method.code
}));
},
monthlyChart() {
return {
labels: this.monthlyReport.months.map((row) => `M${row.month}`),
datasets: [{ data: this.monthlyReport.months.map((row) => row.depreciation), backgroundColor: '#006c6a' }]
};
},
selectedTemplate() {
return this.templates.find((template) => template.id === this.selectedTemplateId) || null;
},
selectedTemplateFields() {
return normalizeCustomFields(this.selectedTemplate?.custom_fields || []);
},
templateOptions() {
return this.templates.map((template) => ({ title: template.name, value: template.id }));
},
visibleNavItems() {
const allowedByRole = {
admin: ['dashboard', 'assets', 'assignments', 'reports', 'templates', 'admin'],
finance: ['dashboard', 'assets', 'assignments', 'reports', 'templates'],
operations: ['dashboard', 'assets', 'assignments'],
viewer: ['dashboard', 'assets', 'assignments', 'reports', 'templates']
};
const allowed = allowedByRole[this.user?.role] || allowedByRole.viewer;
return this.navItems.filter((item) => allowed.includes(item.key));
}
},
watch: {
activeView(view) {
if (view === 'reports') this.loadReports();
if (view === 'admin') this.loadAdmin();
if (view === 'assignments') this.loadAssignments();
},
visibleNavItems(items) {
if (!items.some((item) => item.key === this.activeView)) {
this.activeView = items[0]?.key || 'dashboard';
}
}
},
mounted() {
if (this.token) this.loadInitial();
},
methods: {
currency,
shortDate,
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async addEmployee(employee) {
await this.api('/api/employees', { method: 'POST', body: JSON.stringify(employee) });
await this.loadAssignments();
},
applyTemplate(id) {
this.selectedTemplateId = id || null;
const template = this.templates.find((item) => item.id === id);
if (!template) {
this.assetForm = { ...this.assetForm, template_id: null };
return;
}
const customFields = { ...(this.assetForm.custom_fields || {}) };
for (const field of normalizeCustomFields(template.custom_fields)) {
if (customFields[field.key] === undefined) customFields[field.key] = defaultValueForField(field);
}
this.assetForm = { ...this.assetForm, ...template.defaults, custom_fields: customFields, template_id: template.id };
},
async assignAsset(form) {
this.assignmentSaving = true;
try {
await this.api('/api/assignments', { method: 'POST', body: JSON.stringify(form) });
await Promise.all([this.loadAssignments(), this.loadAssets(), this.loadDashboard()]);
} finally {
this.assignmentSaving = false;
}
},
async createTeam(team) {
this.teamSaving = true;
try {
await this.api('/api/teams', { method: 'POST', body: JSON.stringify(team) });
await this.loadAdmin();
} finally {
this.teamSaving = false;
}
},
async createUser(user) {
this.userSaving = true;
try {
await this.api('/api/users', { method: 'POST', body: JSON.stringify(user) });
await this.loadAdmin();
} finally {
this.userSaving = false;
}
},
async downloadPdf() {
const blob = await (await this.api(`/api/reports/depreciation.pdf?year=${this.reportFilters.year}&book=${this.reportFilters.book}`)).blob();
saveBlob(blob, `mixedassets-${this.reportFilters.book}-${this.reportFilters.year}.pdf`);
},
editAsset(asset) {
this.assetForm = { ...blankAsset(), ...asset, custom_fields: asset.custom_fields || {} };
this.selectedTemplateId = asset.template_id || null;
this.assetDrawer = true;
},
async exportAssets(format) {
const blob = await (await this.api(`/api/export/assets?format=${format}`)).blob();
saveBlob(blob, `mixedassets-assets.${format}`);
},
filterAssets(filters) {
this.assetFilters = filters;
this.loadAssets();
},
filterReports(filters) {
this.reportFilters = filters;
this.loadReports();
},
async importAssets(event) {
const [file] = event.target.files || [];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
await this.api('/api/import/assets', { method: 'POST', body: formData });
event.target.value = '';
await this.loadAssets();
await this.loadDashboard();
},
async loadAdmin() {
const [settings, rules, workday, users, teams, accessControl] = await Promise.all([
this.api('/api/settings').then((r) => r.json()).catch(() => ({ settings: {} })),
this.api('/api/tax-rules').then((r) => r.json()),
this.api('/api/workday/connection').then((r) => r.json()).catch(() => ({ connection: {} })),
this.api('/api/users').then((r) => r.json()).catch(() => ({ users: [] })),
this.api('/api/teams').then((r) => r.json()).catch(() => ({ teams: [] })),
this.api('/api/access-control').then((r) => r.json()).catch(() => ({
roles: [],
rolePermissions: {},
roleCapabilities: []
}))
]);
this.settings = settings.settings;
this.taxRules = rules.ruleSets;
this.workdayConnection = workday.connection || {};
this.users = users.users || [];
this.teams = teams.teams || [];
this.roles = accessControl.roles || [];
this.rolePermissions = accessControl.rolePermissions || {};
this.roleCapabilities = accessControl.roleCapabilities || [];
},
async loadAssets() {
const params = new URLSearchParams();
if (this.assetFilters.search) params.set('search', this.assetFilters.search);
if (this.assetFilters.status) params.set('status', this.assetFilters.status);
const data = await (await this.api(`/api/assets?${params.toString()}`)).json();
this.assets = data.assets;
this.selectedAssetIds = this.selectedAssetIds.filter((id) => this.assets.some((asset) => asset.id === id));
},
async loadDashboard() {
this.dashboard = await (await this.api('/api/dashboard')).json();
},
async loadInitial() {
await Promise.all([
this.loadProfile(),
this.loadDashboard(),
this.loadAssets(),
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/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; })
]);
},
async loadAssignments() {
const [employees, assignments, workday] = await Promise.all([
this.api('/api/employees').then((r) => r.json()),
this.api('/api/assignments').then((r) => r.json()),
this.api('/api/workday/connection').then((r) => r.json()).catch(() => ({ connection: {} }))
]);
this.employees = employees.employees;
this.assignments = assignments.assignments;
this.assignmentSummary = assignments.summary;
this.workdayConnection = workday.connection || {};
},
async loadProfile() {
const profile = await (await this.api('/api/profile')).json();
this.user = profile.user;
this.profilePreferences = { theme: 'mixedAssetsDark', ...(profile.preferences || {}) };
localStorage.setItem('mixedassets.user', JSON.stringify(profile.user));
localStorage.setItem('mixedassets.theme', this.activeTheme);
},
async loadReports() {
const query = `year=${this.reportFilters.year}&book=${this.reportFilters.book}`;
const [annual, monthly] = await Promise.all([
this.api(`/api/reports/depreciation?${query}`).then((r) => r.json()),
this.api(`/api/reports/monthly-depreciation?${query}`).then((r) => r.json())
]);
this.depreciationReport = annual;
this.monthlyReport = monthly;
},
async login(form) {
this.loading = true;
this.error = '';
try {
const data = await loginRequest(form);
this.loginForm = { ...form };
this.token = data.token;
this.user = data.user;
localStorage.setItem('mixedassets.token', data.token);
localStorage.setItem('mixedassets.user', JSON.stringify(data.user));
await this.loadInitial();
} catch (error) {
this.error = error.message;
} finally {
this.loading = false;
}
},
logout() {
this.token = '';
this.user = null;
localStorage.removeItem('mixedassets.token');
localStorage.removeItem('mixedassets.user');
},
async massUpdate(changesInput) {
const changes = Object.fromEntries(Object.entries(changesInput).filter(([, value]) => value !== '' && value !== null));
await this.api('/api/assets/mass-update', { method: 'POST', body: JSON.stringify({ ids: this.selectedAssetIds, changes }) });
this.massDialog = false;
this.massChanges = { status: null, location: '', department: '' };
await this.loadAssets();
await this.loadDashboard();
},
newAsset() {
this.assetForm = blankAsset();
this.assetForm.entity_id = this.entities[0]?.id || null;
this.selectedTemplateId = null;
this.drawerError = '';
this.assetDrawer = true;
},
async saveAsset(assetInput) {
this.saving = true;
this.drawerError = '';
try {
const payload = {
...assetInput,
books: this.bookItems.map((book) => ({
book_type: book,
depreciation_method: assetInput.depreciation_method,
useful_life_months: assetInput.useful_life_months,
cost: assetInput.acquisition_cost
}))
};
const method = assetInput.id ? 'PUT' : 'POST';
const url = assetInput.id ? `/api/assets/${assetInput.id}` : '/api/assets';
await this.api(url, { method, body: JSON.stringify(payload) });
this.assetDrawer = false;
await this.loadAssets();
await this.loadDashboard();
} catch (error) {
this.drawerError = error.message;
} finally {
this.saving = false;
}
},
async saveSettings(settings) {
await this.api('/api/settings', { method: 'PUT', body: JSON.stringify({ settings }) });
await this.loadAdmin();
},
async releaseAssignment(assignment) {
await this.api(`/api/assignments/${assignment.id}/release`, {
method: 'PUT',
body: JSON.stringify({ release_reason: 'Released from UI' })
});
await Promise.all([this.loadAssignments(), this.loadAssets()]);
},
async saveProfilePreferences(preferences, options = { persist: true }) {
this.profilePreferences = { ...this.profilePreferences, ...(preferences || {}) };
localStorage.setItem('mixedassets.theme', this.activeTheme);
if (!options.persist) return;
this.preferenceSaving = true;
try {
const profile = await (await this.api('/api/profile', {
method: 'PUT',
body: JSON.stringify({ preferences: this.profilePreferences })
})).json();
this.user = profile.user;
this.profilePreferences = { theme: 'mixedAssetsDark', ...(profile.preferences || {}) };
localStorage.setItem('mixedassets.user', JSON.stringify(profile.user));
localStorage.setItem('mixedassets.theme', this.activeTheme);
} finally {
this.preferenceSaving = false;
}
},
async saveWorkdayConnection(connection) {
this.workdaySaving = true;
try {
const body = { ...connection };
if (!body.client_secret) delete body.client_secret;
await this.api('/api/workday/connection', { method: 'PUT', body: JSON.stringify(body) });
await this.loadAssignments();
} finally {
this.workdaySaving = false;
}
},
async saveTemplate(form) {
const payload = {
...form,
custom_fields: normalizeCustomFields(form.custom_fields)
};
await this.api('/api/templates', { method: 'POST', body: JSON.stringify(payload) });
const data = await (await this.api('/api/templates')).json();
this.templates = data.templates;
this.templateForm = {
name: '',
description: '',
defaults: { category: 'Computer Equipment', useful_life_months: 60, depreciation_method: 'straight_line' },
custom_fields: []
};
},
async syncWorkday() {
this.workdaySyncing = true;
try {
await this.api('/api/workday/sync-workers', { method: 'POST', body: JSON.stringify({}) });
await this.loadAssignments();
} finally {
this.workdaySyncing = false;
}
},
async updateUser(user) {
this.userSaving = true;
try {
const body = { ...user };
if (!body.password) delete body.password;
await this.api(`/api/users/${user.id}`, { method: 'PUT', body: JSON.stringify(body) });
await this.loadAdmin();
} finally {
this.userSaving = false;
}
},
toggleAll(value) {
this.selectedAssetIds = value ? this.assets.map((asset) => asset.id) : [];
},
toggleAsset(id) {
this.selectedAssetIds = this.selectedAssetIds.includes(id)
? this.selectedAssetIds.filter((assetId) => assetId !== id)
: [...this.selectedAssetIds, id];
}
}
};
</script>

View File

@@ -0,0 +1,102 @@
<template>
<v-navigation-drawer :model-value="modelValue" location="right" temporary width="520" @update:model-value="$emit('update:modelValue', $event)">
<div class="page-band">
<h2 class="text-h6 font-weight-bold">{{ assetForm.id ? 'Edit asset' : 'New asset' }}</h2>
</div>
<div class="pa-4">
<v-select :model-value="selectedTemplateId" :items="templateOptions" label="Template" clearable @update:model-value="$emit('select-template', $event)" />
<div class="form-grid">
<v-text-field v-model="localAsset.asset_id" label="Asset ID" />
<v-select v-model="localAsset.entity_id" :items="entityOptions" label="Entity" />
<v-text-field v-model="localAsset.description" class="full" label="Description" />
<v-select v-model="localAsset.category" :items="categoryItems" label="Category" />
<v-select v-model="localAsset.status" :items="statusItems" label="Status" />
<v-text-field v-model.number="localAsset.acquisition_cost" type="number" label="Acquisition cost" />
<v-text-field v-model.number="localAsset.salvage_value" type="number" label="Salvage value" />
<v-text-field v-model="localAsset.acquired_date" type="date" label="Acquired date" />
<v-text-field v-model="localAsset.in_service_date" type="date" label="In-service date" />
<v-text-field v-model.number="localAsset.useful_life_months" type="number" label="Useful life months" />
<v-select v-model="localAsset.depreciation_method" :items="methodItems" item-title="title" item-value="value" label="Method" />
<v-text-field v-model="localAsset.location" label="Location" />
<v-text-field v-model="localAsset.department" label="Department" />
<v-text-field v-model="localAsset.custodian" label="Custodian" />
<v-text-field v-model="localAsset.vendor" label="Vendor" />
<v-text-field v-model="localAsset.serial_number" label="Serial number" />
<v-text-field v-model="localAsset.invoice_number" label="Invoice number" />
<v-text-field v-model="localAsset.gl_asset_account" label="Asset GL" />
<v-text-field v-model="localAsset.gl_expense_account" label="Expense GL" />
<v-textarea v-model="localAsset.notes" class="full" label="Notes" rows="2" />
</div>
<template v-if="selectedTemplateFields.length">
<v-divider class="my-4" />
<h3 class="section-title mb-3">Template fields</h3>
<div class="form-grid">
<template v-for="field in selectedTemplateFields" :key="field.key">
<v-switch
v-if="field.type === 'bool'"
v-model="localAsset.custom_fields[field.key]"
:label="field.label"
color="primary"
density="compact"
hide-details
/>
<v-text-field
v-else
v-model="localAsset.custom_fields[field.key]"
:label="field.label"
:type="inputTypeForCustomField(field)"
:rules="field.required ? [requiredRule] : []"
/>
</template>
</div>
</template>
<v-alert v-if="drawerError" class="mt-4" density="compact" type="error">{{ drawerError }}</v-alert>
<div class="toolbar-row mt-4">
<v-spacer />
<v-btn variant="text" @click="$emit('update:modelValue', false)">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="$emit('save-asset', localAsset)">Save asset</v-btn>
</div>
</div>
</v-navigation-drawer>
</template>
<script>
import { inputTypeForCustomField } from '../utils/assets';
import { clonePlain } from '../utils/clone';
export default {
props: {
assetForm: { type: Object, required: true },
categoryItems: { type: Array, required: true },
drawerError: { type: String, default: '' },
entityOptions: { type: Array, required: true },
methodItems: { type: Array, required: true },
modelValue: { type: Boolean, default: false },
saving: { type: Boolean, default: false },
selectedTemplateFields: { type: Array, required: true },
selectedTemplateId: { type: [Number, String], default: null },
statusItems: { type: Array, required: true },
templateOptions: { type: Array, required: true }
},
emits: ['save-asset', 'select-template', 'update:modelValue'],
data() {
return {
localAsset: clonePlain(this.assetForm),
requiredRule: (value) => value !== null && value !== undefined && value !== '' || 'Required'
};
},
watch: {
assetForm: {
handler(value) {
this.localAsset = clonePlain(value);
},
deep: true
}
},
methods: {
inputTypeForCustomField
}
};
</script>

View File

@@ -0,0 +1,41 @@
<template>
<v-dialog :model-value="modelValue" max-width="460" @update:model-value="$emit('update:modelValue', $event)">
<v-card>
<v-card-title>Mass edit</v-card-title>
<v-card-text>
<v-select v-model="localChanges.status" :items="statusItems" clearable label="Status" />
<v-text-field v-model="localChanges.location" label="Location" />
<v-text-field v-model="localChanges.department" label="Department" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="$emit('update:modelValue', false)">Cancel</v-btn>
<v-btn color="primary" @click="$emit('apply', localChanges)">Apply</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
export default {
props: {
changes: { type: Object, required: true },
modelValue: { type: Boolean, default: false },
statusItems: { type: Array, required: true }
},
emits: ['apply', 'update:modelValue'],
data() {
return {
localChanges: { ...this.changes }
};
},
watch: {
changes: {
handler(value) {
this.localChanges = { ...value };
},
deep: true
}
}
};
</script>

99
src/components/TopNav.vue Normal file
View File

@@ -0,0 +1,99 @@
<template>
<v-app-bar class="top-nav" density="compact" flat height="64">
<div class="top-nav-title">
<div class="text-subtitle-2 quiet">{{ subtitle }}</div>
<div class="text-h6 font-weight-bold">{{ title }}</div>
</div>
<v-spacer />
<slot name="actions" />
<v-menu v-model="profileMenu" :close-on-content-click="false" location="bottom end">
<template #activator="{ props }">
<v-btn v-bind="props" class="profile-trigger" variant="text">
<v-avatar color="primary" size="32">
<span class="text-caption font-weight-bold">{{ initials }}</span>
</v-avatar>
<span class="profile-name">{{ user?.name }}</span>
<v-icon icon="mdi-chevron-down" size="18" />
</v-btn>
</template>
<v-card width="340">
<v-card-text>
<div class="d-flex align-center ga-3 mb-4">
<v-avatar color="primary" size="42">
<span class="font-weight-bold">{{ initials }}</span>
</v-avatar>
<div>
<div class="font-weight-bold">{{ user?.name }}</div>
<div class="text-caption quiet">{{ user?.email }} · {{ user?.role }}</div>
</div>
</div>
<v-select
v-model="localPreferences.theme"
:items="themeItems"
item-title="title"
item-value="value"
label="Theme"
prepend-inner-icon="mdi-theme-light-dark"
/>
<v-alert v-if="status" class="mt-2" density="compact" type="success">{{ status }}</v-alert>
</v-card-text>
<v-card-actions>
<v-btn variant="text" prepend-icon="mdi-logout" @click="$emit('logout')">Sign out</v-btn>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save</v-btn>
</v-card-actions>
</v-card>
</v-menu>
</v-app-bar>
</template>
<script>
export default {
props: {
preferences: { type: Object, required: true },
saving: { type: Boolean, default: false },
subtitle: { type: String, required: true },
themeItems: { type: Array, required: true },
title: { type: String, required: true },
user: { type: Object, required: true }
},
emits: ['logout', 'save-preferences'],
data() {
return {
localPreferences: { ...this.preferences },
profileMenu: false,
status: ''
};
},
computed: {
initials() {
return String(this.user?.name || 'MA')
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join('');
}
},
watch: {
preferences: {
handler(value) {
this.localPreferences = { ...value };
},
deep: true
},
'localPreferences.theme'(theme) {
this.$emit('save-preferences', { ...this.localPreferences, theme }, { persist: false });
}
},
methods: {
async save() {
await this.$emit('save-preferences', { ...this.localPreferences }, { persist: true });
this.status = 'Preferences saved';
window.setTimeout(() => {
this.status = '';
}, 1800);
}
}
};
</script>

24
src/constants.js Normal file
View File

@@ -0,0 +1,24 @@
export const bookItems = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
export const statusItems = ['in_service', 'cip', 'reserved', 'checked_out', 'disposed', 'retired'];
export const customFieldTypes = [
{ title: 'Text', value: 'text' },
{ title: 'Number', value: 'numeric' },
{ title: 'Date', value: 'date' },
{ title: 'Checkbox', value: 'bool' }
];
export const themeItems = [
{ title: 'Dark', value: 'mixedAssetsDark' },
{ title: 'Light', value: 'mixedAssets' }
];
export const navItems = [
{ key: 'dashboard', label: 'Dashboard', icon: 'mdi-view-dashboard-outline' },
{ key: 'assets', label: 'Assets', icon: 'mdi-archive-search-outline' },
{ key: 'assignments', label: 'Assignments', icon: 'mdi-account-switch-outline' },
{ key: 'reports', label: 'Reports', icon: 'mdi-chart-bar' },
{ key: 'templates', label: 'Templates', icon: 'mdi-form-select' },
{ key: 'admin', label: 'Admin', icon: 'mdi-cog-outline' }
];

59
src/main.js Normal file
View File

@@ -0,0 +1,59 @@
import { createApp } from 'vue';
import '@mdi/font/css/materialdesignicons.css';
import 'vuetify/styles';
import './styles/app.css';
import { allComponents, provideFluentDesignSystem } from '@fluentui/web-components';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import App from './App.vue';
provideFluentDesignSystem().register(allComponents);
const vuetify = createVuetify({
components,
directives,
theme: {
defaultTheme: 'mixedAssetsDark',
themes: {
mixedAssets: {
dark: false,
colors: {
background: '#f7f8fb',
surface: '#ffffff',
primary: '#2457a6',
secondary: '#006c6a',
accent: '#8f4f00',
info: '#3f6f99',
success: '#2f7d4b',
warning: '#a56a00',
error: '#b3261e'
}
},
mixedAssetsDark: {
dark: true,
colors: {
background: '#11161d',
surface: '#171d25',
primary: '#8fb8ff',
secondary: '#54d3c7',
accent: '#f2b36b',
info: '#9cc8e8',
success: '#8fd7a3',
warning: '#f0c06a',
error: '#ffb4ab'
}
}
}
},
defaults: {
VBtn: { rounded: 'sm', style: 'text-transform:none; letter-spacing:0' },
VCard: { rounded: 'sm' },
VTextField: { variant: 'outlined', density: 'compact' },
VSelect: { variant: 'outlined', density: 'compact' },
VTextarea: { variant: 'outlined', density: 'compact' },
VDataTable: { density: 'comfortable' }
}
});
createApp(App).use(vuetify).mount('#app');

35
src/services/api.js Normal file
View File

@@ -0,0 +1,35 @@
export async function apiRequest(path, token, options = {}) {
const response = await fetch(path, {
...options,
headers: {
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(options.headers || {})
}
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${response.status}`);
}
return response;
}
export async function loginRequest(credentials) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials)
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Unable to sign in');
return data;
}
export function saveBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}

342
src/styles/app.css Normal file
View File

@@ -0,0 +1,342 @@
:root {
color: #17202c;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
background: #11161d;
}
.app-shell {
min-height: 100vh;
}
.rail {
border-right: 1px solid #dde3ec;
}
.brand-lockup {
align-items: center;
display: flex;
gap: 10px;
min-height: 56px;
padding: 0 18px;
}
.brand-mark {
align-items: center;
background: #2457a6;
border-radius: 6px;
color: #fff;
display: inline-flex;
font-weight: 800;
height: 34px;
justify-content: center;
width: 34px;
}
.brand-name {
font-size: 1.05rem;
font-weight: 750;
letter-spacing: 0;
}
.page-band {
border-bottom: 1px solid #dde3ec;
padding: 22px 24px 18px;
}
.top-nav {
border-bottom: 1px solid rgba(120, 138, 163, 0.24);
padding: 0 18px;
}
.top-nav-title {
min-width: 0;
}
.profile-trigger {
min-width: 0;
}
.profile-name {
display: inline-block;
font-weight: 700;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.content-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(12, minmax(0, 1fr));
padding: 18px 24px 28px;
}
.span-3 {
grid-column: span 3;
}
.span-4 {
grid-column: span 4;
}
.span-5 {
grid-column: span 5;
}
.span-6 {
grid-column: span 6;
}
.span-7 {
grid-column: span 7;
}
.span-8 {
grid-column: span 8;
}
.span-12 {
grid-column: span 12;
}
.metric {
min-height: 116px;
padding: 18px;
}
.metric-label {
color: #617085;
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
.metric-value {
font-size: clamp(1.45rem, 2.4vw, 2.15rem);
font-weight: 800;
line-height: 1.1;
margin-top: 12px;
}
.metric-subtle {
color: #617085;
font-size: 0.86rem;
margin-top: 8px;
}
.toolbar-row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.section-title {
font-size: 1rem;
font-weight: 800;
margin: 0;
}
.quiet {
color: #617085;
}
.asset-table {
border-collapse: collapse;
width: 100%;
}
.asset-table th,
.asset-table td {
border-bottom: 1px solid #e7ebf1;
font-size: 0.88rem;
padding: 10px 12px;
text-align: left;
vertical-align: middle;
}
.asset-table th {
color: #617085;
font-size: 0.74rem;
font-weight: 800;
text-transform: uppercase;
white-space: nowrap;
}
.asset-table tr:hover td {
background: #f8fafc;
}
.status-chip {
border-radius: 999px;
display: inline-flex;
font-size: 0.78rem;
font-weight: 750;
padding: 4px 9px;
}
.status-in_service {
background: #e4f2eb;
color: #246140;
}
.status-disposed {
background: #f7e3df;
color: #8d2b1d;
}
.status-cip,
.status-reserved {
background: #f4ead9;
color: #7a4a00;
}
.panel-pad {
padding: 16px;
}
.chart-box {
height: 280px;
}
.login-screen {
align-items: center;
background:
linear-gradient(110deg, rgba(36, 87, 166, 0.92), rgba(0, 108, 106, 0.86)),
url('/asset-workbench.png');
background-position: center;
background-size: cover;
display: grid;
min-height: 100vh;
padding: 24px;
}
.login-card {
max-width: 430px;
width: 100%;
}
.form-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.form-grid .full {
grid-column: 1 / -1;
}
.custom-field-row {
align-items: center;
display: grid;
gap: 10px;
grid-template-columns: minmax(120px, 1fr) minmax(110px, 0.9fr) 130px minmax(110px, 0.8fr) 104px 40px;
margin-bottom: 10px;
}
.v-theme--mixedAssetsDark {
color-scheme: dark;
}
.v-theme--mixedAssetsDark .rail {
border-right-color: rgba(143, 184, 255, 0.2);
}
.v-theme--mixedAssetsDark .page-band,
.v-theme--mixedAssetsDark .asset-table th,
.v-theme--mixedAssetsDark .asset-table td {
border-color: rgba(143, 184, 255, 0.16);
}
.v-theme--mixedAssetsDark .quiet,
.v-theme--mixedAssetsDark .metric-label,
.v-theme--mixedAssetsDark .metric-subtle,
.v-theme--mixedAssetsDark .asset-table th {
color: #a8b3c2;
}
.v-theme--mixedAssetsDark .asset-table tr:hover td {
background: rgba(143, 184, 255, 0.07);
}
.v-theme--mixedAssetsDark .status-in_service {
background: rgba(143, 215, 163, 0.16);
color: #9ee8b5;
}
.v-theme--mixedAssetsDark .status-disposed {
background: rgba(255, 180, 171, 0.16);
color: #ffb4ab;
}
.v-theme--mixedAssetsDark .status-cip,
.v-theme--mixedAssetsDark .status-reserved {
background: rgba(240, 192, 106, 0.18);
color: #ffd18a;
}
@media (max-width: 980px) {
.content-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
.span-3,
.span-4,
.span-5,
.span-6,
.span-7,
.span-8 {
grid-column: span 6;
}
}
@media (max-width: 680px) {
.content-grid,
.page-band {
padding-left: 14px;
padding-right: 14px;
}
.content-grid {
grid-template-columns: 1fr;
}
.span-3,
.span-4,
.span-5,
.span-6,
.span-7,
.span-8,
.span-12 {
grid-column: 1;
}
.form-grid {
grid-template-columns: 1fr;
}
.custom-field-row {
align-items: stretch;
grid-template-columns: 1fr;
}
.profile-name {
display: none;
}
.asset-table {
min-width: 760px;
}
}

62
src/utils/assets.js Normal file
View File

@@ -0,0 +1,62 @@
export function blankAsset() {
const today = new Date().toISOString().slice(0, 10);
return {
asset_id: '',
entity_id: null,
description: '',
category: 'Computer Equipment',
status: 'in_service',
acquisition_cost: 0,
acquired_date: today,
in_service_date: today,
useful_life_months: 60,
salvage_value: 0,
depreciation_method: 'straight_line',
location: '',
department: '',
custodian: '',
vendor: '',
serial_number: '',
invoice_number: '',
gl_asset_account: '',
gl_expense_account: '',
notes: '',
custom_fields: {}
};
}
export function slugFieldKey(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
export function normalizeCustomFields(fields) {
return (fields || [])
.map((field) => ({
key: slugFieldKey(field.key || field.label),
label: field.label || field.key || 'Custom field',
type: ['text', 'date', 'bool', 'numeric'].includes(field.type) ? field.type : 'text',
required: Boolean(field.required),
defaultValue: field.defaultValue ?? field.default ?? ''
}))
.filter((field) => field.key);
}
export function defaultValueForField(field) {
if (field.defaultValue !== undefined && field.defaultValue !== null && field.defaultValue !== '') {
if (field.type === 'bool') return Boolean(field.defaultValue);
if (field.type === 'numeric') return Number(field.defaultValue);
return field.defaultValue;
}
if (field.type === 'bool') return false;
return '';
}
export function inputTypeForCustomField(field) {
if (field.type === 'numeric') return 'number';
if (field.type === 'date') return 'date';
return 'text';
}

11
src/utils/charts.js Normal file
View File

@@ -0,0 +1,11 @@
import {
BarElement,
CategoryScale,
Chart as ChartJS,
Legend,
LinearScale,
Title,
Tooltip
} from 'chart.js';
ChartJS.register(BarElement, CategoryScale, LinearScale, Legend, Title, Tooltip);

3
src/utils/clone.js Normal file
View File

@@ -0,0 +1,3 @@
export function clonePlain(value) {
return JSON.parse(JSON.stringify(value || {}));
}

11
src/utils/format.js Normal file
View File

@@ -0,0 +1,11 @@
export function currency(value) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0
}).format(Number(value || 0));
}
export function shortDate(value) {
return value ? new Date(value).toLocaleString() : '';
}

249
src/views/AdminView.vue Normal file
View File

@@ -0,0 +1,249 @@
<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<div>
<h2 class="section-title">Users and roles</h2>
<div class="quiet text-body-2">Manage account access, team membership, role assignment, and active status.</div>
</div>
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-account-plus" @click="userDialog = true">Add user</v-btn>
</div>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Team</th>
<th>Role</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="account in users" :key="account.id">
<td class="font-weight-bold">{{ account.name }}</td>
<td>{{ account.email }}</td>
<td>{{ account.team_name || 'No team' }}</td>
<td><v-chip size="small" variant="tonal">{{ account.role }}</v-chip></td>
<td><span :class="['status-chip', account.status === 'active' ? 'status-in_service' : 'status-disposed']">{{ account.status }}</span></td>
<td class="text-right">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="editUser(account)" />
</td>
</tr>
</tbody>
</table>
</div>
</v-card>
<v-card class="span-5 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Teams</h2>
<v-spacer />
<v-btn variant="tonal" size="small" prepend-icon="mdi-plus" @click="teamDialog = true">Add team</v-btn>
</div>
<v-list lines="two">
<v-list-item v-for="team in teams" :key="team.id" prepend-icon="mdi-account-group">
<v-list-item-title>{{ team.name }}</v-list-item-title>
<v-list-item-subtitle>{{ team.description || 'No description' }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card>
<v-card class="span-7 panel-pad">
<h2 class="section-title mb-4">Role permissions</h2>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th>Capability</th>
<th v-for="role in roles" :key="role">{{ role }}</th>
</tr>
</thead>
<tbody>
<tr v-for="capability in roleCapabilities" :key="capability.key">
<td>{{ capability.label }}</td>
<td v-for="role in roles" :key="role">
<v-icon :color="capability.roles.includes(role) ? 'success' : 'disabled'" :icon="capability.roles.includes(role) ? 'mdi-check-circle' : 'mdi-minus-circle'" size="18" />
</td>
</tr>
</tbody>
</table>
</div>
</v-card>
<v-card class="span-6 panel-pad">
<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.cors_allowed_domains" label="Allowed CORS domains" />
<v-text-field v-model="localSettings.database_driver" label="Database driver" readonly />
<v-text-field v-model="localSettings.database_path" label="Database path" readonly />
<v-btn color="primary" prepend-icon="mdi-content-save" @click="$emit('save-settings', localSettings)">Save settings</v-btn>
</v-card>
<v-card class="span-6 panel-pad">
<h2 class="section-title mb-4">Tax rule sets</h2>
<v-list lines="two">
<v-list-item v-for="rule in taxRules" :key="rule.id" prepend-icon="mdi-scale-balance">
<v-list-item-title>{{ rule.name }} · {{ rule.version }}</v-list-item-title>
<v-list-item-subtitle>{{ rule.jurisdiction }} · {{ rule.effective_date }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<div>
<h2 class="section-title">Workday connector</h2>
<div class="quiet text-body-2">Configure employee, department, and location sync from your Workday tenant.</div>
</div>
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-cloud-sync" :loading="workdaySyncing" @click="$emit('sync-workday')">Sync workers</v-btn>
</div>
<div class="form-grid">
<v-text-field v-model="localWorkday.name" label="Connection name" />
<v-text-field v-model="localWorkday.tenant" label="Tenant" />
<v-text-field v-model="localWorkday.base_url" label="Base URL" />
<v-text-field v-model="localWorkday.token_url" label="Token URL" />
<v-text-field v-model="localWorkday.client_id" label="Client ID" />
<v-text-field v-model="localWorkday.client_secret" type="password" label="Client secret" />
<v-text-field v-model="localWorkday.workers_path" label="Workers path" />
<v-switch v-model="localWorkday.enabled" label="Enabled" color="primary" density="compact" hide-details />
</div>
<div class="toolbar-row mt-3">
<div class="quiet text-body-2">Last sync: {{ workdayConnection.last_sync_at ? shortDate(workdayConnection.last_sync_at) : 'Never' }}</div>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="workdaySaving" @click="$emit('save-workday', localWorkday)">Save Workday connection</v-btn>
</div>
<v-alert v-if="workdayConnection.last_sync_status" class="mt-3" density="compact" type="info">{{ workdayConnection.last_sync_status }}</v-alert>
</v-card>
<v-dialog v-model="userDialog" max-width="560">
<v-card>
<v-card-title>{{ userForm.id ? 'Edit user' : 'Add user' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<v-text-field v-model="userForm.name" label="Name" />
<v-text-field v-model="userForm.email" label="Email" />
<v-select v-model="userForm.team_id" :items="teamOptions" item-title="title" item-value="value" clearable label="Team" />
<v-select v-model="userForm.role" :items="roles" label="Role" />
<v-select v-model="userForm.status" :items="['active', 'inactive']" label="Status" />
<v-text-field v-model="userForm.password" type="password" :label="userForm.id ? 'New password' : 'Password'" />
</div>
<v-alert v-if="userForm.role" class="mt-3" density="compact" type="info">
{{ roleSummary(userForm.role) }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="userDialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="userSaving" @click="saveUser">Save user</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="teamDialog" max-width="460">
<v-card>
<v-card-title>Add team</v-card-title>
<v-card-text>
<v-text-field v-model="teamForm.name" label="Team name" />
<v-textarea v-model="teamForm.description" label="Description" rows="2" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="teamDialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="teamSaving" @click="saveTeam">Save team</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
export default {
props: {
roleCapabilities: { type: Array, default: () => [] },
rolePermissions: { type: Object, default: () => ({}) },
roles: { type: Array, default: () => [] },
settings: { type: Object, required: true },
shortDate: { type: Function, default: (value) => value || '' },
taxRules: { type: Array, required: true },
teamSaving: { type: Boolean, default: false },
teams: { type: Array, default: () => [] },
userSaving: { type: Boolean, default: false },
users: { type: Array, default: () => [] },
workdayConnection: { type: Object, default: () => ({}) },
workdaySaving: { type: Boolean, default: false },
workdaySyncing: { type: Boolean, default: false }
},
emits: ['create-team', 'create-user', 'save-settings', 'save-workday', 'sync-workday', 'update-user'],
data() {
return {
localSettings: { ...this.settings },
localWorkday: { ...this.workdayConnection, client_secret: '' },
teamDialog: false,
teamForm: { name: '', description: '' },
userDialog: false,
userForm: this.blankUser()
};
},
computed: {
teamOptions() {
return this.teams.map((team) => ({ title: team.name, value: team.id }));
}
},
watch: {
settings: {
handler(value) {
this.localSettings = { ...value };
},
deep: true
},
workdayConnection: {
handler(value) {
this.localWorkday = { ...value, client_secret: '' };
},
deep: true
}
},
methods: {
blankUser() {
return {
id: null,
name: '',
email: '',
team_id: null,
role: 'viewer',
status: 'active',
password: ''
};
},
editUser(account) {
this.userForm = {
id: account.id,
name: account.name,
email: account.email,
team_id: account.team_id,
role: account.role,
status: account.status,
password: ''
};
this.userDialog = true;
},
roleSummary(role) {
return (this.rolePermissions[role] || []).join(' · ');
},
saveTeam() {
this.$emit('create-team', { ...this.teamForm });
this.teamDialog = false;
this.teamForm = { name: '', description: '' };
},
saveUser() {
const payload = { ...this.userForm };
if (!payload.password) delete payload.password;
this.$emit(payload.id ? 'update-user' : 'create-user', payload);
this.userDialog = false;
this.userForm = this.blankUser();
}
}
};
</script>

86
src/views/AssetsView.vue Normal file
View File

@@ -0,0 +1,86 @@
<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<v-text-field v-model="localFilters.search" label="Search" hide-details prepend-inner-icon="mdi-magnify" style="max-width: 300px" @update:model-value="emitFilters" />
<v-select v-model="localFilters.status" :items="statusItems" clearable label="Status" hide-details style="max-width: 180px" @update:model-value="emitFilters" />
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-pencil-multiple" :disabled="!selectedAssetIds.length" @click="$emit('open-mass-edit')">Mass edit</v-btn>
<v-btn variant="tonal" prepend-icon="mdi-upload" @click="$refs.importFile.click()">Import</v-btn>
<input ref="importFile" hidden type="file" accept=".csv,.json,.xlsx,.xls,.xml" @change="$emit('import-assets', $event)" />
<v-menu>
<template #activator="{ props }">
<v-btn v-bind="props" variant="tonal" prepend-icon="mdi-download">Export</v-btn>
</template>
<v-list density="compact">
<v-list-item v-for="format in ['json', 'csv', 'xlsx', 'xml']" :key="format" :title="format.toUpperCase()" @click="$emit('export-assets', format)" />
</v-list>
</v-menu>
</div>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th style="width:44px"><v-checkbox-btn :model-value="allSelected" @update:model-value="$emit('toggle-all', $event)" /></th>
<th>Asset ID</th>
<th>Description</th>
<th>Entity</th>
<th>Category</th>
<th>Status</th>
<th>Cost</th>
<th>Service date</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="asset in assets" :key="asset.id">
<td><v-checkbox-btn :model-value="selectedAssetIds.includes(asset.id)" @update:model-value="$emit('toggle-asset', asset.id)" /></td>
<td class="font-weight-bold">{{ asset.asset_id }}</td>
<td>{{ asset.description }}</td>
<td>{{ asset.entity_name }}</td>
<td>{{ asset.category }}</td>
<td><span :class="['status-chip', `status-${asset.status}`]">{{ asset.status }}</span></td>
<td>{{ currency(asset.acquisition_cost) }}</td>
<td>{{ asset.in_service_date }}</td>
<td class="text-right">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="$emit('edit-asset', asset)" />
</td>
</tr>
</tbody>
</table>
</div>
</v-card>
</section>
</template>
<script>
export default {
props: {
allSelected: { type: Boolean, default: false },
assets: { type: Array, required: true },
assetFilters: { type: Object, required: true },
currency: { type: Function, required: true },
selectedAssetIds: { type: Array, required: true },
statusItems: { type: Array, required: true }
},
emits: ['edit-asset', 'export-assets', 'filter-assets', 'import-assets', 'open-mass-edit', 'toggle-all', 'toggle-asset'],
data() {
return {
localFilters: { ...this.assetFilters }
};
},
watch: {
assetFilters: {
handler(value) {
this.localFilters = { ...value };
},
deep: true
}
},
methods: {
emitFilters() {
this.$emit('filter-assets', { ...this.localFilters });
}
}
};
</script>

View File

@@ -0,0 +1,168 @@
<template>
<section class="content-grid">
<v-card class="metric span-3">
<div class="metric-label">Assigned</div>
<div class="metric-value">{{ summary.active_assignments || 0 }}</div>
<div class="metric-subtle">Assets with active custody</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Unassigned</div>
<div class="metric-value">{{ summary.unassigned_assets || 0 }}</div>
<div class="metric-subtle">Need owner/location review</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Employees</div>
<div class="metric-value">{{ employees.length }}</div>
<div class="metric-subtle">Manual or Workday synced</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Locations</div>
<div class="metric-value">{{ summary.assigned_locations || 0 }}</div>
<div class="metric-subtle">Active assignment sites</div>
</v-card>
<v-card class="span-5 panel-pad">
<h2 class="section-title mb-4">Assign asset</h2>
<v-select v-model="assignmentForm.asset_id" :items="assetOptions" item-title="title" item-value="value" label="Asset" />
<v-select v-model="assignmentForm.employee_id" :items="employeeOptions" item-title="title" item-value="value" clearable label="Employee" @update:model-value="applyEmployeeDefaults" />
<v-text-field v-model="assignmentForm.department" label="Department" />
<v-text-field v-model="assignmentForm.location" label="Location" />
<v-textarea v-model="assignmentForm.notes" label="Notes" rows="2" />
<v-btn color="primary" prepend-icon="mdi-account-check" :loading="assignmentSaving" @click="$emit('assign-asset', assignmentForm)">Assign asset</v-btn>
</v-card>
<v-card class="span-7 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Employees</h2>
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-account-plus" @click="employeeDialog = true">Add employee</v-btn>
</div>
<v-list lines="two">
<v-list-item v-for="employee in employees" :key="employee.id" prepend-icon="mdi-account">
<v-list-item-title>{{ employee.name }}</v-list-item-title>
<v-list-item-subtitle>{{ employee.email || employee.employee_number }} · {{ employee.department || 'No department' }} · {{ employee.location || 'No location' }}</v-list-item-subtitle>
<template #append>
<v-chip size="small" variant="tonal">{{ employee.source }}</v-chip>
</template>
</v-list-item>
</v-list>
</v-card>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Traceability</h2>
<v-spacer />
<v-btn variant="tonal" prepend-icon="mdi-refresh" @click="$emit('reload')">Refresh</v-btn>
</div>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th>Asset</th>
<th>Employee</th>
<th>Department</th>
<th>Location</th>
<th>Assigned</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="assignment in assignments" :key="assignment.id">
<td>
<strong>{{ assignment.asset_code }}</strong><br />
<span class="quiet">{{ assignment.asset_description }}</span>
</td>
<td>{{ assignment.employee_name || 'Unassigned employee' }}</td>
<td>{{ assignment.department || '-' }}</td>
<td>{{ assignment.location || '-' }}</td>
<td>{{ shortDate(assignment.assigned_at) }}</td>
<td><span :class="['status-chip', assignment.active ? 'status-in_service' : 'status-disposed']">{{ assignment.status }}</span></td>
<td class="text-right">
<v-btn v-if="assignment.active" size="small" variant="text" color="warning" @click="$emit('release-assignment', assignment)">Release</v-btn>
</td>
</tr>
</tbody>
</table>
</div>
</v-card>
<v-dialog v-model="employeeDialog" max-width="480">
<v-card>
<v-card-title>Add employee</v-card-title>
<v-card-text>
<v-text-field v-model="employeeForm.name" label="Name" />
<v-text-field v-model="employeeForm.email" label="Email" />
<v-text-field v-model="employeeForm.employee_number" label="Employee number" />
<v-text-field v-model="employeeForm.department" label="Department" />
<v-text-field v-model="employeeForm.location" label="Location" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="employeeDialog = false">Cancel</v-btn>
<v-btn color="primary" @click="saveEmployee">Save employee</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
export default {
props: {
assignments: { type: Array, required: true },
assets: { type: Array, required: true },
assignmentSaving: { type: Boolean, default: false },
employees: { type: Array, required: true },
shortDate: { type: Function, required: true },
summary: { type: Object, required: true }
},
emits: ['add-employee', 'assign-asset', 'release-assignment', 'reload'],
data() {
return {
assignmentForm: {
asset_id: null,
employee_id: null,
department: '',
location: '',
notes: ''
},
employeeDialog: false,
employeeForm: {
name: '',
email: '',
employee_number: '',
department: '',
location: ''
}
};
},
computed: {
assetOptions() {
return this.assets.map((asset) => ({
title: `${asset.asset_id} · ${asset.description}`,
value: asset.id
}));
},
employeeOptions() {
return this.employees.map((employee) => ({
title: `${employee.name}${employee.department ? ` · ${employee.department}` : ''}`,
value: employee.id
}));
}
},
methods: {
applyEmployeeDefaults(id) {
const employee = this.employees.find((item) => item.id === id);
if (!employee) return;
this.assignmentForm.department = employee.department || '';
this.assignmentForm.location = employee.location || '';
},
saveEmployee() {
this.$emit('add-employee', { ...this.employeeForm });
this.employeeDialog = false;
this.employeeForm = { name: '', email: '', employee_number: '', department: '', location: '' };
}
}
};
</script>

View File

@@ -0,0 +1,61 @@
<template>
<section class="content-grid">
<v-card class="metric span-3">
<div class="metric-label">Assets</div>
<div class="metric-value">{{ dashboard.stats.asset_count }}</div>
<div class="metric-subtle">{{ dashboard.stats.in_service_count }} in service</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Capitalized cost</div>
<div class="metric-value">{{ currency(dashboard.stats.total_cost) }}</div>
<div class="metric-subtle">{{ dashboard.byCategory.length }} categories</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Disposed</div>
<div class="metric-value">{{ dashboard.stats.disposed_count }}</div>
<div class="metric-subtle">Tracked gain/loss ready</div>
</v-card>
<v-card class="metric span-3">
<div class="metric-label">Open tasks</div>
<div class="metric-value">{{ dashboard.upcomingTasks.length }}</div>
<div class="metric-subtle">Maintenance and reviews</div>
</v-card>
<v-card class="span-7 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Category value</h2>
</div>
<div class="chart-box">
<Bar :data="categoryChart" :options="chartOptions" />
</div>
</v-card>
<v-card class="span-5 panel-pad">
<div class="toolbar-row mb-4">
<h2 class="section-title">Activity</h2>
</div>
<v-list lines="two" density="compact">
<v-list-item v-for="log in dashboard.auditTrail" :key="log.id" prepend-icon="mdi-history">
<v-list-item-title>{{ log.action }} {{ log.entity_type }}</v-list-item-title>
<v-list-item-subtitle>{{ log.actor_name || 'System' }} · {{ shortDate(log.created_at) }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card>
</section>
</template>
<script>
import { Bar } from 'vue-chartjs';
import '../utils/charts';
export default {
components: { Bar },
props: {
categoryChart: { type: Object, required: true },
chartOptions: { type: Object, required: true },
currency: { type: Function, required: true },
dashboard: { type: Object, required: true },
shortDate: { type: Function, required: true }
}
};
</script>

46
src/views/LoginView.vue Normal file
View File

@@ -0,0 +1,46 @@
<template>
<div class="login-screen">
<v-card class="login-card" elevation="12">
<div class="brand-lockup">
<span class="brand-mark">MA</span>
<span class="brand-name">MixedAssets</span>
</div>
<v-divider />
<v-card-text class="pa-5">
<h1 class="text-h5 font-weight-bold mb-5">Fixed asset command center</h1>
<v-text-field v-model="localForm.email" label="Email" autocomplete="email" prepend-inner-icon="mdi-email-outline" />
<v-text-field
v-model="localForm.password"
label="Password"
type="password"
autocomplete="current-password"
prepend-inner-icon="mdi-lock-outline"
@keyup.enter="$emit('login', localForm)"
/>
<v-alert v-if="error" class="mb-4" density="compact" type="error">{{ error }}</v-alert>
<v-btn block color="primary" :loading="loading" prepend-icon="mdi-login" @click="$emit('login', localForm)">Sign in</v-btn>
</v-card-text>
</v-card>
</div>
</template>
<script>
export default {
props: {
error: { type: String, default: '' },
form: { type: Object, required: true },
loading: { type: Boolean, default: false }
},
emits: ['login'],
data() {
return {
localForm: { ...this.form }
};
},
watch: {
form(value) {
this.localForm = { ...value };
}
}
};
</script>

77
src/views/ReportsView.vue Normal file
View File

@@ -0,0 +1,77 @@
<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-4">
<v-select v-model="localFilters.book" :items="bookItems" label="Book" hide-details style="max-width: 180px" @update:model-value="emitFilters" />
<v-text-field v-model.number="localFilters.year" type="number" label="Year" hide-details style="max-width: 140px" @update:model-value="emitFilters" />
<v-spacer />
<div class="font-weight-bold">Annual depreciation: {{ currency(depreciationReport.totals.depreciation) }}</div>
</div>
<div class="chart-box mb-4">
<Bar :data="monthlyChart" :options="chartOptions" />
</div>
<div style="overflow-x:auto">
<table class="asset-table">
<thead>
<tr>
<th>Asset ID</th>
<th>Description</th>
<th>Method</th>
<th>Cost</th>
<th>Depreciation</th>
<th>Accumulated</th>
<th>NBV</th>
</tr>
</thead>
<tbody>
<tr v-for="row in depreciationReport.rows" :key="row.asset_id">
<td class="font-weight-bold">{{ row.asset_id }}</td>
<td>{{ row.description }}</td>
<td>{{ row.methodLabel || row.method }}</td>
<td>{{ currency(row.cost) }}</td>
<td>{{ currency(row.depreciation) }}</td>
<td>{{ currency(row.accumulated) }}</td>
<td>{{ currency(row.net_book_value) }}</td>
</tr>
</tbody>
</table>
</div>
</v-card>
</section>
</template>
<script>
import { Bar } from 'vue-chartjs';
import '../utils/charts';
export default {
components: { Bar },
props: {
bookItems: { type: Array, required: true },
chartOptions: { type: Object, required: true },
currency: { type: Function, required: true },
depreciationReport: { type: Object, required: true },
monthlyChart: { type: Object, required: true },
reportFilters: { type: Object, required: true }
},
emits: ['filter-reports'],
data() {
return {
localFilters: { ...this.reportFilters }
};
},
watch: {
reportFilters: {
handler(value) {
this.localFilters = { ...value };
},
deep: true
}
},
methods: {
emitFilters() {
this.$emit('filter-reports', { ...this.localFilters });
}
}
};
</script>

View File

@@ -0,0 +1,85 @@
<template>
<section class="content-grid">
<v-card class="span-5 panel-pad">
<h2 class="section-title mb-4">Asset template</h2>
<v-text-field v-model="localForm.name" label="Name" />
<v-textarea v-model="localForm.description" label="Description" rows="2" />
<v-select v-model="localForm.defaults.category" :items="categoryItems" label="Category" />
<v-text-field v-model.number="localForm.defaults.useful_life_months" type="number" label="Useful life months" />
<v-select v-model="localForm.defaults.depreciation_method" :items="methodItems" item-title="title" item-value="value" label="Depreciation method" />
<v-divider class="my-4" />
<div class="toolbar-row mb-3">
<h3 class="section-title">Custom fields</h3>
<v-spacer />
<v-btn variant="tonal" size="small" prepend-icon="mdi-plus" @click="addField">Add field</v-btn>
</div>
<div v-if="!localForm.custom_fields.length" class="quiet text-body-2 mb-4">No custom fields defined.</div>
<div v-for="(field, index) in localForm.custom_fields" :key="field.local_id" class="custom-field-row">
<v-text-field v-model="field.label" label="Label" @update:model-value="syncFieldKey(field)" />
<v-text-field v-model="field.key" label="Key" />
<v-select v-model="field.type" :items="customFieldTypes" item-title="title" item-value="value" label="Type" />
<v-text-field v-if="field.type !== 'bool'" v-model="field.defaultValue" label="Default" />
<v-switch v-else v-model="field.defaultValue" label="Default" color="primary" density="compact" hide-details />
<v-switch v-model="field.required" label="Required" color="primary" density="compact" hide-details />
<v-btn icon="mdi-delete" variant="text" color="error" @click="localForm.custom_fields.splice(index, 1)" />
</div>
<v-btn color="primary" prepend-icon="mdi-content-save" @click="$emit('save-template', localForm)">Save template</v-btn>
</v-card>
<v-card class="span-7 panel-pad">
<h2 class="section-title mb-4">Templates</h2>
<v-list lines="two">
<v-list-item v-for="template in templates" :key="template.id" prepend-icon="mdi-form-select">
<v-list-item-title>{{ template.name }}</v-list-item-title>
<v-list-item-subtitle>{{ template.description }}</v-list-item-subtitle>
<template #append>
<v-chip size="small" variant="tonal">{{ template.custom_fields.length }} fields</v-chip>
</template>
</v-list-item>
</v-list>
</v-card>
</section>
</template>
<script>
import { slugFieldKey } from '../utils/assets';
import { clonePlain } from '../utils/clone';
export default {
props: {
categoryItems: { type: Array, required: true },
customFieldTypes: { type: Array, required: true },
methodItems: { type: Array, required: true },
templateForm: { type: Object, required: true },
templates: { type: Array, required: true }
},
emits: ['save-template'],
data() {
return {
localForm: clonePlain(this.templateForm)
};
},
watch: {
templateForm: {
handler(value) {
this.localForm = clonePlain(value);
},
deep: true
}
},
methods: {
addField() {
this.localForm.custom_fields.push({
local_id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
key: '',
label: '',
type: 'text',
required: false,
defaultValue: ''
});
},
syncFieldKey(field) {
if (!field.key) field.key = slugFieldKey(field.label);
}
}
};
</script>

22
vite.config.js Normal file
View File

@@ -0,0 +1,22 @@
const { defineConfig } = require('vite');
const vue = require('@vitejs/plugin-vue');
module.exports = defineConfig({
plugins: [vue({
template: {
compilerOptions: {
isCustomElement: (tag) => tag.startsWith('fluent-')
}
}
})],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:3000'
}
},
build: {
outDir: 'dist',
emptyOutDir: true
}
});