commit 2f13b8c59023fa3da4a51a3a2d6b5fbff225ce6d Author: mpuckett Date: Wed Jun 3 13:58:12 2026 -0500 Initial diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d129ac5 --- /dev/null +++ b/.env.example @@ -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! diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f60014 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules +/data +/logs +*.log +*.env +.DS_Store +dist +build +*-lock.json \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5f5d562 --- /dev/null +++ b/README.md @@ -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. diff --git a/index.html b/index.html new file mode 100644 index 0000000..526f7a0 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + MixedAssets + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..7386fec --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/project.txt b/project.txt new file mode 100644 index 0000000..c5df240 --- /dev/null +++ b/project.txt @@ -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, user’s 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 asset’s 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 you’re 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 device’s 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 \ No newline at end of file diff --git a/public/asset-workbench.png b/public/asset-workbench.png new file mode 100644 index 0000000..7fa7bb8 Binary files /dev/null and b/public/asset-workbench.png differ diff --git a/server/app.js b/server/app.js new file mode 100644 index 0000000..c73c0f7 --- /dev/null +++ b/server/app.js @@ -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 +}; diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..281ae3d --- /dev/null +++ b/server/db.js @@ -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 +}; diff --git a/server/depreciation.js b/server/depreciation.js new file mode 100644 index 0000000..f034c5b --- /dev/null +++ b/server/depreciation.js @@ -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 +}; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..65d2dc0 --- /dev/null +++ b/server/index.js @@ -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}`); +}); diff --git a/server/middleware/auth.js b/server/middleware/auth.js new file mode 100644 index 0000000..962031a --- /dev/null +++ b/server/middleware/auth.js @@ -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 +}; diff --git a/server/middleware/cors.js b/server/middleware/cors.js new file mode 100644 index 0000000..ef7a011 --- /dev/null +++ b/server/middleware/cors.js @@ -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 +}; diff --git a/server/routes/admin.js b/server/routes/admin.js new file mode 100644 index 0000000..66eab59 --- /dev/null +++ b/server/routes/admin.js @@ -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; diff --git a/server/routes/assets.js b/server/routes/assets.js new file mode 100644 index 0000000..f6cd114 --- /dev/null +++ b/server/routes/assets.js @@ -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; diff --git a/server/routes/assignments.js b/server/routes/assignments.js new file mode 100644 index 0000000..9138167 --- /dev/null +++ b/server/routes/assignments.js @@ -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; diff --git a/server/routes/auth.js b/server/routes/auth.js new file mode 100644 index 0000000..fce211a --- /dev/null +++ b/server/routes/auth.js @@ -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; diff --git a/server/routes/dashboard.js b/server/routes/dashboard.js new file mode 100644 index 0000000..cce67d0 --- /dev/null +++ b/server/routes/dashboard.js @@ -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; diff --git a/server/routes/dataPortability.js b/server/routes/dataPortability.js new file mode 100644 index 0000000..2c524d9 --- /dev/null +++ b/server/routes/dataPortability.js @@ -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; diff --git a/server/routes/profile.js b/server/routes/profile.js new file mode 100644 index 0000000..e51e732 --- /dev/null +++ b/server/routes/profile.js @@ -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; diff --git a/server/routes/reference.js b/server/routes/reference.js new file mode 100644 index 0000000..25f635d --- /dev/null +++ b/server/routes/reference.js @@ -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; diff --git a/server/routes/reports.js b/server/routes/reports.js new file mode 100644 index 0000000..95bda17 --- /dev/null +++ b/server/routes/reports.js @@ -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; diff --git a/server/routes/taxRules.js b/server/routes/taxRules.js new file mode 100644 index 0000000..62ebe8a --- /dev/null +++ b/server/routes/taxRules.js @@ -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; diff --git a/server/routes/templates.js b/server/routes/templates.js new file mode 100644 index 0000000..c63dafe --- /dev/null +++ b/server/routes/templates.js @@ -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; diff --git a/server/routes/workday.js b/server/routes/workday.js new file mode 100644 index 0000000..b48aecf --- /dev/null +++ b/server/routes/workday.js @@ -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; diff --git a/server/rule-catalog.js b/server/rule-catalog.js new file mode 100644 index 0000000..5c9dcef --- /dev/null +++ b/server/rule-catalog.js @@ -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 +}; diff --git a/server/services/accessControl.js b/server/services/accessControl.js new file mode 100644 index 0000000..6de311f --- /dev/null +++ b/server/services/accessControl.js @@ -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 +}; diff --git a/server/services/assets.js b/server/services/assets.js new file mode 100644 index 0000000..52f1085 --- /dev/null +++ b/server/services/assets.js @@ -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 +}; diff --git a/server/services/assignments.js b/server/services/assignments.js new file mode 100644 index 0000000..02d4951 --- /dev/null +++ b/server/services/assignments.js @@ -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 +}; diff --git a/server/services/importExport.js b/server/services/importExport.js new file mode 100644 index 0000000..590bfc1 --- /dev/null +++ b/server/services/importExport.js @@ -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 +}; diff --git a/server/services/profile.js b/server/services/profile.js new file mode 100644 index 0000000..4877a82 --- /dev/null +++ b/server/services/profile.js @@ -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 +}; diff --git a/server/services/reports.js b/server/services/reports.js new file mode 100644 index 0000000..593b96e --- /dev/null +++ b/server/services/reports.js @@ -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 +}; diff --git a/server/services/workday.js b/server/services/workday.js new file mode 100644 index 0000000..adc4f57 --- /dev/null +++ b/server/services/workday.js @@ -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 +}; diff --git a/server/smoke-test.js b/server/smoke-test.js new file mode 100644 index 0000000..b109e67 --- /dev/null +++ b/server/smoke-test.js @@ -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'); + }); diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..3b48aed --- /dev/null +++ b/src/App.vue @@ -0,0 +1,657 @@ + + + diff --git a/src/components/AssetDrawer.vue b/src/components/AssetDrawer.vue new file mode 100644 index 0000000..3355375 --- /dev/null +++ b/src/components/AssetDrawer.vue @@ -0,0 +1,102 @@ + + + diff --git a/src/components/MassEditDialog.vue b/src/components/MassEditDialog.vue new file mode 100644 index 0000000..949cd66 --- /dev/null +++ b/src/components/MassEditDialog.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/components/TopNav.vue b/src/components/TopNav.vue new file mode 100644 index 0000000..38ca438 --- /dev/null +++ b/src/components/TopNav.vue @@ -0,0 +1,99 @@ + + + diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..c426bb5 --- /dev/null +++ b/src/constants.js @@ -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' } +]; diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..747ef37 --- /dev/null +++ b/src/main.js @@ -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'); diff --git a/src/services/api.js b/src/services/api.js new file mode 100644 index 0000000..aff988a --- /dev/null +++ b/src/services/api.js @@ -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); +} diff --git a/src/styles/app.css b/src/styles/app.css new file mode 100644 index 0000000..e742514 --- /dev/null +++ b/src/styles/app.css @@ -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; + } +} diff --git a/src/utils/assets.js b/src/utils/assets.js new file mode 100644 index 0000000..5e32b38 --- /dev/null +++ b/src/utils/assets.js @@ -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'; +} diff --git a/src/utils/charts.js b/src/utils/charts.js new file mode 100644 index 0000000..b56c8ef --- /dev/null +++ b/src/utils/charts.js @@ -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); diff --git a/src/utils/clone.js b/src/utils/clone.js new file mode 100644 index 0000000..488c324 --- /dev/null +++ b/src/utils/clone.js @@ -0,0 +1,3 @@ +export function clonePlain(value) { + return JSON.parse(JSON.stringify(value || {})); +} diff --git a/src/utils/format.js b/src/utils/format.js new file mode 100644 index 0000000..f52d0cb --- /dev/null +++ b/src/utils/format.js @@ -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() : ''; +} diff --git a/src/views/AdminView.vue b/src/views/AdminView.vue new file mode 100644 index 0000000..f550cbb --- /dev/null +++ b/src/views/AdminView.vue @@ -0,0 +1,249 @@ + + + diff --git a/src/views/AssetsView.vue b/src/views/AssetsView.vue new file mode 100644 index 0000000..82e8beb --- /dev/null +++ b/src/views/AssetsView.vue @@ -0,0 +1,86 @@ + + + diff --git a/src/views/AssignmentsView.vue b/src/views/AssignmentsView.vue new file mode 100644 index 0000000..a769a60 --- /dev/null +++ b/src/views/AssignmentsView.vue @@ -0,0 +1,168 @@ + + + diff --git a/src/views/DashboardView.vue b/src/views/DashboardView.vue new file mode 100644 index 0000000..b7150ad --- /dev/null +++ b/src/views/DashboardView.vue @@ -0,0 +1,61 @@ + + + diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue new file mode 100644 index 0000000..3d6b8c7 --- /dev/null +++ b/src/views/LoginView.vue @@ -0,0 +1,46 @@ + + + diff --git a/src/views/ReportsView.vue b/src/views/ReportsView.vue new file mode 100644 index 0000000..2a6ae0a --- /dev/null +++ b/src/views/ReportsView.vue @@ -0,0 +1,77 @@ + + + diff --git a/src/views/TemplatesView.vue b/src/views/TemplatesView.vue new file mode 100644 index 0000000..f8ea8cb --- /dev/null +++ b/src/views/TemplatesView.vue @@ -0,0 +1,85 @@ + + + diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..65fd9c8 --- /dev/null +++ b/vite.config.js @@ -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 + } +});