Updated A LOT
This commit is contained in:
549
README.md
549
README.md
@@ -4,6 +4,22 @@ MixedAssets is a fixed asset management and depreciation SPA for small and mid-s
|
|||||||
|
|
||||||
Each asset carries six independent books (GAAP, Federal, State, AMT, ACE, User-defined), each with its own cost, depreciation method, useful life, §179 amount, bonus percentage, and convention. The asset workbench also includes a File Cabinet (invoices, photos, manuals), warranty/service-agreement tracking, a per-asset task list, and a running notes timeline.
|
Each asset carries six independent books (GAAP, Federal, State, AMT, ACE, User-defined), each with its own cost, depreciation method, useful life, §179 amount, bonus percentage, and convention. The asset workbench also includes a File Cabinet (invoices, photos, manuals), warranty/service-agreement tracking, a per-asset task list, and a running notes timeline.
|
||||||
|
|
||||||
|
The **Books** screen manages the sets of books as first-class entities: create user-defined books, enable/disable each book, set its type (financial/tax/internal) and entry defaults, and assign which tax rule set drives its depreciation. Enabled depreciation books automatically apply to assets (new and existing), appear in the asset's Books tab, and are selectable in reports — the engine resolves rules per book rather than from a single global set. It also renders each book in a general-ledger table (asset cost, accumulated depreciation, and period expense rolled up to GL accounts with debit/credit and net book value), with PDF/Excel/CSV export.
|
||||||
|
|
||||||
|
Disposals support full and partial dispositions with automatic gain/loss against book net-book-value (with a live preview before committing), Form 4797 property typing, and impairment/write-down adjustments. Lease accounting captures each agreement and generates an ASC 842-style amortization schedule (present value, periodic interest/principal, lease-liability and right-of-use balances).
|
||||||
|
|
||||||
|
Barcode tracking lets you print Code 128 / Code 39 labels (single asset from the workbench, or a batch from the register) and includes a **Scan** screen: use a device camera to scan an asset's barcode (or type the code), then update its status, location, department, custodian, condition, and add a field note on the spot. Camera scanning requires HTTPS or localhost; manual code entry works everywhere.
|
||||||
|
|
||||||
|
The **Maintenance** screen builds reusable preventative-maintenance (PM) plans — each with a frequency (every N days/weeks/months/quarters/bi-yearly/years), an ordered step checklist with per-step time estimates (the plan's estimated minutes auto-totals from the steps), and a components/parts list (part #, description, supplier, cost). Plans are attached to assets from the asset's **PM tab** with a per-asset interval (default set in Admin) and an end date pre-filled to the asset's depreciation end. Completing a service opens a structured form — performed-by (the logged-in user), a **required signature**, 1–5★ condition ratings (Safety/Is it safe?, Physical condition, Operating condition), dynamic completion notes, and photos — then checks off steps, captures parts cost, records history, and rolls the next-due date forward; due/overdue PM raises alerts. Completed forms (with signature, ratings, notes, and photos) are viewable from the **Maintenance** screen and from each asset's **PM tab**. Maintenance spend is tracked per asset in a dedicated **PM book** (Books screen → general ledger) that aggregates completion costs.
|
||||||
|
|
||||||
|
The **Parts inventory** screen (a sub-menu under Maintenance) is a standalone inventory of maintenance parts & supplies, kept separate from the depreciation register. Look up a part to see whether it's in stock, which **aisle/bin** to get it from, and how much is **reserved** vs available; add identification details like photos, measurements, manufacturer part #, and barcode. Stock can live in one or more **Work Location** contacts, and each part's **supplier** is chosen from the Contacts directory (vendors, service providers, or contractors). Stock movements (receive, issue, adjust, reserve, unreserve) are recorded with full history, and parts at/below their reorder point are flagged as low stock.
|
||||||
|
|
||||||
|
The **Contacts** screen is a directory of people and organizations — Employees, Vendors, Service Providers, Contractors, Work Locations, and Other — each with name, international-friendly address and phone, email, and notes, plus type-specific fields (employee ID/department/manager/start date; contractor tax ID or business license; vendor/service-provider star rating and credit term) and a per-contact notes log. It works fully without Workday; when Workday is connected, employee records are synced into Contacts with manager and start-date enrichment, and the sync can be **scheduled automatically** from the Admin → Workday panel.
|
||||||
|
|
||||||
|
The **Alerts** screen surfaces reminders for assets needing maintenance, overdue tasks, preventative-maintenance due dates, and warranties/leases about to expire. A background job (and an on-demand scan) computes alerts with a configurable lead time and severity, and — when SMTP is configured and alerts are enabled — emails a digest of new items via Nodemailer. SMTP/email server settings live in a dedicated panel in the Admin pane (the password is write-only and never returned by the API).
|
||||||
|
|
||||||
|
The Reports workbench is driven by a server report catalog covering depreciation (annual, monthly, quarterly, YTD, net book value, lifetime, projection), activity (rollforward, acquisitions, disposals & gain/loss, adjustments, construction-in-progress), journal entries (depreciation, disposals), tax (Form 4562, Form 4797, AMT, ACE, personal property tax, UBIA for §199A), a fixed-asset card, and a custom **Report Builder** with selectable columns. Every report can be saved with its options for reuse and exported to PDF, Excel, or CSV.
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -33,7 +49,538 @@ npm test # API smoke test
|
|||||||
|
|
||||||
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.
|
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.
|
| Variable | Default | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `MIXEDASSETS_PORT` (or `PORT`) | `3000` | API/server port |
|
||||||
|
| `MIXEDASSETS_DATA_DIR` | `./data` | Data directory |
|
||||||
|
| `MIXEDASSETS_DB_PATH` | `./data/mixedassets.sqlite` | SQLite database file |
|
||||||
|
| `MIXEDASSETS_SERVER_FQDN` | `localhost` | Server FQDN (also editable in Admin) |
|
||||||
|
| `MIXEDASSETS_CORS_ORIGINS` | `http://localhost:5173,...` | Allowed CORS origins (comma-separated) |
|
||||||
|
| `MIXEDASSETS_JWT_SECRET` | dev fallback | JWT signing secret — set a long random value in production |
|
||||||
|
| `MIXEDASSETS_ADMIN_PASSWORD` | `ChangeMe123!` | Password for the seeded admin user |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# API Reference
|
||||||
|
|
||||||
|
The API is served under `/api`. All payloads are JSON unless noted (file upload and export endpoints differ).
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Authenticate with `POST /api/auth/login` to receive a JWT, then send it on every other request:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Tokens expire after 12 hours. The only routes that do **not** require a token are `GET /api/health`, `GET /api/public/bootstrap`, and `POST /api/auth/login`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login
|
||||||
|
curl -s http://localhost:3000/api/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"email":"admin@mixedassets.local","password":"ChangeMe123!"}'
|
||||||
|
# => { "token": "<jwt>", "user": { "id":1, "name":"...", "role":"admin", ... } }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
Four roles gate write access: `admin`, `finance`, `operations`, `viewer`. The capability matrix is returned by `GET /api/access-control`. Endpoints below list the roles allowed to call them; read endpoints are available to any authenticated user.
|
||||||
|
|
||||||
|
## Errors & status codes
|
||||||
|
|
||||||
|
Errors return `{ "error": "message" }` with an appropriate status:
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `400` | Validation error (bad body, invalid JSON, missing required field) |
|
||||||
|
| `401` | Missing/expired token or invalid credentials |
|
||||||
|
| `403` | Authenticated but role not permitted |
|
||||||
|
| `404` | Resource not found |
|
||||||
|
| `500` | Unhandled server error |
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Auth & profile
|
||||||
|
|
||||||
|
| Method | Path | Roles | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/health` | public | Liveness check |
|
||||||
|
| GET | `/api/public/bootstrap` | public | App name, setup status, DB driver |
|
||||||
|
| POST | `/api/auth/login` | public | Exchange credentials for a JWT |
|
||||||
|
| GET | `/api/profile` | any | Current user + preferences |
|
||||||
|
| PUT | `/api/profile` | any | Update preferences (e.g. theme) |
|
||||||
|
|
||||||
|
### Dashboard & reference data
|
||||||
|
|
||||||
|
| Method | Path | Roles | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/dashboard` | any | Portfolio stats, charts, recent audit trail |
|
||||||
|
| GET | `/api/entities` | any | List entities/companies |
|
||||||
|
| POST | `/api/entities` | admin, finance | Create an entity |
|
||||||
|
| GET | `/api/references` | any | Reference values (locations, departments, categories) |
|
||||||
|
|
||||||
|
### Assets
|
||||||
|
|
||||||
|
| Method | Path | Roles | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/assets` | any | List/filter assets (`?search=&status=&entity_id=&category=`) |
|
||||||
|
| POST | `/api/assets` | admin, finance, operations | Create asset (optionally with `books[]`) |
|
||||||
|
| GET | `/api/assets/lookup?code=` | any | Resolve by barcode value, asset ID, or serial number |
|
||||||
|
| GET | `/api/assets/:id` | any | Hydrated asset (books, files, warranties, tasks, notes, disposals, adjustments, leases) |
|
||||||
|
| PUT | `/api/assets/:id` | admin, finance, operations | Update asset (and `books[]` when provided) |
|
||||||
|
| DELETE | `/api/assets/:id` | admin, finance | Delete asset |
|
||||||
|
| POST | `/api/assets/mass-update` | admin, finance | Bulk field update `{ ids:[], changes:{} }` |
|
||||||
|
| GET | `/api/assets/:id/depreciation` | any | Multi-year schedule across active books (`?startYear=&endYear=`) |
|
||||||
|
| POST | `/api/assets/:id/files` | admin, finance, operations | Upload a file (multipart `file`) |
|
||||||
|
| GET | `/api/assets/:id/files/:fileId` | any | Download a stored file |
|
||||||
|
| DELETE | `/api/assets/:id/files/:fileId` | admin, finance, operations | Delete a file |
|
||||||
|
| POST | `/api/assets/:id/photos` | admin, finance, operations | Add an identification photo `{ data, name, mime, caption }` (base64 data URL) |
|
||||||
|
| PUT | `/api/assets/:id/photos/:photoId` | admin, finance, operations | Update a photo caption |
|
||||||
|
| DELETE | `/api/assets/:id/photos/:photoId` | admin, finance, operations | Delete a photo |
|
||||||
|
| POST | `/api/assets/:id/warranties` | admin, finance, operations | Add warranty/service agreement |
|
||||||
|
| DELETE | `/api/assets/:id/warranties/:warrantyId` | admin, finance, operations | Delete warranty |
|
||||||
|
| POST | `/api/assets/:id/tasks` | admin, finance, operations | Add task |
|
||||||
|
| PUT | `/api/assets/:id/tasks/:taskId` | admin, finance, operations | Update task (e.g. mark complete) |
|
||||||
|
| DELETE | `/api/assets/:id/tasks/:taskId` | admin, finance, operations | Delete task |
|
||||||
|
| POST | `/api/assets/:id/notes` | admin, finance, operations | Add timestamped note |
|
||||||
|
| DELETE | `/api/assets/:id/notes/:noteId` | admin, finance, operations | Delete note |
|
||||||
|
|
||||||
|
### Disposals & adjustments
|
||||||
|
|
||||||
|
| Method | Path | Roles | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| POST | `/api/assets/:id/disposal/preview` | any | Compute gain/loss without committing |
|
||||||
|
| POST | `/api/assets/:id/disposals` | admin, finance | Record full/partial disposal |
|
||||||
|
| DELETE | `/api/assets/:id/disposals/:disposalId` | admin, finance | Reverse a disposal |
|
||||||
|
| POST | `/api/assets/:id/adjustments` | admin, finance | Record impairment/write-down/write-up |
|
||||||
|
| DELETE | `/api/assets/:id/adjustments/:adjustmentId` | admin, finance | Delete an adjustment |
|
||||||
|
|
||||||
|
Disposal body: `{ book_type, disposal_date, disposal_price, disposal_expense, method, property_type, partial, disposed_cost, notes }`. Adjustment body: `{ type, amount, book_type, adjustment_date, reason }` where `type` is `impairment | write_down | write_up | revaluation`.
|
||||||
|
|
||||||
|
### Leases
|
||||||
|
|
||||||
|
| Method | Path | Roles | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/leases` | any | List leases (`?asset_id=`) |
|
||||||
|
| POST | `/api/leases` | admin, finance | Create lease |
|
||||||
|
| PUT | `/api/leases/:id` | admin, finance | Update lease |
|
||||||
|
| DELETE | `/api/leases/:id` | admin, finance | Delete lease |
|
||||||
|
| GET | `/api/leases/:id/schedule` | any | ASC 842-style amortization schedule |
|
||||||
|
|
||||||
|
Lease body: `{ asset_id, lessor, contract_value, start_date, end_date, discount_rate, payment_frequency }` where `payment_frequency` is `monthly | quarterly | semiannual | annual`.
|
||||||
|
|
||||||
|
### Books & general ledger
|
||||||
|
|
||||||
|
| Method | Path | Roles | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/books` | any | Book definitions + available rule sets |
|
||||||
|
| POST | `/api/books` | admin, finance | Create a user-defined book `{ code, name, book_type, ... }` |
|
||||||
|
| PUT | `/api/books/:id` | admin, finance | Update book (name, type, enabled, `tax_rule_set_id`, defaults) |
|
||||||
|
| DELETE | `/api/books/:id` | admin, finance | Delete a book (blocked for primary/PM books or books in use) |
|
||||||
|
| GET | `/api/books/:code/ledger?year=` | any | GL rollup + per-asset detail for a book |
|
||||||
|
| POST | `/api/books/:code/ledger/export` | any | Export the GL (`{ year, format }`, format `pdf|xlsx|csv`) |
|
||||||
|
|
||||||
|
### Assignments & employees
|
||||||
|
|
||||||
|
| Method | Path | Roles | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/employees` | any | List employees (`?search=&status=`) |
|
||||||
|
| POST | `/api/employees` | admin, finance, operations | Create/upsert employee |
|
||||||
|
| GET | `/api/assignments` | any | List assignments + summary (`?asset_id=&employee_id=&active=true`) |
|
||||||
|
| POST | `/api/assignments` | admin, finance, operations | Assign asset to employee/department/location |
|
||||||
|
| PUT | `/api/assignments/:id/release` | admin, finance, operations | Release an assignment |
|
||||||
|
|
||||||
|
### Contacts
|
||||||
|
|
||||||
|
| Method | Path | Roles | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/contacts` | any | List contacts (`?type=&search=&status=`) |
|
||||||
|
| GET | `/api/contacts/:id` | any | Single contact with its notes log |
|
||||||
|
| POST | `/api/contacts` | admin, finance, operations | Create a contact |
|
||||||
|
| PUT | `/api/contacts/:id` | admin, finance, operations | Update a contact |
|
||||||
|
| DELETE | `/api/contacts/:id` | admin, finance | Delete a contact |
|
||||||
|
| POST | `/api/contacts/:id/notes` | admin, finance, operations | Add a note |
|
||||||
|
| DELETE | `/api/contacts/:id/notes/:noteId` | admin, finance, operations | Delete a note |
|
||||||
|
|
||||||
|
Contact `type` is `employee | vendor | service_provider | contractor | work_location | other`. Vendor/provider `credit_term` is `na | net_30 | net_60 | net_90 | net_180`; `rating` is 1–5. The Workday connector (`/api/workday/*`) upserts `employee` contacts; `PUT /api/workday/connection` accepts `sync_enabled` and `sync_interval_hours` for the automatic scheduled sync.
|
||||||
|
|
||||||
|
### Templates (data-entry forms)
|
||||||
|
|
||||||
|
| Method | Path | Roles | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/templates` | any | List asset entry templates |
|
||||||
|
| POST | `/api/templates` | admin, finance | Create a template (defaults + custom fields) |
|
||||||
|
|
||||||
|
### Tax rule sets ("tax templates")
|
||||||
|
|
||||||
|
| Method | Path | Roles | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/tax-rules` | any | List rule sets (parsed JSON) |
|
||||||
|
| POST | `/api/tax-rules` | admin, finance | Create a rule set |
|
||||||
|
| PUT | `/api/tax-rules/:id` | admin, finance | Update a rule set |
|
||||||
|
| DELETE | `/api/tax-rules/:id` | admin, finance | Delete a rule set |
|
||||||
|
| POST | `/api/tax-rules/:id/activate` | admin, finance | Activate (deactivates others in the same jurisdiction) |
|
||||||
|
| POST | `/api/tax-rules/expand` | admin, finance | Merge the generated 68-method catalog into a draft |
|
||||||
|
|
||||||
|
See [Tax Rule Sets](#tax-rule-sets-tax-templates-1) below for the full JSON schema.
|
||||||
|
|
||||||
|
### Reports
|
||||||
|
|
||||||
|
| Method | Path | Roles | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/reports/catalog` | any | Report types + parameter specs |
|
||||||
|
| POST | `/api/reports/run` | any | Run a report `{ type, options }` → `{ report }` |
|
||||||
|
| POST | `/api/reports/export` | any | Export `{ type, options, format }` (`pdf|xlsx|csv`) |
|
||||||
|
| GET | `/api/saved-reports` | any | List saved reports |
|
||||||
|
| POST | `/api/saved-reports` | admin, finance, operations | Save a report `{ name, report_type, options }` |
|
||||||
|
| DELETE | `/api/saved-reports/:id` | admin, finance, operations | Delete a saved report |
|
||||||
|
| GET | `/api/reports/depreciation` | any | Legacy annual depreciation (`?year=&book=`) |
|
||||||
|
| GET | `/api/reports/monthly-depreciation` | any | Legacy monthly depreciation |
|
||||||
|
| GET | `/api/reports/depreciation.pdf` | any | Legacy PDF |
|
||||||
|
|
||||||
|
Report types include `depreciation_expense`, `monthly_depreciation`, `quarterly_depreciation`, `ytd_depreciation`, `net_book_value`, `lifetime_depreciation`, `projection`, `rollforward`, `acquisitions`, `disposals`, `adjustments`, `cip`, `journal_depreciation`, `journal_disposals`, `form_4562`, `form_4797`, `amt`, `ace`, `personal_property_tax`, `ubia`, `fixed_asset_card`, `custom`, and the maintenance set: `pm_due` (PM due before a selectable date), `pm_uncompleted` (assets with overdue PM), `pm_completed` (PM completed since a selectable date), `pm_plan` (printable individual PM plan via selector), and `alerts_since` (alerts raised since a selectable date). Parameter types include `select`, `multiselect`, `number`, and `date`. A report result has the shape `{ title, columns:[{key,label,format}], rows:[], totals:{}, chart?, meta? }`.
|
||||||
|
|
||||||
|
### Import / export
|
||||||
|
|
||||||
|
| Method | Path | Roles | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/export/assets?format=` | any | Export assets (`json|csv|xlsx|xml`) |
|
||||||
|
| POST | `/api/import/assets` | admin, finance | Import assets (multipart `file`: JSON/CSV/XLSX/XML) |
|
||||||
|
|
||||||
|
### Preventative maintenance
|
||||||
|
|
||||||
|
| Method | Path | Roles | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/pm-plans` | any | List PM plans (steps/components; photo `photo_count` only, no blobs) |
|
||||||
|
| GET | `/api/pm-plans/:id` | any | One plan with full guidance-photo image data |
|
||||||
|
| POST | `/api/pm-plans` | admin, finance, operations | Create a plan `{ name, frequency_value, frequency_unit, steps[], components[], photos[] }` (steps carry `est_minutes`; estimated minutes auto-total) |
|
||||||
|
| PUT | `/api/pm-plans/:id` | admin, finance, operations | Update a plan (photos: resend existing as `{ id, caption }`, new as `{ data, name, mime, caption }`) |
|
||||||
|
| DELETE | `/api/pm-plans/:id` | admin, finance | Delete a plan |
|
||||||
|
| GET | `/api/pm-settings` | any | PM defaults (frequency + alert lead days) |
|
||||||
|
| PUT | `/api/pm-settings` | admin | Save PM defaults |
|
||||||
|
| GET | `/api/assets/:id/pm` | any | Asset's attached PM schedules + completions |
|
||||||
|
| POST | `/api/assets/:id/pm` | admin, finance, operations | Attach a plan `{ plan_id, frequency_value, frequency_unit, start_date, end_date }` |
|
||||||
|
| PUT | `/api/assets/:id/pm/:apId` | admin, finance, operations | Update an asset's PM schedule |
|
||||||
|
| DELETE | `/api/assets/:id/pm/:apId` | admin, finance, operations | Detach a schedule |
|
||||||
|
| POST | `/api/assets/:id/pm/:apId/complete` | admin, finance, operations | Record a service `{ steps[], components[], cost, notes_list[], ratings{safety,physical,operating}, signature, photos[] }` (signature required) and advance next-due |
|
||||||
|
| GET | `/api/pm-completions` | any | List completed PM forms (`?asset_id=`) |
|
||||||
|
| GET | `/api/pm-completions/:id` | any | Full completion form (signature, ratings, notes, photos) |
|
||||||
|
|
||||||
|
`frequency_unit` is `days | weeks | months | quarters | semiannual | years`. PM schedules feed the alerts engine (`pm_due` / `pm_overdue`).
|
||||||
|
|
||||||
|
**Photos.** Assets carry **identification photos** (Photos tab in the asset workbench) — what it looks like, where it is, nameplate/serial labels — each with an optional caption. PM plans carry **guidance photos** (diagrams, part locations, before/after) that appear to the technician on the completion form. On a phone, the "Add photos" buttons open the camera (`capture`). Photos are stored as base64 data URLs; plan guidance photos are also surfaced on each asset's PM schedule (`/api/assets/:id/pm`).
|
||||||
|
|
||||||
|
### Parts inventory
|
||||||
|
|
||||||
|
A standalone inventory of maintenance parts & supplies (sub-menu under **Maintenance**). It is **not** part of the depreciation register — parts are not assets and do not depreciate. Each part tracks stock per location with **aisle/bin**, on-hand vs **reserved** quantities, a reorder point for low-stock flagging, identification details (measurements, manufacturer part #, barcode, photos), a **supplier** chosen from Contacts (vendors/service providers/contractors), and stock stored at one or more **Work Location** contacts.
|
||||||
|
|
||||||
|
| Method | Path | Roles | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/parts` | any | List parts with aggregated stock (`?search=&category=&status=&low_stock=true`) |
|
||||||
|
| GET | `/api/parts/:id` | any | Single part with stock locations, photos, and movement history |
|
||||||
|
| POST | `/api/parts` | admin, finance, operations | Create a part `{ part_number, name, category, unit_of_measure, unit_cost, reorder_point, reorder_quantity, measurements, manufacturer, barcode, supplier_contact_id, status }` |
|
||||||
|
| PUT | `/api/parts/:id` | admin, finance, operations | Update a part |
|
||||||
|
| DELETE | `/api/parts/:id` | admin, finance | Delete a part |
|
||||||
|
| POST | `/api/parts/:id/stock` | admin, finance, operations | Create/update a stock location `{ id?, location_contact_id, aisle, bin, quantity, reserved }` |
|
||||||
|
| DELETE | `/api/parts/:id/stock/:stockId` | admin, finance, operations | Remove a stock location |
|
||||||
|
| POST | `/api/parts/:id/transactions` | admin, finance, operations | Record a movement `{ stock_id, type, quantity, asset_id?, notes? }` |
|
||||||
|
| POST | `/api/parts/:id/photos` | admin, finance, operations | Attach a photo `{ name, mime, data }` (base64 data URL) |
|
||||||
|
| DELETE | `/api/parts/:id/photos/:photoId` | admin, finance, operations | Delete a photo |
|
||||||
|
|
||||||
|
Movement `type` is `receive` (add on-hand), `issue` (consume), `adjust` (set on-hand to an absolute count), `reserve`, or `unreserve`. `low_stock` is flagged when on-hand ≤ a non-zero reorder point. `available` = on-hand − reserved. All stock/movement/photo endpoints return the fully hydrated part.
|
||||||
|
|
||||||
|
### Alerts & notifications
|
||||||
|
|
||||||
|
| Method | Path | Roles | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/alerts` | any | List alerts (`?status=&type=`) + summary |
|
||||||
|
| POST | `/api/alerts/scan` | admin, finance, operations | Recompute alerts from due/expiring items |
|
||||||
|
| POST | `/api/alerts/run` | admin | Scan and notify (email digest and/or webhook) on new alerts |
|
||||||
|
| PUT | `/api/alerts/:id/acknowledge` | admin, finance, operations | Acknowledge an alert |
|
||||||
|
| PUT | `/api/alerts/:id/dismiss` | admin, finance, operations | Dismiss an alert (won't reappear) |
|
||||||
|
| POST | `/api/alerts/:id/ticket` | admin, finance, operations | Submit the alert as a ServiceNow incident (returns the existing one if already raised) |
|
||||||
|
| GET | `/api/notifications/settings` | admin | SMTP + alert + webhook settings (password/secret masked) |
|
||||||
|
| PUT | `/api/notifications/settings` | admin | Save settings (send `smtp_password`/`webhook_secret` only to change them; `webhook_secret_clear: true` removes the secret) |
|
||||||
|
| POST | `/api/notifications/test` | admin | Send a test email (`{ to }` optional) |
|
||||||
|
| POST | `/api/notifications/webhook-test` | admin | POST a test event to the configured webhook URL |
|
||||||
|
|
||||||
|
Alert types: `maintenance_overdue`, `maintenance_due`, `warranty_expired`, `warranty_expiring`, `lease_expiring`, `pm_due`, `pm_overdue`. Severity is `critical` (overdue/expired or within 7 days) or `warning`. The server also runs the scan-and-notify cycle ~twice daily; it's a no-op unless something is configured.
|
||||||
|
|
||||||
|
**Webhook delivery.** When `webhook_enabled` is true and `webhook_url` is set, each newly raised alert is delivered as its own JSON `POST` (one request per alert) on the alert cycle — independently of, or alongside, email. An alert is marked notified once any channel handles it, so it is delivered at most once. If `webhook_secret` is set, the raw request body is signed with HMAC-SHA256 and sent in the `X-MixedAssets-Signature: sha256=<hex>` header so the receiver can verify authenticity. Per-alert payload:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"event": "alert",
|
||||||
|
"id": 142, "type": "pm_overdue", "severity": "critical",
|
||||||
|
"title": "PM overdue: Lift quarterly service",
|
||||||
|
"message": "Lift quarterly service on FA-1042 was due 2026-05-20.",
|
||||||
|
"due_date": "2026-05-20", "status": "open",
|
||||||
|
"reference_type": "asset_pm", "reference_id": 88,
|
||||||
|
"asset_id": 1042, "asset_code": "FA-1042",
|
||||||
|
"created_at": "2026-06-04T12:00:00.000Z", "sent_at": "2026-06-04T12:05:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin & integrations
|
||||||
|
|
||||||
|
| Method | Path | Roles | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/api/settings` | admin | App settings |
|
||||||
|
| PUT | `/api/settings` | admin | Update settings `{ settings:{} }` |
|
||||||
|
| GET | `/api/users` | admin | List users |
|
||||||
|
| POST | `/api/users` | admin | Create user |
|
||||||
|
| PUT | `/api/users/:id` | admin | Update user (role/status/team/password) |
|
||||||
|
| GET | `/api/teams` | admin | List teams |
|
||||||
|
| POST | `/api/teams` | admin | Create team |
|
||||||
|
| GET | `/api/access-control` | admin | Roles + capability matrix |
|
||||||
|
| GET | `/api/workday/connection` | admin | Workday connector config |
|
||||||
|
| PUT | `/api/workday/connection` | admin | Save Workday connector |
|
||||||
|
| POST | `/api/workday/sync-workers` | admin | Pull workers from Workday |
|
||||||
|
| POST | `/api/workday/import-workers` | admin | Import a worker payload |
|
||||||
|
| GET | `/api/servicenow/settings` | admin | ServiceNow connection config (password masked) |
|
||||||
|
| PUT | `/api/servicenow/settings` | admin | Save ServiceNow config (send `servicenow_password` only to change it; `servicenow_password_clear: true` removes it) |
|
||||||
|
| POST | `/api/servicenow/test` | admin | Verify the connection (GETs the incident table) |
|
||||||
|
| POST | `/api/servicenow/cmdb-sync` | admin, finance | Pull configuration items from the CMDB into the asset register |
|
||||||
|
|
||||||
|
### ServiceNow integration
|
||||||
|
|
||||||
|
Configured in **Admin → ServiceNow integration** using **basic auth** over HTTPS against a ServiceNow instance (`https://<instance>.service-now.com`).
|
||||||
|
|
||||||
|
**Incident push.** Alerts can be submitted as ServiceNow incidents via the Table API (`POST /api/now/table/{incident_table}`). From the **Alerts** screen, the **ServiceNow** action on any open alert creates an incident and links it back (number + deep link shown in the Ticket column). Severity maps to impact/urgency (critical → 1/1, warning → 2/2, else 3/3); optional `assignment_group` and `caller_id` are applied when set. With **auto-create** enabled, the alert cycle raises an incident for each new *critical* alert automatically. Alerts store the linked `external_ticket_number` / `external_ticket_url` and won't duplicate.
|
||||||
|
|
||||||
|
**CMDB pull.** The asset register can be populated from the CMDB via the Table API (`GET /api/now/table/{ci_table}`, default `cmdb_ci_hardware`), requested with display values. A JSON **field map** (asset field → CI column, with sensible hardware defaults) controls population; an optional `sysparm_query` filters which CIs are pulled. Existing assets are matched by ServiceNow `sys_id`, then serial number, then asset tag, so re-syncing updates in place rather than duplicating. The CI's `sys_id` is stored on the asset (`servicenow_sys_id`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tax Rule Sets ("tax templates")
|
||||||
|
|
||||||
|
A **tax rule set** is the JSON template that tells the depreciation engine which methods exist and how to compute them, plus the statutory limits for a jurisdiction (e.g. `US-FED`, `US-CA`). It satisfies the requirement for a JSON-driven system for federal/state/local depreciation-law updates.
|
||||||
|
|
||||||
|
Rule sets are stored in the `tax_rule_sets` table and edited in the **Tax rules** screen (Monaco JSON editor) or via the API. The starter federal template lives at `data/tax-rules/federal-2026.json` and is seeded on first run.
|
||||||
|
|
||||||
|
## How a rule set is selected
|
||||||
|
|
||||||
|
Each **book** (GAAP, Federal, …) has a `tax_rule_set_id`. When the engine depreciates an asset for a given book, it uses **that book's assigned rule set**; if a book has no assignment, it falls back to the globally *active* rule set. Assign rule sets to books on the **Books** screen.
|
||||||
|
|
||||||
|
> Because the engine looks up each asset book's `depreciation_method` **by code** inside the rule set's `methods[]` array, the assigned rule set must contain the method codes your assets use. Use **Regenerate method catalog** (UI) or `POST /api/tax-rules/expand` to inject the standard 68 methods, then add or edit any custom methods.
|
||||||
|
|
||||||
|
## Top-level fields
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"schema": "mixedassets.taxRuleSet.v1", // optional label
|
||||||
|
"jurisdiction": "US-FED", // unique key with version
|
||||||
|
"name": "Federal 68-method depreciation catalog",
|
||||||
|
"version": "2026.2", // unique per jurisdiction
|
||||||
|
"effectiveDate": "2026-01-01", // ISO date
|
||||||
|
"sourceNote": "Validate against current IRS guidance before filing.",
|
||||||
|
"books": ["GAAP", "FEDERAL", "STATE", "AMT", "ACE", "USER"],
|
||||||
|
"limits": { /* see below */ },
|
||||||
|
"conventions": ["half_year","mid_quarter","mid_month","full_month","none"],
|
||||||
|
"assetLives": [ { "code": "5Y", "label": "5-year property", "months": 60 } ],
|
||||||
|
"methods": [ /* see below — the part the engine computes from */ ],
|
||||||
|
"catalogNotes": ["free-form notes"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `jurisdiction` | string | Paired with `version` as a unique key; assign to a book by id |
|
||||||
|
| `name`, `version`, `effectiveDate` | string | Metadata; `version` must be unique within a jurisdiction |
|
||||||
|
| `limits` | object | Statutory thresholds (reference/advisory — see below) |
|
||||||
|
| `conventions` | string[] | Conventions offered in the UI |
|
||||||
|
| `assetLives` | object[] | `{ code, label, months }` reference table of class lives |
|
||||||
|
| `methods` | object[] | **Required for calculation** — the method catalog (see below) |
|
||||||
|
|
||||||
|
## `limits` object
|
||||||
|
|
||||||
|
These values are stored for compliance reference and surfaced in the UI. The engine applies §179 and bonus **per asset-book** (from each book's `section_179_amount` and `bonus_percent`); the limits document the statutory caps your data entry should respect.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
"limits": {
|
||||||
|
"deMinimisSafeHarbor": {
|
||||||
|
"defaultThreshold": 2500,
|
||||||
|
"auditedFinancialStatementThreshold": 5000
|
||||||
|
},
|
||||||
|
"section179": {
|
||||||
|
"deductionLimit": 2500000,
|
||||||
|
"phaseOutBegins": 4000000,
|
||||||
|
"cannotCreateLoss": true
|
||||||
|
},
|
||||||
|
"bonusDepreciation": {
|
||||||
|
"qualifiedPropertyPercent": 100,
|
||||||
|
"effectivePlacedInServiceAfter": "2025-01-19"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `methods[]` — the calculation catalog
|
||||||
|
|
||||||
|
Each method is referenced by an asset book's `depreciation_method` (its `code`). Shape:
|
||||||
|
|
||||||
|
| Field | Type | Used by | Notes |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `code` | string | all | Unique identifier referenced by asset books |
|
||||||
|
| `family` | string | UI | Grouping label: `MACRS`, `ACRS`, `GAAP`, `Manual`, `Lease` |
|
||||||
|
| `label` | string | UI | Human-readable name |
|
||||||
|
| `formula` | string | engine | One of the formulas below — drives the math |
|
||||||
|
| `lifeMonths` | number | engine | Optional; overrides the book/asset useful life |
|
||||||
|
| `defaultConvention` | string | engine | Convention applied for first/last partial years |
|
||||||
|
| `rateMultiplier` | number | engine | Declining-balance rate (e.g. `2` = 200%, `1.5` = 150%) |
|
||||||
|
| `switchToStraightLine` | boolean | engine | MACRS-style switch to SL when it yields more |
|
||||||
|
| `rates` | number[] | engine | For `rate_table`: per-year fraction of basis |
|
||||||
|
| `fallbackFormula` | string | engine | Used when `rates` is empty (e.g. `straight_line`) |
|
||||||
|
| `system`, `legacy`, `sourceNote`, `requiresMonthTable`, `requiresListedPropertyTable` | mixed | reference | Informational only |
|
||||||
|
|
||||||
|
### Supported `formula` values
|
||||||
|
|
||||||
|
| `formula` | Computation | Honors fields |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `straight_line` | `basis / years`, prorated by convention | `lifeMonths`, `defaultConvention` |
|
||||||
|
| `sum_of_years_digits` | SYD acceleration | `lifeMonths` |
|
||||||
|
| `declining_balance` | Declining balance at `rateMultiplier`; optional SL switch | `rateMultiplier`, `switchToStraightLine`, `defaultConvention`, `lifeMonths` |
|
||||||
|
| `macrs_declining_balance` | Same as `declining_balance` (MACRS GDS/ADS) | same as above |
|
||||||
|
| `rate_table` | `basis × rates[year]`; falls back to `fallbackFormula` if `rates` empty | `rates`, `fallbackFormula`, `lifeMonths` |
|
||||||
|
| `acrs_alternate_straight_line` | Straight-line forced to half-year convention (legacy ACRS) | `lifeMonths` |
|
||||||
|
| `manual` | Reads per-year amounts from the asset book's `manual_depreciation` map | — |
|
||||||
|
| *(unknown)* | Falls back to `straight_line` | — |
|
||||||
|
|
||||||
|
### Depreciable basis & conventions
|
||||||
|
|
||||||
|
The engine computes, for each asset book:
|
||||||
|
|
||||||
|
```
|
||||||
|
depreciable basis = max(0, (cost − land − salvage) × businessUse%) − §179 − bonus
|
||||||
|
§179 = min(basis, book.section_179_amount)
|
||||||
|
bonus = (basis − §179) × book.bonus_percent / 100 (taken in year 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
First-year proration by convention (subsequent years run full, with the remainder pushed into the final year):
|
||||||
|
|
||||||
|
| Convention | First-year fraction |
|
||||||
|
| --- | --- |
|
||||||
|
| `half_year` | `0.5` |
|
||||||
|
| `mid_quarter` | Q1 `0.875`, Q2 `0.625`, Q3 `0.375`, Q4 `0.125` |
|
||||||
|
| `mid_month` | `(12 − month + 0.5) / 12` |
|
||||||
|
| `full_month` | `(13 − month) / 12` |
|
||||||
|
| `none` | `1` (full year) |
|
||||||
|
|
||||||
|
The effective life is `ceil(lifeMonths / 12)` where `lifeMonths` is the method's `lifeMonths`, else the book's `useful_life_months`, else the asset's, else 60.
|
||||||
|
|
||||||
|
## Worked examples
|
||||||
|
|
||||||
|
A minimal rule set with one custom straight-line method and one rate-table method:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"jurisdiction": "US-CA",
|
||||||
|
"name": "California starter",
|
||||||
|
"version": "2026.1",
|
||||||
|
"effectiveDate": "2026-01-01",
|
||||||
|
"limits": { "section179": { "deductionLimit": 25000, "phaseOutBegins": 200000 } },
|
||||||
|
"conventions": ["half_year", "mid_month", "none"],
|
||||||
|
"assetLives": [{ "code": "5Y", "label": "5-year", "months": 60 }],
|
||||||
|
"methods": [
|
||||||
|
{
|
||||||
|
"code": "ca_sl_5",
|
||||||
|
"family": "GAAP",
|
||||||
|
"label": "CA straight-line 5-year",
|
||||||
|
"formula": "straight_line",
|
||||||
|
"lifeMonths": 60,
|
||||||
|
"defaultConvention": "half_year"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ca_macrs_5",
|
||||||
|
"family": "MACRS",
|
||||||
|
"label": "CA MACRS 5-year (table)",
|
||||||
|
"formula": "rate_table",
|
||||||
|
"lifeMonths": 60,
|
||||||
|
"defaultConvention": "half_year",
|
||||||
|
"rates": [0.20, 0.32, 0.192, 0.1152, 0.1152, 0.0576]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A 200% declining-balance MACRS method with the straight-line switch:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"code": "macrs_gds_200db_5",
|
||||||
|
"family": "MACRS",
|
||||||
|
"label": "MACRS GDS 200% DB 5-year",
|
||||||
|
"formula": "macrs_declining_balance",
|
||||||
|
"lifeMonths": 60,
|
||||||
|
"rateMultiplier": 2,
|
||||||
|
"switchToStraightLine": true,
|
||||||
|
"defaultConvention": "half_year"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating a tax template
|
||||||
|
|
||||||
|
**In the UI (Tax rules screen, admin/finance):**
|
||||||
|
|
||||||
|
1. Click **New** (blank draft) or **Upload JSON** to load a file into the editor.
|
||||||
|
2. Fill in metadata (name, jurisdiction, version, effective date) and edit the JSON body in the Monaco editor — invalid JSON disables Save and shows the parse error.
|
||||||
|
3. Optionally click **Regenerate method catalog** to inject the standard 68 methods, then add/edit custom methods.
|
||||||
|
4. **Save**, then **Activate** to make it the active set for its jurisdiction. **Export** downloads the JSON; assign it to books on the **Books** screen.
|
||||||
|
|
||||||
|
**Via the API:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN=... # from /api/auth/login
|
||||||
|
|
||||||
|
# (optional) expand a draft to include the 68-method catalog
|
||||||
|
curl -s http://localhost:3000/api/tax-rules/expand \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"rules_json":{"jurisdiction":"US-CA","version":"2026.1"}}'
|
||||||
|
|
||||||
|
# create
|
||||||
|
curl -s http://localhost:3000/api/tax-rules \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"jurisdiction":"US-CA","name":"California starter","version":"2026.1",
|
||||||
|
"effective_date":"2026-01-01","active":false,
|
||||||
|
"rules_json":{ "jurisdiction":"US-CA","version":"2026.1","methods":[ /* ... */ ] }
|
||||||
|
}'
|
||||||
|
|
||||||
|
# activate (deactivates other US-CA sets)
|
||||||
|
curl -s -X POST http://localhost:3000/api/tax-rules/<id>/activate \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then assign the rule set to a book:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -X PUT http://localhost:3000/api/books/<bookId> \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"tax_rule_set_id": <ruleSetId>}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Asset entry templates
|
||||||
|
|
||||||
|
Separate from tax rule sets, **asset entry templates** (`/api/templates`) speed up data entry by pre-filling defaults and adding custom fields. A template has:
|
||||||
|
|
||||||
|
- `defaults` — an object of asset fields applied on selection (e.g. `category`, `useful_life_months`, `depreciation_method`, GL accounts).
|
||||||
|
- `custom_fields` — an array of field definitions: `{ key, label, type, required, defaultValue }` where `type` is `text | numeric | date | bool`.
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"name": "Laptop or workstation",
|
||||||
|
"description": "Standard computer equipment, 5-year life",
|
||||||
|
"defaults": { "category": "Computer Equipment", "useful_life_months": 60, "depreciation_method": "macrs_gds_200db_5" },
|
||||||
|
"custom_fields": [
|
||||||
|
{ "key": "assigned_employee_id", "label": "Employee ID", "type": "text", "required": false },
|
||||||
|
{ "key": "calibration_due", "label": "Calibration due", "type": "date", "required": false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Compliance Note
|
## Compliance Note
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@lucide/vue": "^1.17.0",
|
"@lucide/vue": "^1.17.0",
|
||||||
"@mdi/font": "^7.4.47",
|
"@mdi/font": "^7.4.47",
|
||||||
"@vitejs/plugin-vue": "^6.0.7",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
|
"@zxing/browser": "^0.1.5",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
@@ -27,9 +28,12 @@
|
|||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"fast-xml-parser": "^5.8.0",
|
"fast-xml-parser": "^5.8.0",
|
||||||
|
"jsbarcode": "^3.12.3",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
|
"monaco-editor": "^0.52.2",
|
||||||
"multer": "^2.1.1",
|
"multer": "^2.1.1",
|
||||||
|
"nodemailer": "^8.0.10",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
"pdfkit": "^0.18.0",
|
"pdfkit": "^0.18.0",
|
||||||
|
|||||||
@@ -7,14 +7,22 @@ const { createCorsMiddleware } = require('./middleware/cors');
|
|||||||
const { requireAuth } = require('./middleware/auth');
|
const { requireAuth } = require('./middleware/auth');
|
||||||
|
|
||||||
const adminRoutes = require('./routes/admin');
|
const adminRoutes = require('./routes/admin');
|
||||||
|
const alertRoutes = require('./routes/alerts');
|
||||||
const assetRoutes = require('./routes/assets');
|
const assetRoutes = require('./routes/assets');
|
||||||
const assignmentRoutes = require('./routes/assignments');
|
const assignmentRoutes = require('./routes/assignments');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
|
const bookRoutes = require('./routes/books');
|
||||||
|
const contactRoutes = require('./routes/contacts');
|
||||||
const dashboardRoutes = require('./routes/dashboard');
|
const dashboardRoutes = require('./routes/dashboard');
|
||||||
const dataPortabilityRoutes = require('./routes/dataPortability');
|
const dataPortabilityRoutes = require('./routes/dataPortability');
|
||||||
|
const disposalRoutes = require('./routes/disposals');
|
||||||
|
const leaseRoutes = require('./routes/leases');
|
||||||
|
const partRoutes = require('./routes/parts');
|
||||||
|
const pmRoutes = require('./routes/pm');
|
||||||
const profileRoutes = require('./routes/profile');
|
const profileRoutes = require('./routes/profile');
|
||||||
const referenceRoutes = require('./routes/reference');
|
const referenceRoutes = require('./routes/reference');
|
||||||
const reportRoutes = require('./routes/reports');
|
const reportRoutes = require('./routes/reports');
|
||||||
|
const serviceNowRoutes = require('./routes/servicenow');
|
||||||
const taxRuleRoutes = require('./routes/taxRules');
|
const taxRuleRoutes = require('./routes/taxRules');
|
||||||
const templateRoutes = require('./routes/templates');
|
const templateRoutes = require('./routes/templates');
|
||||||
const workdayRoutes = require('./routes/workday');
|
const workdayRoutes = require('./routes/workday');
|
||||||
@@ -36,10 +44,18 @@ function createApp() {
|
|||||||
app.use('/api', dashboardRoutes);
|
app.use('/api', dashboardRoutes);
|
||||||
app.use('/api', referenceRoutes);
|
app.use('/api', referenceRoutes);
|
||||||
app.use('/api', assetRoutes);
|
app.use('/api', assetRoutes);
|
||||||
|
app.use('/api', alertRoutes);
|
||||||
|
app.use('/api', bookRoutes);
|
||||||
|
app.use('/api', contactRoutes);
|
||||||
|
app.use('/api', disposalRoutes);
|
||||||
|
app.use('/api', leaseRoutes);
|
||||||
|
app.use('/api', partRoutes);
|
||||||
|
app.use('/api', pmRoutes);
|
||||||
app.use('/api', assignmentRoutes);
|
app.use('/api', assignmentRoutes);
|
||||||
app.use('/api', templateRoutes);
|
app.use('/api', templateRoutes);
|
||||||
app.use('/api', taxRuleRoutes);
|
app.use('/api', taxRuleRoutes);
|
||||||
app.use('/api', reportRoutes);
|
app.use('/api', reportRoutes);
|
||||||
|
app.use('/api', serviceNowRoutes);
|
||||||
app.use('/api', dataPortabilityRoutes);
|
app.use('/api', dataPortabilityRoutes);
|
||||||
app.use('/api', adminRoutes);
|
app.use('/api', adminRoutes);
|
||||||
app.use('/api', workdayRoutes);
|
app.use('/api', workdayRoutes);
|
||||||
|
|||||||
342
server/data/irsAssetClasses.js
Normal file
342
server/data/irsAssetClasses.js
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
// IRS Table 1 — Alphabetical Listing of Commonly Used Assets (MACRS asset classes with
|
||||||
|
// GDS and ADS recovery periods). Source: IRS Pub. 946 as published by Sage. Reference data
|
||||||
|
// used to pre-populate the Asset Class Number on asset categories. null = no class number /
|
||||||
|
// recovery period determined by another method (amortization, income forecasting, etc.).
|
||||||
|
// Verify against current tax law before relying on these for a filing.
|
||||||
|
|
||||||
|
// IRS Table 1 — Alphabetical Listing of Commonly Used Assets.
|
||||||
|
const COMMON = [
|
||||||
|
{ description: 'Adding Machines', class_number: '00.13', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Agricultural Machinery and Equipment', class_number: '01.1', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Airplanes, Commercial', class_number: '45.0', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Airplanes, Noncommercial', class_number: '00.21', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Appliances in Rental Property', class_number: null, gds: 5, ads: 9 },
|
||||||
|
{ description: 'Automobiles', class_number: '00.22', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Barges', class_number: '00.28', gds: 10, ads: 18 },
|
||||||
|
{ description: 'Billboards', class_number: '57.1', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Boats', class_number: '00.28', gds: 10, ads: 18 },
|
||||||
|
{ description: 'Bookcases', class_number: '00.11', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Books (Professional Library)', class_number: '57.0', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Bridges', class_number: '00.3', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Buildings, Residential Rental', class_number: null, gds: 27.5, ads: 30 },
|
||||||
|
{ description: 'Buildings, if not Residential Rental', class_number: null, gds: 39, ads: 40 },
|
||||||
|
{ description: 'Buildings, Car Wash', class_number: '57.1', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Buildings, Farm (not single-purpose structures)', class_number: '01.3', gds: 20, ads: 25 },
|
||||||
|
{ description: 'Buildings, Service Station', class_number: '57.1', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Buildings, Telephone Central Office', class_number: '48.11', gds: 20, ads: 45 },
|
||||||
|
{ description: 'Buses', class_number: '00.23', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Calculators', class_number: '00.13', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Canals', class_number: '00.3', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Carpeting', class_number: '57.0', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Cars', class_number: '00.22', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Car Phones', class_number: '00.11', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Car Wash Buildings', class_number: '57.1', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Cash Registers', class_number: '57.0', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Cattle, Breeding or Dairy', class_number: '01.21', gds: 5, ads: 7 },
|
||||||
|
{ description: 'Cell Phones', class_number: '00.11', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Coin-Operated Dispensing Machines', class_number: '57.0', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Communications Equipment (not included elsewhere)', class_number: '00.11', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Computers', class_number: '00.12', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Computer Peripheral Equipment', class_number: '00.12', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Computer Software – Amortized', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Concrete Ready Mix Trucks', class_number: '00.242', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Construction Assets', class_number: '15.0', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Copiers', class_number: '00.13', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Copyrights – Amortized', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Cotton Ginning Assets', class_number: '01.11', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Covenants Not to Compete – Amortized', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Customer Lists – Amortized', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Data Entry Devices', class_number: '00.12', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Desks', class_number: '00.11', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Disk Drives', class_number: '00.12', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Disk Files', class_number: '00.12', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Disk Packs', class_number: '00.12', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Docks', class_number: '00.3', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Drainage Facilities', class_number: '00.3', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Duplicating Equipment', class_number: '00.13', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Elevators, if Residential Rental', class_number: null, gds: 27.5, ads: 30 },
|
||||||
|
{ description: 'Elevators, if not Residential Rental', class_number: null, gds: 39, ads: 40 },
|
||||||
|
{ description: 'Escalators, if Residential Rental', class_number: null, gds: 27.5, ads: 30 },
|
||||||
|
{ description: 'Escalators, if not Residential Rental', class_number: null, gds: 39, ads: 40 },
|
||||||
|
{ description: 'Farm Buildings (not single-purpose structures)', class_number: '01.3', gds: 20, ads: 25 },
|
||||||
|
{ description: 'Fax Machines', class_number: '00.11', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Fences for Agriculture', class_number: '01.1', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Fences not for Agriculture', class_number: '00.3', gds: 15, ads: 20 },
|
||||||
|
{ description: 'File Cabinets', class_number: '00.11', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Films', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Fixtures, Office', class_number: '00.11', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Fixtures in Rental Property', class_number: null, gds: 5, ads: 9 },
|
||||||
|
{ description: 'Franchises – Amortized', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Furniture, Office', class_number: '00.11', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Furniture in Rental Property', class_number: '57.0', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Goats, Breeding', class_number: '01.24', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Goodwill – Amortized', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Grain Bins', class_number: '01.1', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Helicopters', class_number: '00.21', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Hogs, Breeding', class_number: '01.23', gds: 3, ads: 3 },
|
||||||
|
{ description: 'Horses, Breeding or Work (12 years old or less)', class_number: '01.221', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Horses, Breeding or Work (more than 12 years old)', class_number: '01.222', gds: 3, ads: 10 },
|
||||||
|
{ description: 'Horses, Race (more than 2 years old at placed in service date)', class_number: '01.223', gds: 3, ads: 12 },
|
||||||
|
{ description: 'Horses, not Race, Breeding, or Work (more than 12 years old)', class_number: '01.224', gds: 3, ads: 12 },
|
||||||
|
{ description: 'Horses, Other (not described in any other category)', class_number: '01.225', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Hotels', class_number: null, gds: 39, ads: 40 },
|
||||||
|
{ description: 'Land Improvements (not in any other class)', class_number: '00.3', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Landscaping Shrubbery', class_number: '00.3', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Leasehold Improvements', class_number: null, gds: 39, ads: 40 },
|
||||||
|
{ description: 'Magnetic Tape Feeds', class_number: '00.12', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Mobile Homes, if Residential Rental', class_number: null, gds: 27.5, ads: 40 },
|
||||||
|
{ description: 'Mobile Homes, if not Residential Rental', class_number: null, gds: 39, ads: 40 },
|
||||||
|
{ description: 'Motels', class_number: null, gds: 39, ads: 40 },
|
||||||
|
{ description: 'Motion Pictures', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Motorsports Entertainment Complex', class_number: null, gds: 7, ads: 40 },
|
||||||
|
{ description: 'Nonresidential Real Property', class_number: null, gds: 39, ads: 40 },
|
||||||
|
{ description: 'Office Furniture', class_number: '00.11', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Optical Character Readers', class_number: '00.12', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Ore Trucks', class_number: '00.242', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Organization Costs – Amortized', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Patents – Amortized', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Plotters', class_number: '00.12', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Printers', class_number: '00.12', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Qualified Improvement Property', class_number: null, gds: 15, ads: 20 },
|
||||||
|
{ description: 'Qualified Restaurant Property', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Qualified Retail Property', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Radio Transmitting Towers', class_number: '00.3', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Railroad Cars and Locomotives, Commercial', class_number: '40.1', gds: 7, ads: 14 },
|
||||||
|
{ description: 'Railroad Cars and Locomotives, if not Commercial', class_number: '00.25', gds: 7, ads: 15 },
|
||||||
|
{ description: 'Railroad Machinery and Equipment', class_number: '40.1', gds: 7, ads: 14 },
|
||||||
|
{ description: 'Railroad Structures and Improvements', class_number: '40.2', gds: 20, ads: 30 },
|
||||||
|
{ description: 'Railroad Track', class_number: '40.4', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Recreational Vehicles (RV) (Unloaded Weight 13,000 lbs or More)', class_number: '00.242', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Recreational Vehicles (RV) (Unloaded Weight Less than 13,000 lbs)', class_number: '00.241', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Rent-to-Own Property', class_number: null, gds: 3, ads: 4 },
|
||||||
|
{ description: 'Rental Formal Wear', class_number: '57.0', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Rental Tuxedos', class_number: '57.0', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Research and Development', class_number: null, gds: 5, ads: null },
|
||||||
|
{ description: 'Residential Rental Real Property', class_number: null, gds: 27.5, ads: 30 },
|
||||||
|
{ description: 'Roads', class_number: '00.3', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Safes', class_number: '00.11', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Service Station Buildings', class_number: '57.1', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Sewers, Municipal', class_number: '51.', gds: 25, ads: 50 },
|
||||||
|
{ description: 'Sewers, if not Municipal Sewers', class_number: '00.3', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Sheep, Breeding', class_number: '01.24', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Ships', class_number: '00.28', gds: 10, ads: 18 },
|
||||||
|
{ description: 'Shrubbery, Landscaping', class_number: '00.3', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Sidewalks', class_number: '00.3', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Single-Purpose Agricultural Structures', class_number: '01.4', gds: 10, ads: 15 },
|
||||||
|
{ description: 'Single-Purpose Horticultural Structures', class_number: '01.4', gds: 10, ads: 15 },
|
||||||
|
{ description: 'Slot Machines', class_number: '79.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Smart Electric Meter', class_number: null, gds: 10, ads: 30 },
|
||||||
|
{ description: 'Smart Grid System', class_number: null, gds: 10, ads: 30 },
|
||||||
|
{ description: 'Sound Recordings', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Syndication Expenses – Capitalized', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Tape Cassettes', class_number: '00.12', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Tape Drives', class_number: '00.12', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Taxis', class_number: '00.22', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Telephones', class_number: '00.11', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Telephone Central Office Buildings', class_number: '48.11', gds: 20, ads: 45 },
|
||||||
|
{ description: 'Telephone Central Office Equipment', class_number: '48.12', gds: 10, ads: 18 },
|
||||||
|
{ description: 'Telephone Distribution Plants', class_number: '48.14', gds: 15, ads: 24 },
|
||||||
|
{ description: 'Telephone Systems', class_number: '00.11', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Teleprinters', class_number: '00.12', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Television Transmitting Towers', class_number: '00.3', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Terminals', class_number: '00.12', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Tractor Units for Use Over the Road', class_number: '00.26', gds: 3, ads: 4 },
|
||||||
|
{ description: 'Trade Names – Amortized', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Trademarks – Amortized', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Trailer-Mounted Containers', class_number: '00.27', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Trailers', class_number: '00.27', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Trees Bearing Fruits or Nuts', class_number: null, gds: 10, ads: 20 },
|
||||||
|
{ description: 'Trucks, Heavy (13,000 lbs or more)', class_number: '00.242', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Trucks, Light (less than 13,000 lbs)', class_number: '00.241', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Tugs (not used in marine construction)', class_number: '00.28', gds: 10, ads: 18 },
|
||||||
|
{ description: 'Typewriters', class_number: '00.13', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Vessels', class_number: '00.28', gds: 10, ads: 18 },
|
||||||
|
{ description: 'Videotapes', class_number: null, gds: null, ads: null },
|
||||||
|
{ description: 'Vines Bearing Fruit or Nuts', class_number: null, gds: 10, ads: 20 },
|
||||||
|
{ description: 'Waterways', class_number: '00.3', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Wharves', class_number: '00.3', gds: 15, ads: 20 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// IRS Table 2 — Property Categorized by the Type of Manufacturing Industry.
|
||||||
|
const INDUSTRY = [
|
||||||
|
{ description: 'Aerospace Products', class_number: '37.2', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Airplanes (manufacturing)', class_number: '37.2', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Amusement Machines', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Apparel (excluding rubber and leather)', class_number: '23.0', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Appliances', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Art Supplies', class_number: '39.0', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Athletic Goods', class_number: '39.0', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Automobiles (manufacturing)', class_number: '37.11', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Boat and Ship Building — Dry Docks and Land Improvements', class_number: '37.32', gds: 10, ads: 16 },
|
||||||
|
{ description: 'Boat and Ship Building — Machinery and Equipment', class_number: '37.31', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Boat and Ship Building — Special Tools', class_number: '37.33', gds: 5, ads: 6.5 },
|
||||||
|
{ description: 'Boiler Systems for Steam and Chemical Recovery', class_number: '26.1', gds: 7, ads: 13 },
|
||||||
|
{ description: 'Brooms and Brushes', class_number: '39.0', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Buses (manufacturing)', class_number: '37.11', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Carpets', class_number: '22.3', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Caskets', class_number: '39.0', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Cement (not Concrete)', class_number: '32.2', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Chemical and Steam Recovery Boiler Systems', class_number: '26.1', gds: 7, ads: 13 },
|
||||||
|
{ description: 'Chemicals and Allied Products', class_number: '28.0', gds: null, ads: null },
|
||||||
|
{ description: 'Clay Products', class_number: '32.3', gds: 7, ads: 15 },
|
||||||
|
{ description: 'Clocks and Watches', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Clothing (excluding rubber and leather)', class_number: '23.0', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Computers and Peripheral Machines (manufacturing)', class_number: '36.0', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Concrete and Concrete Products', class_number: '32.3', gds: 7, ads: 15 },
|
||||||
|
{ description: 'Construction Machinery', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Cooling Systems', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Dental Equipment', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Dental Supplies', class_number: '22.3', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Dyeing Textile Products', class_number: '22.3', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Electronic Components, Products, and Systems', class_number: '36.0', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Farm Machinery (placed in service before 1/1/2018)', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Farm Machinery (placed in service after 12/31/2017)', class_number: '35.0', gds: 5, ads: 10 },
|
||||||
|
{ description: 'Floor Coverings — Hard Surface', class_number: '22.3', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Food and Beverage — Other', class_number: '20.4', gds: null, ads: null },
|
||||||
|
{ description: 'Food and Beverage Special Handling Devices', class_number: '20.5', gds: 3, ads: 4 },
|
||||||
|
{ description: 'Foundry Products', class_number: '33.3', gds: 7, ads: 14 },
|
||||||
|
{ description: 'Furniture (wood)', class_number: '24.4', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Gas — Coal Gasification', class_number: '49.223', gds: 10, ads: 18 },
|
||||||
|
{ description: 'Gas Production Plants', class_number: '49.23', gds: 7, ads: 14 },
|
||||||
|
{ description: 'Glass Products', class_number: '32.1', gds: 7, ads: 14 },
|
||||||
|
{ description: 'Glass Products — Special Tools', class_number: '32.11', gds: 3, ads: 2.5 },
|
||||||
|
{ description: 'Grain and Grain Mill Products', class_number: '20.1', gds: 10, ads: 17 },
|
||||||
|
{ description: 'Jewelry', class_number: '39.0', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Knitted Goods', class_number: '22.1', gds: 5, ads: 7.5 },
|
||||||
|
{ description: 'Leather and Leather Products', class_number: '31.1', gds: 7, ads: 11 },
|
||||||
|
{ description: 'Lighting Fixtures', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Locomotives', class_number: '37.41', gds: 7, ads: 11.5 },
|
||||||
|
{ description: 'Machine Tools', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Machinery, Electrical', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Machinery, Nonelectrical', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Mechanical Products', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Medical Equipment', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Medical Supplies', class_number: '22.3', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Metal Products (Fabricated)', class_number: '34.0', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Metal Products (Fabricated) — Special Tools', class_number: '34.01', gds: 3, ads: 3 },
|
||||||
|
{ description: 'Metals, Primary Nonferrous', class_number: '33.2', gds: 7, ads: 14 },
|
||||||
|
{ description: 'Metals, Primary Nonferrous — Special Tools', class_number: '33.21', gds: 5, ads: 6.5 },
|
||||||
|
{ description: 'Mining Machinery', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Motion Picture Films and Tapes', class_number: '39.0', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Motor Homes (manufacturing)', class_number: '37.11', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Motor Vehicles (manufacturing)', class_number: '37.11', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Motor Vehicles — Special Tools', class_number: '37.12', gds: 3, ads: 3 },
|
||||||
|
{ description: 'Musical Instruments', class_number: '39.0', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Nonwoven Fabrics', class_number: '22.5', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Nonwoven Products', class_number: '22.3', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Office Supplies', class_number: '39.0', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Ophthalmic Goods', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Paper', class_number: '26.1', gds: 7, ads: 13 },
|
||||||
|
{ description: 'Paper Products (converted)', class_number: '26.2', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Paperboard Products', class_number: '26.2', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Pencils and Pens', class_number: '39.0', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Photographic Equipment', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Photographic Supplies', class_number: '28.0', gds: 5, ads: 9.5 },
|
||||||
|
{ description: 'Plastic Finished Products', class_number: '30.2', gds: 7, ads: 11 },
|
||||||
|
{ description: 'Plastic Finished Products — Special Tools', class_number: '30.21', gds: 3, ads: 3.5 },
|
||||||
|
{ description: 'Pollution Control Assets', class_number: '26.1', gds: 7, ads: 13 },
|
||||||
|
{ description: 'Pulp', class_number: '26.1', gds: 7, ads: 13 },
|
||||||
|
{ description: 'Pulp Products (converted)', class_number: '26.2', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Railroad Cars', class_number: '37.42', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Railroad Locomotives', class_number: '37.41', gds: 7, ads: 11.5 },
|
||||||
|
{ description: 'Rubber Products', class_number: '30.1', gds: 7, ads: 14 },
|
||||||
|
{ description: 'Rubber Products — Special Tools and Devices', class_number: '30.11', gds: 3, ads: 4 },
|
||||||
|
{ description: 'Semiconductor Manufacturing Equipment', class_number: '36.1', gds: 5, ads: 5 },
|
||||||
|
{ description: 'Ship and Boat Building — Dry Docks and Land Improvements', class_number: '37.32', gds: 10, ads: 16 },
|
||||||
|
{ description: 'Ship and Boat Building — Machinery and Equipment', class_number: '37.31', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Ship and Boat Building — Special Tools', class_number: '37.33', gds: 5, ads: 6.5 },
|
||||||
|
{ description: 'Sporting Goods', class_number: '39.0', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Steam Utility Production and Distribution', class_number: '49.4', gds: 20, ads: 28 },
|
||||||
|
{ description: 'Steel (Primary) Mill Products', class_number: '33.4', gds: 7, ads: 15 },
|
||||||
|
{ description: 'Stone Products', class_number: '32.3', gds: 7, ads: 15 },
|
||||||
|
{ description: 'Sugar and Sugar Products', class_number: '20.2', gds: 10, ads: 18 },
|
||||||
|
{ description: 'Television Films and Tapes', class_number: '39.0', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Textile Products, Dyeing, Finishing, and Packaging', class_number: '22.3', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Textured Yarns', class_number: '22.4', gds: 5, ads: 8 },
|
||||||
|
{ description: 'Thread', class_number: '22.2', gds: 7, ads: 11 },
|
||||||
|
{ description: 'Tires', class_number: '30.1', gds: 7, ads: 14 },
|
||||||
|
{ description: 'Tobacco and Tobacco Products', class_number: '21.0', gds: 7, ads: 15 },
|
||||||
|
{ description: 'Toys', class_number: '39.0', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Trailers (manufacturing)', class_number: '37.11', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Trucks (manufacturing)', class_number: '37.11', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Vegetable Oils and Products', class_number: '20.3', gds: 10, ads: 18 },
|
||||||
|
{ description: 'Vending Machines', class_number: '35.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Video Recorders', class_number: '36.0', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Wood Products', class_number: '24.4', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Woven Fabric', class_number: '22.2', gds: 7, ads: 11 },
|
||||||
|
{ description: 'Yarn', class_number: '22.2', gds: 7, ads: 11 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// IRS Table 3 — Property Categorized by the Type of Business.
|
||||||
|
const BUSINESS = [
|
||||||
|
{ description: 'Agriculture', class_number: '01.1', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Amusement and Theme Parks', class_number: '80.0', gds: 7, ads: 12.5 },
|
||||||
|
{ description: 'Barber Shop', class_number: '57.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Beauty Shop', class_number: '57.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Bed and Breakfasts', class_number: '57.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Billiard Establishments', class_number: '79.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Bookbinding', class_number: '27.0', gds: 7, ads: 11 },
|
||||||
|
{ description: 'Bowling Alleys', class_number: '79.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Cable Television', class_number: '48.4+', gds: null, ads: null },
|
||||||
|
{ description: 'Casinos', class_number: '00.12', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Communications (Telegraph, Ocean Cable, Satellite)', class_number: '48.3+', gds: null, ads: null },
|
||||||
|
{ description: 'Concert Halls', class_number: '79.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Construction', class_number: '15.0', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Distributive Trades and Services', class_number: '57.0', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Electric Generation (industrial)', class_number: '00.4', gds: 15, ads: 22 },
|
||||||
|
{ description: 'Electric Utility Services', class_number: '49.1+', gds: null, ads: null },
|
||||||
|
{ description: 'Electrotyping', class_number: '27.0', gds: 7, ads: 11 },
|
||||||
|
{ description: 'Engraving', class_number: '27.0', gds: 7, ads: 11 },
|
||||||
|
{ description: 'Gas Deposits — Exploration and Production', class_number: '13.2', gds: 7, ads: 14 },
|
||||||
|
{ description: 'Gas and Oil — Offshore Drilling', class_number: '13.0', gds: 5, ads: 7.5 },
|
||||||
|
{ description: 'Gas and Oil — On-Shore Drilling', class_number: '13.1', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Gas Utility Services', class_number: '49.2+', gds: null, ads: null },
|
||||||
|
{ description: 'Lithography', class_number: '27.0', gds: 7, ads: 11 },
|
||||||
|
{ description: 'Logging', class_number: '24.1+', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Miniature Golf Courses', class_number: '79.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Mining', class_number: '10.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Motorsports Entertainment Complex', class_number: null, gds: 7, ads: 40 },
|
||||||
|
{ description: 'Oil and Gas — Offshore Drilling', class_number: '13.0', gds: 5, ads: 7.5 },
|
||||||
|
{ description: 'Oil and Gas — On-Shore Drilling', class_number: '13.1', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Personal Services', class_number: '57.0', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Petroleum — Exploration and Production', class_number: '13.2', gds: 7, ads: 14 },
|
||||||
|
{ description: 'Petroleum Refining', class_number: '13.3', gds: 10, ads: 16 },
|
||||||
|
{ description: 'Pipeline Transportation', class_number: '46.0', gds: 15, ads: 22 },
|
||||||
|
{ description: 'Printing', class_number: '27.0', gds: 7, ads: 11 },
|
||||||
|
{ description: 'Professional Services', class_number: '57.0', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Publishing', class_number: '27.0', gds: 7, ads: 11 },
|
||||||
|
{ description: 'Radio Broadcasting (excluding transmitting towers)', class_number: '48.2', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Recreation Services (excluding amusement parks)', class_number: '79.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Research and Development (business activity)', class_number: null, gds: 5, ads: null },
|
||||||
|
{ description: 'Retail Trades and Services', class_number: '57.0', gds: 5, ads: 9 },
|
||||||
|
{ description: 'Steam Generation (industrial)', class_number: '00.4', gds: 15, ads: 22 },
|
||||||
|
{ description: 'Steam Utility Production and Distribution (business)', class_number: '49.4', gds: 20, ads: 28 },
|
||||||
|
{ description: 'Telephone Central Office Buildings (business)', class_number: '48.11', gds: 20, ads: 45 },
|
||||||
|
{ description: 'Telephone Central Office Switching', class_number: '48.12', gds: 10, ads: 18 },
|
||||||
|
{ description: 'Telephone Central Office Switching — Computer-Based', class_number: '48.121', gds: 5, ads: 9.5 },
|
||||||
|
{ description: 'Telephone Station Equipment', class_number: '48.13', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Telephone Distribution Plants (business)', class_number: '48.14', gds: 15, ads: 24 },
|
||||||
|
{ description: 'Television Broadcasting (excluding transmitting towers)', class_number: '48.2', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Theaters', class_number: '79.0', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Theme and Amusement Parks', class_number: '80.0', gds: 7, ads: 12.5 },
|
||||||
|
{ description: 'Timber Cutting', class_number: '24.1+', gds: 5, ads: 6 },
|
||||||
|
{ description: 'Transport (Air) — Passengers and Freight', class_number: '45.0', gds: 7, ads: 12 },
|
||||||
|
{ description: 'Transport (Motor) — Freight', class_number: '42.0', gds: 5, ads: 8 },
|
||||||
|
{ description: 'Transport (Motor) — Passengers', class_number: '41.0', gds: 5, ads: 8 },
|
||||||
|
{ description: 'Transport (Pipeline)', class_number: '46.0', gds: 15, ads: 22 },
|
||||||
|
{ description: 'Transport (Water) — Passengers and Freight', class_number: '44.0', gds: 15, ads: 20 },
|
||||||
|
{ description: 'Typesetting', class_number: '27.0', gds: 7, ads: 11 },
|
||||||
|
{ description: 'Waste Reduction and Resource Recovery Plants', class_number: '49.5', gds: 7, ads: 10 },
|
||||||
|
{ description: 'Waste Water (Municipal) Treatment Plants', class_number: '50.0', gds: 15, ads: 24 },
|
||||||
|
{ description: 'Water Utility Services', class_number: '49.3', gds: 25, ads: 50 },
|
||||||
|
{ description: 'Wholesale Trades and Services', class_number: '57.0', gds: 5, ads: 9 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// `table` marks the source list so the picker can label/group entries.
|
||||||
|
const IRS_ASSET_CLASSES = [
|
||||||
|
...COMMON.map((entry) => ({ ...entry, table: 'common' })),
|
||||||
|
...INDUSTRY.map((entry) => ({ ...entry, table: 'industry' })),
|
||||||
|
...BUSINESS.map((entry) => ({ ...entry, table: 'business' }))
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = { IRS_ASSET_CLASSES };
|
||||||
475
server/db.js
475
server/db.js
@@ -84,12 +84,24 @@ function initialize() {
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
email TEXT NOT NULL UNIQUE,
|
email TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
role TEXT NOT NULL CHECK(role IN ('admin','finance','operations','viewer')),
|
role TEXT NOT NULL DEFAULT 'viewer',
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
capabilities_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
is_system INTEGER NOT NULL DEFAULT 0,
|
||||||
|
locked INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
preferences_json TEXT NOT NULL,
|
preferences_json TEXT NOT NULL,
|
||||||
@@ -306,6 +318,8 @@ function initialize() {
|
|||||||
client_secret TEXT,
|
client_secret TEXT,
|
||||||
workers_path TEXT NOT NULL DEFAULT '/workers',
|
workers_path TEXT NOT NULL DEFAULT '/workers',
|
||||||
enabled INTEGER NOT NULL DEFAULT 0,
|
enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sync_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sync_interval_hours INTEGER NOT NULL DEFAULT 24,
|
||||||
last_sync_at TEXT,
|
last_sync_at TEXT,
|
||||||
last_sync_status TEXT,
|
last_sync_status TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
@@ -335,6 +349,309 @@ function initialize() {
|
|||||||
created_at TEXT NOT NULL
|
created_at TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS disposals (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
disposal_date TEXT NOT NULL,
|
||||||
|
method TEXT NOT NULL DEFAULT 'sale',
|
||||||
|
property_type TEXT,
|
||||||
|
partial INTEGER NOT NULL DEFAULT 0,
|
||||||
|
disposed_cost REAL NOT NULL DEFAULT 0,
|
||||||
|
disposal_price REAL NOT NULL DEFAULT 0,
|
||||||
|
disposal_expense REAL NOT NULL DEFAULT 0,
|
||||||
|
accumulated_depreciation REAL NOT NULL DEFAULT 0,
|
||||||
|
net_book_value REAL NOT NULL DEFAULT 0,
|
||||||
|
gain_loss REAL NOT NULL DEFAULT 0,
|
||||||
|
book_type TEXT NOT NULL DEFAULT 'GAAP',
|
||||||
|
notes TEXT,
|
||||||
|
created_by INTEGER REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_adjustments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
adjustment_date TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'impairment',
|
||||||
|
amount REAL NOT NULL DEFAULT 0,
|
||||||
|
book_type TEXT NOT NULL DEFAULT 'GAAP',
|
||||||
|
reason TEXT,
|
||||||
|
created_by INTEGER REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS books (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
code TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
book_type TEXT NOT NULL DEFAULT 'tax',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tax_rule_set_id INTEGER REFERENCES tax_rule_sets(id),
|
||||||
|
default_method TEXT,
|
||||||
|
default_convention TEXT,
|
||||||
|
fiscal_year_end_month INTEGER NOT NULL DEFAULT 12,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS alerts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
reference_type TEXT NOT NULL,
|
||||||
|
reference_id INTEGER,
|
||||||
|
asset_id INTEGER REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
severity TEXT NOT NULL DEFAULT 'warning',
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
message TEXT,
|
||||||
|
due_date TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
notified_at TEXT,
|
||||||
|
acknowledged_by INTEGER REFERENCES users(id),
|
||||||
|
acknowledged_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(reference_type, reference_id, type)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pm_plans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT,
|
||||||
|
frequency_value INTEGER NOT NULL DEFAULT 1,
|
||||||
|
frequency_unit TEXT NOT NULL DEFAULT 'months',
|
||||||
|
estimated_minutes INTEGER,
|
||||||
|
instructions TEXT,
|
||||||
|
created_by INTEGER REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pm_plan_steps (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id INTEGER NOT NULL REFERENCES pm_plans(id) ON DELETE CASCADE,
|
||||||
|
step_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
est_minutes INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pm_plan_components (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id INTEGER NOT NULL REFERENCES pm_plans(id) ON DELETE CASCADE,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
part_number TEXT,
|
||||||
|
description TEXT,
|
||||||
|
supplier TEXT,
|
||||||
|
cost REAL NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_pm_plans (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
plan_id INTEGER REFERENCES pm_plans(id),
|
||||||
|
frequency_value INTEGER NOT NULL DEFAULT 1,
|
||||||
|
frequency_unit TEXT NOT NULL DEFAULT 'months',
|
||||||
|
start_date TEXT,
|
||||||
|
end_date TEXT,
|
||||||
|
next_due_date TEXT,
|
||||||
|
last_completed_date TEXT,
|
||||||
|
assigned_to INTEGER REFERENCES users(id),
|
||||||
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pm_completions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
asset_pm_id INTEGER NOT NULL REFERENCES asset_pm_plans(id) ON DELETE CASCADE,
|
||||||
|
completed_at TEXT NOT NULL,
|
||||||
|
completed_by INTEGER REFERENCES users(id),
|
||||||
|
notes TEXT,
|
||||||
|
notes_json TEXT,
|
||||||
|
steps_json TEXT,
|
||||||
|
cost REAL NOT NULL DEFAULT 0,
|
||||||
|
components_json TEXT,
|
||||||
|
signature TEXT,
|
||||||
|
rating_safety INTEGER,
|
||||||
|
rating_physical INTEGER,
|
||||||
|
rating_operating INTEGER,
|
||||||
|
next_due_date TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pm_completion_photos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
completion_id INTEGER NOT NULL REFERENCES pm_completions(id) ON DELETE CASCADE,
|
||||||
|
name TEXT,
|
||||||
|
mime_type TEXT,
|
||||||
|
data_base64 TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
type TEXT NOT NULL DEFAULT 'other',
|
||||||
|
first_name TEXT,
|
||||||
|
last_name TEXT,
|
||||||
|
organization TEXT,
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
address_line1 TEXT,
|
||||||
|
address_line2 TEXT,
|
||||||
|
city TEXT,
|
||||||
|
state_region TEXT,
|
||||||
|
postal_code TEXT,
|
||||||
|
country TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
employee_id TEXT,
|
||||||
|
tax_id TEXT,
|
||||||
|
department TEXT,
|
||||||
|
work_location TEXT,
|
||||||
|
manager_first_name TEXT,
|
||||||
|
manager_last_name TEXT,
|
||||||
|
manager_email TEXT,
|
||||||
|
start_date TEXT,
|
||||||
|
rating INTEGER,
|
||||||
|
credit_term TEXT,
|
||||||
|
source TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
workday_worker_id TEXT UNIQUE,
|
||||||
|
source_payload TEXT,
|
||||||
|
last_synced_at TEXT,
|
||||||
|
created_by INTEGER REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS contact_notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
contact_id INTEGER NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_by INTEGER REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS parts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
part_number TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category TEXT,
|
||||||
|
manufacturer TEXT,
|
||||||
|
manufacturer_part_number TEXT,
|
||||||
|
barcode TEXT,
|
||||||
|
unit_of_measure TEXT NOT NULL DEFAULT 'each',
|
||||||
|
unit_cost REAL NOT NULL DEFAULT 0,
|
||||||
|
reorder_point REAL NOT NULL DEFAULT 0,
|
||||||
|
reorder_quantity REAL NOT NULL DEFAULT 0,
|
||||||
|
measurements TEXT,
|
||||||
|
supplier_contact_id INTEGER REFERENCES contacts(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
notes TEXT,
|
||||||
|
created_by INTEGER REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS part_stock (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE,
|
||||||
|
location_contact_id INTEGER REFERENCES contacts(id),
|
||||||
|
aisle TEXT,
|
||||||
|
bin TEXT,
|
||||||
|
quantity REAL NOT NULL DEFAULT 0,
|
||||||
|
reserved REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS part_photos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE,
|
||||||
|
name TEXT,
|
||||||
|
mime_type TEXT,
|
||||||
|
data_base64 TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS part_transactions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
part_id INTEGER NOT NULL REFERENCES parts(id) ON DELETE CASCADE,
|
||||||
|
stock_id INTEGER REFERENCES part_stock(id) ON DELETE SET NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'receive',
|
||||||
|
quantity REAL NOT NULL DEFAULT 0,
|
||||||
|
unit_cost REAL,
|
||||||
|
asset_id INTEGER REFERENCES assets(id) ON DELETE SET NULL,
|
||||||
|
reference TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_by INTEGER REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_photos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||||
|
name TEXT,
|
||||||
|
mime_type TEXT,
|
||||||
|
caption TEXT,
|
||||||
|
data_base64 TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_by INTEGER REFERENCES users(id),
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pm_plan_photos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
plan_id INTEGER NOT NULL REFERENCES pm_plans(id) ON DELETE CASCADE,
|
||||||
|
name TEXT,
|
||||||
|
mime_type TEXT,
|
||||||
|
caption TEXT,
|
||||||
|
data_base64 TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_id_templates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
pattern TEXT NOT NULL,
|
||||||
|
next_number INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_classes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
class_number TEXT,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
gds REAL,
|
||||||
|
ads REAL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'custom',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS depreciation_zones (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
code TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
allowance_percent REAL NOT NULL DEFAULT 0,
|
||||||
|
pis_start TEXT,
|
||||||
|
pis_end TEXT,
|
||||||
|
real_property_end TEXT,
|
||||||
|
max_recovery_years INTEGER,
|
||||||
|
leasehold_life_years INTEGER,
|
||||||
|
notes TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
actor_id INTEGER REFERENCES users(id),
|
actor_id INTEGER REFERENCES users(id),
|
||||||
@@ -347,11 +664,100 @@ function initialize() {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
migrate();
|
||||||
seed();
|
seed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Additive column migrations for databases created before these columns existed.
|
||||||
|
function ensureColumn(table, column, definition) {
|
||||||
|
const columns = all(`PRAGMA table_info(${table})`);
|
||||||
|
if (!columns.some((col) => col.name === column)) {
|
||||||
|
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrate() {
|
||||||
|
ensureColumn('pm_plan_steps', 'est_minutes', 'INTEGER');
|
||||||
|
ensureColumn('pm_completions', 'cost', 'REAL NOT NULL DEFAULT 0');
|
||||||
|
ensureColumn('pm_completions', 'components_json', 'TEXT');
|
||||||
|
ensureColumn('pm_completions', 'notes_json', 'TEXT');
|
||||||
|
ensureColumn('pm_completions', 'signature', 'TEXT');
|
||||||
|
ensureColumn('pm_completions', 'rating_safety', 'INTEGER');
|
||||||
|
ensureColumn('pm_completions', 'rating_physical', 'INTEGER');
|
||||||
|
ensureColumn('pm_completions', 'rating_operating', 'INTEGER');
|
||||||
|
ensureColumn('workday_connections', 'sync_enabled', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
ensureColumn('workday_connections', 'sync_interval_hours', 'INTEGER NOT NULL DEFAULT 24');
|
||||||
|
ensureColumn('alerts', 'external_ticket_id', 'TEXT');
|
||||||
|
ensureColumn('alerts', 'external_ticket_number', 'TEXT');
|
||||||
|
ensureColumn('alerts', 'external_ticket_url', 'TEXT');
|
||||||
|
ensureColumn('alerts', 'external_ticket_system', 'TEXT');
|
||||||
|
ensureColumn('assets', 'servicenow_sys_id', 'TEXT');
|
||||||
|
ensureColumn('assets', 'asset_class_number', 'TEXT');
|
||||||
|
ensureColumn('assets', 'special_zone', 'TEXT');
|
||||||
|
dropUsersRoleCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom roles require the legacy CHECK(role IN (...)) constraint on `users` to be removed.
|
||||||
|
// SQLite can't drop a column constraint in place, so rebuild the table when the old DDL is present.
|
||||||
|
function dropUsersRoleCheck() {
|
||||||
|
const ddl = one("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'users'");
|
||||||
|
if (!ddl || !/CHECK\s*\(\s*role/i.test(ddl.sql)) return;
|
||||||
|
db.exec('PRAGMA foreign_keys=OFF');
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE users_rebuild (
|
||||||
|
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 DEFAULT 'viewer',
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO users_rebuild (id, team_id, name, email, password_hash, role, status, created_at, updated_at)
|
||||||
|
SELECT id, team_id, name, email, password_hash, role, status, created_at, updated_at FROM users;
|
||||||
|
DROP TABLE users;
|
||||||
|
ALTER TABLE users_rebuild RENAME TO users;
|
||||||
|
`);
|
||||||
|
db.exec('PRAGMA foreign_keys=ON');
|
||||||
|
}
|
||||||
|
|
||||||
function seed() {
|
function seed() {
|
||||||
const createdAt = now();
|
const createdAt = now();
|
||||||
|
|
||||||
|
// Built-in roles (capability sets). Insert missing ones; keep admin's wildcard in sync.
|
||||||
|
const { DEFAULT_ROLES } = require('./services/accessControl');
|
||||||
|
for (const role of DEFAULT_ROLES) {
|
||||||
|
run(
|
||||||
|
`INSERT INTO roles (key, name, description, capabilities_json, is_system, locked, sort_order, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET is_system = excluded.is_system, locked = excluded.locked`,
|
||||||
|
[role.key, role.name, role.description, json(role.capabilities), role.is_system, role.locked, role.sort_order, createdAt, createdAt]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
run("UPDATE roles SET capabilities_json = ? WHERE key = 'admin'", [json(['*'])]);
|
||||||
|
|
||||||
|
// Asset class catalog: seed from the IRS reference tables once; afterwards it is
|
||||||
|
// fully user-managed (edits/deletes persist and are not re-seeded).
|
||||||
|
if (!one('SELECT id FROM asset_classes LIMIT 1')) {
|
||||||
|
const { IRS_ASSET_CLASSES } = require('./data/irsAssetClasses');
|
||||||
|
for (const item of IRS_ASSET_CLASSES) {
|
||||||
|
run(
|
||||||
|
'INSERT INTO asset_classes (class_number, description, gds, ads, source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[item.class_number || null, item.description, item.gds ?? null, item.ads ?? null, item.table || 'common', createdAt, createdAt]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Special depreciation zones (e.g. New York Liberty Zone). Seeded once, then user-managed.
|
||||||
|
run(
|
||||||
|
`INSERT OR IGNORE INTO depreciation_zones
|
||||||
|
(code, name, allowance_percent, pis_start, pis_end, real_property_end, max_recovery_years, leasehold_life_years, notes, enabled, created_at, updated_at)
|
||||||
|
VALUES ('ny_liberty', 'New York Liberty Zone', 30, '2001-09-11', '2006-12-31', '2009-12-31', 20, 5,
|
||||||
|
'30% special depreciation allowance for qualified NY Liberty Zone property; qualified leasehold improvements use a 5-year life; increased Section 179. Verify current tax law before filing.', 1, ?, ?)`,
|
||||||
|
[createdAt, createdAt]
|
||||||
|
);
|
||||||
if (!one('SELECT id FROM teams LIMIT 1')) {
|
if (!one('SELECT id FROM teams LIMIT 1')) {
|
||||||
run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
|
run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
|
||||||
'Finance Operations',
|
'Finance Operations',
|
||||||
@@ -391,7 +797,14 @@ function seed() {
|
|||||||
['category', 'Computer Equipment', 'COMP'],
|
['category', 'Computer Equipment', 'COMP'],
|
||||||
['category', 'Office Furniture', 'FURN'],
|
['category', 'Office Furniture', 'FURN'],
|
||||||
['category', 'Vehicles', 'AUTO'],
|
['category', 'Vehicles', 'AUTO'],
|
||||||
['category', 'Leasehold Improvements', 'LHI']
|
['category', 'Leasehold Improvements', 'LHI'],
|
||||||
|
['part_category', 'Electrical', null],
|
||||||
|
['part_category', 'Plumbing', null],
|
||||||
|
['part_category', 'Structural Component', null],
|
||||||
|
['part_category', 'Electronic Component', null],
|
||||||
|
['part_category', 'Fire Safety', null],
|
||||||
|
['part_category', 'Mechanical Safety', null],
|
||||||
|
['part_category', 'Other', null]
|
||||||
];
|
];
|
||||||
for (const item of refs) {
|
for (const item of refs) {
|
||||||
run('INSERT OR IGNORE INTO reference_values (type, name, code, metadata) VALUES (?, ?, ?, ?)', [
|
run('INSERT OR IGNORE INTO reference_values (type, name, code, metadata) VALUES (?, ?, ?, ?)', [
|
||||||
@@ -466,12 +879,68 @@ function seed() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!one('SELECT id FROM books LIMIT 1')) {
|
||||||
|
const federalRule = one("SELECT id FROM tax_rule_sets WHERE active = 1 ORDER BY effective_date DESC, id DESC LIMIT 1");
|
||||||
|
const ruleId = federalRule ? federalRule.id : null;
|
||||||
|
const bookDefs = [
|
||||||
|
['GAAP', 'GAAP / Financial', 'Primary book for financial statements', 'financial', 1, 1, 'straight_line', 'half_year'],
|
||||||
|
['FEDERAL', 'Federal Tax', 'US federal income tax depreciation', 'tax', 1, 0, 'macrs_gds_200db_5', 'half_year'],
|
||||||
|
['STATE', 'State Tax', 'State income tax depreciation', 'tax', 1, 0, 'macrs_gds_200db_5', 'half_year'],
|
||||||
|
['AMT', 'Alternative Minimum Tax', 'AMT depreciation adjustments', 'tax', 1, 0, 'macrs_gds_150db_5', 'half_year'],
|
||||||
|
['ACE', 'Adjusted Current Earnings', 'ACE depreciation', 'tax', 1, 0, 'macrs_ads_sl_5', 'half_year'],
|
||||||
|
['USER', 'User-defined', 'Custom internal reporting book', 'internal', 0, 0, 'straight_line', 'half_year']
|
||||||
|
];
|
||||||
|
bookDefs.forEach((def, index) => {
|
||||||
|
run(
|
||||||
|
`INSERT INTO books (code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, sort_order, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[def[0], def[1], def[2], def[3], def[4], def[5], ruleId, def[6], def[7], index, createdAt, createdAt]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The PM book tracks actual preventative-maintenance spend per asset (not depreciation).
|
||||||
|
run(
|
||||||
|
`INSERT OR IGNORE INTO books (code, name, description, book_type, enabled, is_primary, sort_order, created_at, updated_at)
|
||||||
|
VALUES ('PM', 'PM / Maintenance', 'Actual preventative-maintenance spend per asset', 'maintenance', 1, 0, 6, ?, ?)`,
|
||||||
|
[createdAt, createdAt]
|
||||||
|
);
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
server_fqdn: process.env.MIXEDASSETS_SERVER_FQDN || 'localhost',
|
server_fqdn: process.env.MIXEDASSETS_SERVER_FQDN || 'localhost',
|
||||||
cors_allowed_domains: process.env.MIXEDASSETS_CORS_ORIGINS || 'http://localhost:5173',
|
cors_allowed_domains: process.env.MIXEDASSETS_CORS_ORIGINS || 'http://localhost:5173',
|
||||||
database_driver: 'node:sqlite',
|
database_driver: 'node:sqlite',
|
||||||
database_path: DB_PATH,
|
database_path: DB_PATH,
|
||||||
default_books: 'GAAP,FEDERAL,STATE,AMT,ACE,USER'
|
default_books: 'GAAP,FEDERAL,STATE,AMT,ACE,USER',
|
||||||
|
alerts_enabled: 'false',
|
||||||
|
alerts_lead_days: '30',
|
||||||
|
alerts_recipients: '',
|
||||||
|
smtp_host: '',
|
||||||
|
smtp_port: '587',
|
||||||
|
smtp_secure: 'false',
|
||||||
|
smtp_user: '',
|
||||||
|
smtp_password: '',
|
||||||
|
smtp_from: 'MixedAssets <no-reply@mixedassets.local>',
|
||||||
|
webhook_enabled: 'false',
|
||||||
|
webhook_url: '',
|
||||||
|
webhook_secret: '',
|
||||||
|
servicenow_instance_url: '',
|
||||||
|
servicenow_username: '',
|
||||||
|
servicenow_password: '',
|
||||||
|
servicenow_incident_table: 'incident',
|
||||||
|
servicenow_assignment_group: '',
|
||||||
|
servicenow_caller_id: '',
|
||||||
|
servicenow_ticket_enabled: 'false',
|
||||||
|
servicenow_auto_ticket: 'false',
|
||||||
|
servicenow_cmdb_table: 'cmdb_ci_hardware',
|
||||||
|
servicenow_cmdb_query: '',
|
||||||
|
servicenow_cmdb_limit: '200',
|
||||||
|
servicenow_cmdb_map: '',
|
||||||
|
servicenow_last_sync_at: '',
|
||||||
|
servicenow_last_sync_status: '',
|
||||||
|
pm_default_frequency_value: '3',
|
||||||
|
pm_default_frequency_unit: 'months',
|
||||||
|
pm_lead_days: '7'
|
||||||
};
|
};
|
||||||
for (const [key, value] of Object.entries(settings)) {
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
run('INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)', [key, String(value), createdAt]);
|
run('INSERT OR IGNORE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)', [key, String(value), createdAt]);
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
const { parseJson } = require('./db');
|
const { parseJson } = require('./db');
|
||||||
|
const { buildDepreciationMethods } = require('./rule-catalog');
|
||||||
|
const { getZone } = require('./services/zones');
|
||||||
|
|
||||||
|
// Built-in method catalog, used as a fallback so codes like MF200/DB200 always resolve
|
||||||
|
// even if a particular rule set hasn't been re-expanded to include them.
|
||||||
|
let methodCatalog = null;
|
||||||
|
function catalog() {
|
||||||
|
if (!methodCatalog) methodCatalog = buildDepreciationMethods();
|
||||||
|
return methodCatalog;
|
||||||
|
}
|
||||||
|
|
||||||
function money(value) {
|
function money(value) {
|
||||||
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
|
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
|
||||||
@@ -19,25 +29,59 @@ function parseMaybeJson(value, fallback = null) {
|
|||||||
return value ?? fallback;
|
return value ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function totalDepreciableBasis(asset, book) {
|
function businessUseFactor(asset, book) {
|
||||||
|
return Number(book.business_use_percent ?? asset.business_use_percent ?? 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cost basis before salvage: acquisition value less land, scaled by business-use %.
|
||||||
|
function grossBasis(asset, book) {
|
||||||
const cost = Number(book.cost ?? asset.acquisition_cost ?? 0);
|
const cost = Number(book.cost ?? asset.acquisition_cost ?? 0);
|
||||||
const land = Number(asset.land_value || 0);
|
const land = Number(asset.land_value || 0);
|
||||||
const salvage = Number(asset.salvage_value || 0);
|
return Math.max(0, (cost - land) * businessUseFactor(asset, book));
|
||||||
const businessUse = Number(book.business_use_percent ?? asset.business_use_percent ?? 100) / 100;
|
}
|
||||||
return Math.max(0, (cost - land - salvage) * businessUse);
|
|
||||||
|
// Salvage reduces what standard methods recover, but MACRS ignores it (ignoreSalvage flag).
|
||||||
|
function salvageValue(asset, book, methodRule) {
|
||||||
|
if (methodRule && methodRule.ignoreSalvage) return 0;
|
||||||
|
return Math.max(0, Number(asset.salvage_value || 0) * businessUseFactor(asset, book));
|
||||||
}
|
}
|
||||||
|
|
||||||
function section179Amount(asset, book) {
|
function section179Amount(asset, book) {
|
||||||
return Math.min(totalDepreciableBasis(asset, book), Number(book.section_179_amount || 0));
|
return Math.min(grossBasis(asset, book), Number(book.section_179_amount || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special-zone §168 allowance (e.g. New York Liberty Zone): applies to the federal tax book
|
||||||
|
// when the asset is flagged for a zone and its placed-in-service date is within the window.
|
||||||
|
// Returns the allowance % (used as the §168 bonus) or 0.
|
||||||
|
function zoneAllowance(asset, book) {
|
||||||
|
if (!asset.special_zone || String(book.book_type) !== 'FEDERAL') return 0;
|
||||||
|
const zone = getZone(asset.special_zone);
|
||||||
|
if (!zone || !Number(zone.allowance_percent)) return 0;
|
||||||
|
const pis = asset.in_service_date || asset.acquired_date || '';
|
||||||
|
if (pis && zone.pis_start && pis < zone.pis_start) return 0;
|
||||||
|
if (pis && zone.pis_end && pis > zone.pis_end) return 0;
|
||||||
|
return Number(zone.allowance_percent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section 168 (bonus) allowance, applied after Section 179.
|
||||||
function bonusAmount(asset, book) {
|
function bonusAmount(asset, book) {
|
||||||
const after179 = Math.max(0, totalDepreciableBasis(asset, book) - section179Amount(asset, book));
|
const after179 = Math.max(0, grossBasis(asset, book) - section179Amount(asset, book));
|
||||||
return after179 * (Number(book.bonus_percent || 0) / 100);
|
return after179 * (Number(book.bonus_percent || 0) / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function depreciableBasis(asset, book) {
|
// Total amount recoverable over the asset's life (the schedule cap): cost basis less salvage.
|
||||||
return Math.max(0, totalDepreciableBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book));
|
function recoverableBasis(asset, book, methodRule) {
|
||||||
|
return Math.max(0, grossBasis(asset, book) - salvageValue(asset, book, methodRule));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amount a straight-line / SYD method depreciates: net of Section 179, bonus, and salvage.
|
||||||
|
function depreciableBasis(asset, book, methodRule) {
|
||||||
|
return Math.max(0, grossBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book) - salvageValue(asset, book, methodRule));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cost basis net of salvage (no 179/bonus) — retained for reports and disposals.
|
||||||
|
function totalDepreciableBasis(asset, book) {
|
||||||
|
return Math.max(0, grossBasis(asset, book) - salvageValue(asset, book, null));
|
||||||
}
|
}
|
||||||
|
|
||||||
function conventionFor(book, methodRule) {
|
function conventionFor(book, methodRule) {
|
||||||
@@ -83,31 +127,35 @@ function fractionForIndex(index, convention, asset, totalYears) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function straightLine(asset, book, index, totalYears, methodRule) {
|
function straightLine(asset, book, index, totalYears, methodRule) {
|
||||||
const basis = depreciableBasis(asset, book);
|
const basis = depreciableBasis(asset, book, methodRule);
|
||||||
return (basis / totalYears) * fractionForIndex(index, conventionFor(book, methodRule), asset, totalYears);
|
return (basis / totalYears) * fractionForIndex(index, conventionFor(book, methodRule), asset, totalYears);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sumOfYearsDigits(asset, book, index, totalYears) {
|
function sumOfYearsDigits(asset, book, index, totalYears, methodRule) {
|
||||||
const basis = depreciableBasis(asset, book);
|
const basis = depreciableBasis(asset, book, methodRule);
|
||||||
const denominator = (totalYears * (totalYears + 1)) / 2;
|
const denominator = (totalYears * (totalYears + 1)) / 2;
|
||||||
return basis * ((totalYears - index) / denominator);
|
return basis * ((totalYears - index) / denominator);
|
||||||
}
|
}
|
||||||
|
|
||||||
function decliningBalance(asset, book, index, totalYears, multiplier, methodRule) {
|
function decliningBalance(asset, book, index, totalYears, multiplier, methodRule) {
|
||||||
const basis = depreciableBasis(asset, book);
|
// The declining-balance rate applies to the full (post-179/bonus) book value; depreciation
|
||||||
let bookValue = basis;
|
// is floored at salvage. MACRS methods set salvage to 0 via ignoreSalvage.
|
||||||
|
const start = Math.max(0, grossBasis(asset, book) - section179Amount(asset, book) - bonusAmount(asset, book));
|
||||||
|
const salvage = salvageValue(asset, book, methodRule);
|
||||||
|
let bookValue = start;
|
||||||
let elapsed = 0;
|
let elapsed = 0;
|
||||||
const convention = conventionFor(book, methodRule);
|
const convention = conventionFor(book, methodRule);
|
||||||
|
|
||||||
for (let i = 0; i <= index; i += 1) {
|
for (let i = 0; i <= index; i += 1) {
|
||||||
const fraction = fractionForIndex(i, convention, asset, totalYears);
|
const fraction = fractionForIndex(i, convention, asset, totalYears);
|
||||||
if (fraction <= 0 || bookValue <= 0) return 0;
|
const recoverable = Math.max(0, bookValue - salvage);
|
||||||
|
if (fraction <= 0 || recoverable <= 0) return 0;
|
||||||
|
|
||||||
const dbAmount = bookValue * (multiplier / totalYears) * fraction;
|
const dbAmount = bookValue * (multiplier / totalYears) * fraction;
|
||||||
const remainingLife = Math.max(0.0001, totalYears - elapsed);
|
const remainingLife = Math.max(0.0001, totalYears - elapsed);
|
||||||
const slAmount = (bookValue / remainingLife) * fraction;
|
const slAmount = (recoverable / remainingLife) * fraction;
|
||||||
const selected = methodRule.switchToStraightLine ? Math.max(dbAmount, slAmount) : dbAmount;
|
const selected = methodRule.switchToStraightLine ? Math.max(dbAmount, slAmount) : dbAmount;
|
||||||
const amount = Math.min(bookValue, selected);
|
const amount = Math.min(recoverable, selected);
|
||||||
|
|
||||||
if (i === index) return amount;
|
if (i === index) return amount;
|
||||||
bookValue -= amount;
|
bookValue -= amount;
|
||||||
@@ -117,7 +165,7 @@ function decliningBalance(asset, book, index, totalYears, multiplier, methodRule
|
|||||||
}
|
}
|
||||||
|
|
||||||
function rateTable(asset, book, index, methodRule) {
|
function rateTable(asset, book, index, methodRule) {
|
||||||
const basis = depreciableBasis(asset, book);
|
const basis = depreciableBasis(asset, book, methodRule);
|
||||||
const rates = methodRule.rates || [];
|
const rates = methodRule.rates || [];
|
||||||
if (!rates.length && methodRule.fallbackFormula === 'straight_line') {
|
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 straightLine(asset, book, index, Math.max(1, Math.ceil((methodRule.lifeMonths || book.useful_life_months || 60) / 12)), methodRule);
|
||||||
@@ -127,15 +175,18 @@ function rateTable(asset, book, index, methodRule) {
|
|||||||
|
|
||||||
function getMethodRule(ruleSet, code) {
|
function getMethodRule(ruleSet, code) {
|
||||||
const rules = typeof ruleSet === 'string' ? parseJson(ruleSet, {}) : ruleSet || {};
|
const rules = typeof ruleSet === 'string' ? parseJson(ruleSet, {}) : ruleSet || {};
|
||||||
return (rules.methods || []).find((method) => method.code === code) || {
|
return (rules.methods || []).find((method) => method.code === code)
|
||||||
code,
|
|| catalog().find((method) => method.code === code)
|
||||||
formula: code,
|
|| { code, formula: code, label: code };
|
||||||
label: code
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function annualSchedule(asset, book, ruleSet, options = {}) {
|
function annualSchedule(asset, book, ruleSet, options = {}) {
|
||||||
const methodRule = getMethodRule(ruleSet, book.depreciation_method || 'straight_line');
|
const methodRule = getMethodRule(ruleSet, book.depreciation_method || 'straight_line');
|
||||||
|
// Apply a special-zone §168 allowance when the book has no explicit bonus of its own.
|
||||||
|
if (!(Number(book.bonus_percent) > 0)) {
|
||||||
|
const allowance = zoneAllowance(asset, book);
|
||||||
|
if (allowance > 0) book = { ...book, bonus_percent: allowance };
|
||||||
|
}
|
||||||
const usefulLifeMonths = Number(methodRule.lifeMonths || book.useful_life_months || asset.useful_life_months || 60);
|
const usefulLifeMonths = Number(methodRule.lifeMonths || book.useful_life_months || asset.useful_life_months || 60);
|
||||||
const totalYears = Math.max(1, Math.ceil(usefulLifeMonths / 12));
|
const totalYears = Math.max(1, Math.ceil(usefulLifeMonths / 12));
|
||||||
const placedInServiceYear = yearFromDate(asset.in_service_date || asset.acquired_date);
|
const placedInServiceYear = yearFromDate(asset.in_service_date || asset.acquired_date);
|
||||||
@@ -156,7 +207,7 @@ function annualSchedule(asset, book, ruleSet, options = {}) {
|
|||||||
if (methodRule.formula === 'rate_table') {
|
if (methodRule.formula === 'rate_table') {
|
||||||
depreciation += rateTable(asset, book, index, methodRule);
|
depreciation += rateTable(asset, book, index, methodRule);
|
||||||
} else if (methodRule.formula === 'sum_of_years_digits') {
|
} else if (methodRule.formula === 'sum_of_years_digits') {
|
||||||
depreciation += sumOfYearsDigits(asset, book, index, totalYears);
|
depreciation += sumOfYearsDigits(asset, book, index, totalYears, methodRule);
|
||||||
} else if (methodRule.formula === 'declining_balance' || methodRule.formula === 'macrs_declining_balance') {
|
} else if (methodRule.formula === 'declining_balance' || methodRule.formula === 'macrs_declining_balance') {
|
||||||
depreciation += decliningBalance(asset, book, index, totalYears, Number(methodRule.rateMultiplier || 2), methodRule);
|
depreciation += decliningBalance(asset, book, index, totalYears, Number(methodRule.rateMultiplier || 2), methodRule);
|
||||||
} else if (methodRule.formula === 'acrs_alternate_straight_line') {
|
} else if (methodRule.formula === 'acrs_alternate_straight_line') {
|
||||||
@@ -168,8 +219,8 @@ function annualSchedule(asset, book, ruleSet, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const basis = totalDepreciableBasis(asset, book);
|
const cap = recoverableBasis(asset, book, methodRule);
|
||||||
depreciation = Math.max(0, Math.min(depreciation, Math.max(0, basis - accumulated)));
|
depreciation = Math.max(0, Math.min(depreciation, Math.max(0, cap - accumulated)));
|
||||||
accumulated += depreciation;
|
accumulated += depreciation;
|
||||||
|
|
||||||
if (year >= startYear) {
|
if (year >= startYear) {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
const { createApp } = require('./app');
|
const { createApp } = require('./app');
|
||||||
|
const { runAlertCycle } = require('./services/alerts');
|
||||||
|
const { runScheduledSync } = require('./services/workday');
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || process.env.MIXEDASSETS_PORT || 3000);
|
const PORT = Number(process.env.PORT || process.env.MIXEDASSETS_PORT || 3000);
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
@@ -6,3 +8,21 @@ const app = createApp();
|
|||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`MixedAssets API listening on http://localhost:${PORT}`);
|
console.log(`MixedAssets API listening on http://localhost:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Periodically scan for due/expiring items and email digests. These are no-ops
|
||||||
|
// unless alerts are enabled and SMTP is configured. Runs after boot, then twice daily.
|
||||||
|
function scheduleAlertCycle() {
|
||||||
|
const tick = () => runAlertCycle(null).catch((error) => console.error('Alert cycle failed:', error.message));
|
||||||
|
setTimeout(tick, 30_000).unref();
|
||||||
|
setInterval(tick, 12 * 60 * 60 * 1000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hourly check for a due automatic Workday sync (respects the configured interval).
|
||||||
|
function scheduleWorkdaySync() {
|
||||||
|
const tick = () => runScheduledSync().catch((error) => console.error('Workday sync failed:', error.message));
|
||||||
|
setTimeout(tick, 60_000).unref();
|
||||||
|
setInterval(tick, 60 * 60 * 1000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleAlertCycle();
|
||||||
|
scheduleWorkdaySync();
|
||||||
|
|||||||
@@ -41,9 +41,24 @@ function requireRole(...roles) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capability-based guard: the user's role must grant at least one of the listed capabilities.
|
||||||
|
// Required lazily to avoid a load-time cycle (roles -> db -> ...).
|
||||||
|
function requireCapability(...capabilities) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const { capabilitiesForRole } = require('../services/roles');
|
||||||
|
const { capabilitySetIncludes } = require('../services/accessControl');
|
||||||
|
const granted = capabilitiesForRole(req.user.role);
|
||||||
|
if (!capabilities.some((cap) => capabilitySetIncludes(granted, cap))) {
|
||||||
|
return res.status(403).json({ error: 'Insufficient access' });
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
publicUser,
|
publicUser,
|
||||||
requireAuth,
|
requireAuth,
|
||||||
|
requireCapability,
|
||||||
requireRole,
|
requireRole,
|
||||||
tokenFor
|
tokenFor
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const { all, audit, now, one, run } = require('../db');
|
const { all, audit, now, one, run } = require('../db');
|
||||||
const { requireRole } = require('../middleware/auth');
|
const { requireCapability } = require('../middleware/auth');
|
||||||
const { isValidRole, roleCapabilities, rolePermissions, roles } = require('../services/accessControl');
|
const { CAPABILITIES } = require('../services/accessControl');
|
||||||
|
const { createRole, deleteRole, listRoles, roleExists, updateRole } = require('../services/roles');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -17,15 +18,17 @@ function publicAdminUser(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateRole(role) {
|
function validateRole(role) {
|
||||||
if (!isValidRole(role)) throw Object.assign(new Error('Invalid role'), { status: 400 });
|
if (!roleExists(role)) throw Object.assign(new Error('Invalid role'), { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/settings', requireRole('admin'), (req, res) => {
|
router.get('/settings', requireCapability('admin.settings'), (req, res) => {
|
||||||
const rows = all('SELECT * FROM app_settings ORDER BY key');
|
const rows = all('SELECT * FROM app_settings ORDER BY key');
|
||||||
res.json({ settings: Object.fromEntries(rows.map((row) => [row.key, row.value])) });
|
const settings = Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
||||||
|
delete settings.smtp_password; // managed (masked) via the notifications settings endpoint
|
||||||
|
res.json({ settings });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/settings', requireRole('admin'), (req, res) => {
|
router.put('/settings', requireCapability('admin.settings'), (req, res) => {
|
||||||
for (const [key, value] of Object.entries(req.body.settings || req.body)) {
|
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', [
|
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,
|
key,
|
||||||
@@ -37,11 +40,11 @@ router.put('/settings', requireRole('admin'), (req, res) => {
|
|||||||
res.json({ settings: Object.fromEntries(all('SELECT * FROM app_settings ORDER BY key').map((row) => [row.key, row.value])) });
|
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) => {
|
router.get('/users', requireCapability('admin.users'), (req, res) => {
|
||||||
res.json({ users: all(`${userSelect} ORDER BY u.name`) });
|
res.json({ users: all(`${userSelect} ORDER BY u.name`) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/users', requireRole('admin'), (req, res) => {
|
router.post('/users', requireCapability('admin.users'), (req, res) => {
|
||||||
validateRole(req.body.role || 'viewer');
|
validateRole(req.body.role || 'viewer');
|
||||||
if (req.body.status && !['active', 'inactive'].includes(req.body.status)) return res.status(400).json({ error: 'Invalid status' });
|
if (req.body.status && !['active', 'inactive'].includes(req.body.status)) return res.status(400).json({ error: 'Invalid status' });
|
||||||
const created = now();
|
const created = now();
|
||||||
@@ -62,7 +65,7 @@ router.post('/users', requireRole('admin'), (req, res) => {
|
|||||||
res.status(201).json({ user: publicAdminUser(result.lastInsertRowid) });
|
res.status(201).json({ user: publicAdminUser(result.lastInsertRowid) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/users/:id', requireRole('admin'), (req, res) => {
|
router.put('/users/:id', requireCapability('admin.users'), (req, res) => {
|
||||||
const before = publicAdminUser(req.params.id);
|
const before = publicAdminUser(req.params.id);
|
||||||
if (!before) return res.status(404).json({ error: 'User not found' });
|
if (!before) return res.status(404).json({ error: 'User not found' });
|
||||||
const nextRole = req.body.role || before.role;
|
const nextRole = req.body.role || before.role;
|
||||||
@@ -110,11 +113,11 @@ router.put('/users/:id', requireRole('admin'), (req, res) => {
|
|||||||
return res.json({ user });
|
return res.json({ user });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/teams', requireRole('admin'), (req, res) => {
|
router.get('/teams', requireCapability('admin.users'), (req, res) => {
|
||||||
res.json({ teams: all('SELECT * FROM teams ORDER BY name') });
|
res.json({ teams: all('SELECT * FROM teams ORDER BY name') });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/teams', requireRole('admin'), (req, res) => {
|
router.post('/teams', requireCapability('admin.users'), (req, res) => {
|
||||||
const result = run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
|
const result = run('INSERT INTO teams (name, description, created_at) VALUES (?, ?, ?)', [
|
||||||
req.body.name,
|
req.body.name,
|
||||||
req.body.description || null,
|
req.body.description || null,
|
||||||
@@ -124,8 +127,58 @@ router.post('/teams', requireRole('admin'), (req, res) => {
|
|||||||
res.status(201).json({ team: one('SELECT * FROM teams WHERE id = ?', [result.lastInsertRowid]) });
|
res.status(201).json({ team: one('SELECT * FROM teams WHERE id = ?', [result.lastInsertRowid]) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/access-control', requireRole('admin'), (req, res) => {
|
router.put('/teams/:id', requireCapability('admin.users'), (req, res) => {
|
||||||
res.json({ roles, rolePermissions, roleCapabilities });
|
const before = one('SELECT * FROM teams WHERE id = ?', [req.params.id]);
|
||||||
|
if (!before) return res.status(404).json({ error: 'Team not found' });
|
||||||
|
run('UPDATE teams SET name = ?, description = ? WHERE id = ?', [
|
||||||
|
req.body.name || before.name,
|
||||||
|
req.body.description ?? before.description,
|
||||||
|
req.params.id
|
||||||
|
]);
|
||||||
|
audit(req.user.id, 'update', 'team', req.params.id, before, req.body);
|
||||||
|
return res.json({ team: one('SELECT * FROM teams WHERE id = ?', [req.params.id]) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/teams/:id', requireCapability('admin.users'), (req, res) => {
|
||||||
|
const before = one('SELECT * FROM teams WHERE id = ?', [req.params.id]);
|
||||||
|
if (!before) return res.status(404).json({ error: 'Team not found' });
|
||||||
|
const memberCount = one('SELECT COUNT(*) AS count FROM users WHERE team_id = ?', [req.params.id]).count;
|
||||||
|
if (memberCount) {
|
||||||
|
return res.status(400).json({ error: `Reassign the ${memberCount} user(s) on this team before deleting it` });
|
||||||
|
}
|
||||||
|
run('DELETE FROM teams WHERE id = ?', [req.params.id]);
|
||||||
|
audit(req.user.id, 'delete', 'team', req.params.id, before, null);
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/access-control', requireCapability('admin.users'), (req, res) => {
|
||||||
|
res.json({ roles: listRoles(), capabilities: CAPABILITIES });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Roles (capability sets, custom roles allowed) --------------------------
|
||||||
|
|
||||||
|
router.get('/roles', requireCapability('admin.roles'), (req, res) => {
|
||||||
|
res.json({ roles: listRoles(), capabilities: CAPABILITIES });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/roles', requireCapability('admin.roles'), (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.status(201).json({ role: createRole(req.body, req.user.id) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/roles/:key', requireCapability('admin.roles'), (req, res) => {
|
||||||
|
const role = updateRole(req.params.key, req.body, req.user.id);
|
||||||
|
if (!role) return res.status(404).json({ error: 'Role not found' });
|
||||||
|
return res.json({ role });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/roles/:key', requireCapability('admin.roles'), (req, res) => {
|
||||||
|
const result = deleteRole(req.params.key, req.user.id);
|
||||||
|
if (!result.ok) return res.status(result.status).json({ error: result.error });
|
||||||
|
return res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
73
server/routes/alerts.js
Normal file
73
server/routes/alerts.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { requireCapability } = require('../middleware/auth');
|
||||||
|
const { listAlerts, runAlertCycle, scanAlerts, setStatus, summary } = require('../services/alerts');
|
||||||
|
const { publicConfig, saveConfig, sendTestEmail } = require('../services/notifications');
|
||||||
|
const { sendTestWebhook } = require('../services/webhooks');
|
||||||
|
const { submitTicket } = require('../services/servicenow');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/alerts', (req, res) => {
|
||||||
|
res.json({ alerts: listAlerts(req.query), summary: summary() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/alerts/scan', requireCapability('alerts.manage'), (req, res) => {
|
||||||
|
const result = scanAlerts(req.user.id);
|
||||||
|
res.json({ created: result.created.length, openCount: result.openCount, alerts: listAlerts({}), summary: summary() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/alerts/run', requireCapability('admin.integrations'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(await runAlertCycle(req.user.id));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/alerts/:id/acknowledge', requireCapability('alerts.manage'), (req, res) => {
|
||||||
|
const alert = setStatus(req.params.id, 'acknowledged', req.user.id);
|
||||||
|
if (!alert) return res.status(404).json({ error: 'Alert not found' });
|
||||||
|
return res.json({ alert });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/alerts/:id/dismiss', requireCapability('alerts.manage'), (req, res) => {
|
||||||
|
const alert = setStatus(req.params.id, 'dismissed', req.user.id);
|
||||||
|
if (!alert) return res.status(404).json({ error: 'Alert not found' });
|
||||||
|
return res.json({ alert });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/alerts/:id/ticket', requireCapability('alerts.manage'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const ticket = await submitTicket(req.params.id, req.user.id);
|
||||||
|
if (!ticket) return res.status(404).json({ error: 'Alert not found' });
|
||||||
|
return res.json({ ticket });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/notifications/settings', requireCapability('admin.integrations'), (req, res) => {
|
||||||
|
res.json({ settings: publicConfig() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/notifications/settings', requireCapability('admin.integrations'), (req, res) => {
|
||||||
|
res.json({ settings: saveConfig(req.body, req.user.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/notifications/test', requireCapability('admin.integrations'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(await sendTestEmail(req.body.to, req.user.id));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/notifications/webhook-test', requireCapability('admin.integrations'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(await sendTestWebhook(req.user.id));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -2,16 +2,20 @@ const express = require('express');
|
|||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const { audit, now, one, run } = require('../db');
|
const { audit, now, one, run } = require('../db');
|
||||||
const { annualSchedule } = require('../depreciation');
|
const { annualSchedule } = require('../depreciation');
|
||||||
const { requireRole } = require('../middleware/auth');
|
const { requireCapability } = require('../middleware/auth');
|
||||||
const {
|
const {
|
||||||
|
addAssetPhoto,
|
||||||
createAsset,
|
createAsset,
|
||||||
deleteAsset,
|
deleteAsset,
|
||||||
|
deleteAssetPhoto,
|
||||||
hydrateAsset,
|
hydrateAsset,
|
||||||
listAssets,
|
listAssets,
|
||||||
|
lookupAssetByCode,
|
||||||
massUpdateAssets,
|
massUpdateAssets,
|
||||||
updateAsset
|
updateAsset,
|
||||||
|
updateAssetPhoto
|
||||||
} = require('../services/assets');
|
} = require('../services/assets');
|
||||||
const { activeRuleSet } = require('../services/reports');
|
const { ruleSetForBook } = require('../services/ruleSets');
|
||||||
|
|
||||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024 } });
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -20,28 +24,36 @@ router.get('/assets', (req, res) => {
|
|||||||
res.json({ assets: listAssets(req.query) });
|
res.json({ assets: listAssets(req.query) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/assets', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
router.post('/assets', requireCapability('assets.edit'), (req, res) => {
|
||||||
res.status(201).json({ asset: createAsset(req.body, req.user.id) });
|
res.status(201).json({ asset: createAsset(req.body, req.user.id) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/assets/lookup', (req, res) => {
|
||||||
|
const code = String(req.query.code || '').trim();
|
||||||
|
if (!code) return res.status(400).json({ error: 'Scan code is required' });
|
||||||
|
const asset = lookupAssetByCode(code);
|
||||||
|
if (!asset) return res.status(404).json({ error: `No asset matches "${code}"` });
|
||||||
|
return res.json({ asset });
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/assets/:id', (req, res) => {
|
router.get('/assets/:id', (req, res) => {
|
||||||
const asset = hydrateAsset(req.params.id);
|
const asset = hydrateAsset(req.params.id);
|
||||||
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
||||||
return res.json({ asset });
|
return res.json({ asset });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/assets/:id', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
router.put('/assets/:id', requireCapability('assets.edit'), (req, res) => {
|
||||||
const asset = updateAsset(req.params.id, req.body, req.user.id);
|
const asset = updateAsset(req.params.id, req.body, req.user.id);
|
||||||
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
||||||
return res.json({ asset });
|
return res.json({ asset });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/assets/:id', requireRole('admin', 'finance'), (req, res) => {
|
router.delete('/assets/:id', requireCapability('assets.delete'), (req, res) => {
|
||||||
if (!deleteAsset(req.params.id, req.user.id)) return res.status(404).json({ error: 'Asset not found' });
|
if (!deleteAsset(req.params.id, req.user.id)) return res.status(404).json({ error: 'Asset not found' });
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/assets/mass-update', requireRole('admin', 'finance'), (req, res) => {
|
router.post('/assets/mass-update', requireCapability('assets.bulk'), (req, res) => {
|
||||||
const ids = Array.isArray(req.body.ids) ? req.body.ids : [];
|
const ids = Array.isArray(req.body.ids) ? req.body.ids : [];
|
||||||
const updated = massUpdateAssets(ids, req.body.changes || {}, req.user.id);
|
const updated = massUpdateAssets(ids, req.body.changes || {}, req.user.id);
|
||||||
if (!updated) return res.status(400).json({ error: 'Choose assets and editable fields' });
|
if (!updated) return res.status(400).json({ error: 'Choose assets and editable fields' });
|
||||||
@@ -51,16 +63,15 @@ router.post('/assets/mass-update', requireRole('admin', 'finance'), (req, res) =
|
|||||||
router.get('/assets/:id/depreciation', (req, res) => {
|
router.get('/assets/:id/depreciation', (req, res) => {
|
||||||
const asset = hydrateAsset(req.params.id);
|
const asset = hydrateAsset(req.params.id);
|
||||||
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
if (!asset) return res.status(404).json({ error: 'Asset not found' });
|
||||||
const rules = activeRuleSet();
|
|
||||||
const startYear = Number(req.query.startYear || new Date().getFullYear());
|
const startYear = Number(req.query.startYear || new Date().getFullYear());
|
||||||
const endYear = Number(req.query.endYear || startYear + 5);
|
const endYear = Number(req.query.endYear || startYear + 5);
|
||||||
const schedules = asset.books
|
const schedules = asset.books
|
||||||
.filter((book) => book.active)
|
.filter((book) => book.active)
|
||||||
.flatMap((book) => annualSchedule(asset, book, rules, { startYear, endYear }));
|
.flatMap((book) => annualSchedule(asset, book, ruleSetForBook(book.book_type), { startYear, endYear }));
|
||||||
res.json({ assetId: asset.asset_id, schedules });
|
res.json({ assetId: asset.asset_id, schedules });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/assets/:id/files', upload.single('file'), requireRole('admin', 'finance', 'operations'), (req, res) => {
|
router.post('/assets/:id/files', upload.single('file'), requireCapability('assets.edit'), (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'File is required' });
|
if (!req.file) return res.status(400).json({ error: 'File is required' });
|
||||||
const result = run(
|
const result = run(
|
||||||
'INSERT INTO asset_files (asset_id, file_name, mime_type, size, content_base64, uploaded_by, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO asset_files (asset_id, file_name, mime_type, size, content_base64, uploaded_by, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
@@ -78,14 +89,32 @@ router.get('/assets/:id/files/:fileId', (req, res) => {
|
|||||||
return res.send(Buffer.from(file.content_base64, 'base64'));
|
return res.send(Buffer.from(file.content_base64, 'base64'));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/assets/:id/files/:fileId', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
router.delete('/assets/:id/files/:fileId', requireCapability('assets.edit'), (req, res) => {
|
||||||
const result = run('DELETE FROM asset_files WHERE id = ? AND asset_id = ?', [req.params.fileId, req.params.id]);
|
const result = run('DELETE FROM asset_files WHERE id = ? AND asset_id = ?', [req.params.fileId, req.params.id]);
|
||||||
if (!result.changes) return res.status(404).json({ error: 'File not found' });
|
if (!result.changes) return res.status(404).json({ error: 'File not found' });
|
||||||
audit(req.user.id, 'delete', 'asset_file', req.params.fileId, null, { asset_id: req.params.id });
|
audit(req.user.id, 'delete', 'asset_file', req.params.fileId, null, { asset_id: req.params.id });
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/assets/:id/warranties', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
// Identification photos (base64 data URLs, with optional captions)
|
||||||
|
router.post('/assets/:id/photos', requireCapability('assets.edit'), (req, res) => {
|
||||||
|
const photo = addAssetPhoto(req.params.id, req.body, req.user.id);
|
||||||
|
if (!photo) return res.status(404).json({ error: 'Asset not found' });
|
||||||
|
return res.status(201).json({ photo });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/assets/:id/photos/:photoId', requireCapability('assets.edit'), (req, res) => {
|
||||||
|
const photo = updateAssetPhoto(req.params.id, req.params.photoId, req.body, req.user.id);
|
||||||
|
if (!photo) return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
return res.json({ photo });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/assets/:id/photos/:photoId', requireCapability('assets.edit'), (req, res) => {
|
||||||
|
if (!deleteAssetPhoto(req.params.id, req.params.photoId, req.user.id)) return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/assets/:id/warranties', requireCapability('assets.edit'), (req, res) => {
|
||||||
const b = req.body;
|
const b = req.body;
|
||||||
const result = run(
|
const result = run(
|
||||||
`INSERT INTO warranties (
|
`INSERT INTO warranties (
|
||||||
@@ -107,14 +136,14 @@ router.post('/assets/:id/warranties', requireRole('admin', 'finance', 'operation
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/assets/:id/warranties/:warrantyId', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
router.delete('/assets/:id/warranties/:warrantyId', requireCapability('assets.edit'), (req, res) => {
|
||||||
const result = run('DELETE FROM warranties WHERE id = ? AND asset_id = ?', [req.params.warrantyId, req.params.id]);
|
const result = run('DELETE FROM warranties WHERE id = ? AND asset_id = ?', [req.params.warrantyId, req.params.id]);
|
||||||
if (!result.changes) return res.status(404).json({ error: 'Warranty not found' });
|
if (!result.changes) return res.status(404).json({ error: 'Warranty not found' });
|
||||||
audit(req.user.id, 'delete', 'warranty', req.params.warrantyId, null, { asset_id: req.params.id });
|
audit(req.user.id, 'delete', 'warranty', req.params.warrantyId, null, { asset_id: req.params.id });
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/assets/:id/tasks', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
router.post('/assets/:id/tasks', requireCapability('assets.edit'), (req, res) => {
|
||||||
const created = now();
|
const created = now();
|
||||||
const result = run(
|
const result = run(
|
||||||
'INSERT INTO tasks (asset_id, title, type, status, due_date, assigned_to, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO tasks (asset_id, title, type, status, due_date, assigned_to, notes, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
@@ -124,7 +153,7 @@ router.post('/assets/:id/tasks', requireRole('admin', 'finance', 'operations'),
|
|||||||
res.status(201).json({ task: one('SELECT * FROM tasks WHERE id = ?', [result.lastInsertRowid]) });
|
res.status(201).json({ task: one('SELECT * FROM tasks WHERE id = ?', [result.lastInsertRowid]) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/assets/:id/tasks/:taskId', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
router.put('/assets/:id/tasks/:taskId', requireCapability('assets.edit'), (req, res) => {
|
||||||
const before = one('SELECT * FROM tasks WHERE id = ? AND asset_id = ?', [req.params.taskId, req.params.id]);
|
const before = one('SELECT * FROM tasks WHERE id = ? AND asset_id = ?', [req.params.taskId, req.params.id]);
|
||||||
if (!before) return res.status(404).json({ error: 'Task not found' });
|
if (!before) return res.status(404).json({ error: 'Task not found' });
|
||||||
run(
|
run(
|
||||||
@@ -143,14 +172,14 @@ router.put('/assets/:id/tasks/:taskId', requireRole('admin', 'finance', 'operati
|
|||||||
return res.json({ task: one('SELECT * FROM tasks WHERE id = ?', [req.params.taskId]) });
|
return res.json({ task: one('SELECT * FROM tasks WHERE id = ?', [req.params.taskId]) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/assets/:id/tasks/:taskId', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
router.delete('/assets/:id/tasks/:taskId', requireCapability('assets.edit'), (req, res) => {
|
||||||
const result = run('DELETE FROM tasks WHERE id = ? AND asset_id = ?', [req.params.taskId, req.params.id]);
|
const result = run('DELETE FROM tasks WHERE id = ? AND asset_id = ?', [req.params.taskId, req.params.id]);
|
||||||
if (!result.changes) return res.status(404).json({ error: 'Task not found' });
|
if (!result.changes) return res.status(404).json({ error: 'Task not found' });
|
||||||
audit(req.user.id, 'delete', 'task', req.params.taskId, null, { asset_id: req.params.id });
|
audit(req.user.id, 'delete', 'task', req.params.taskId, null, { asset_id: req.params.id });
|
||||||
return res.status(204).end();
|
return res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/assets/:id/notes', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
router.post('/assets/:id/notes', requireCapability('assets.edit'), (req, res) => {
|
||||||
if (!req.body.body) return res.status(400).json({ error: 'Note body is required' });
|
if (!req.body.body) return res.status(400).json({ error: 'Note body is required' });
|
||||||
const result = run(
|
const result = run(
|
||||||
'INSERT INTO asset_notes (asset_id, body, created_by, created_at) VALUES (?, ?, ?, ?)',
|
'INSERT INTO asset_notes (asset_id, body, created_by, created_at) VALUES (?, ?, ?, ?)',
|
||||||
@@ -165,7 +194,7 @@ router.post('/assets/:id/notes', requireRole('admin', 'finance', 'operations'),
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/assets/:id/notes/:noteId', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
router.delete('/assets/:id/notes/:noteId', requireCapability('assets.edit'), (req, res) => {
|
||||||
const result = run('DELETE FROM asset_notes WHERE id = ? AND asset_id = ?', [req.params.noteId, req.params.id]);
|
const result = run('DELETE FROM asset_notes WHERE id = ? AND asset_id = ?', [req.params.noteId, req.params.id]);
|
||||||
if (!result.changes) return res.status(404).json({ error: 'Note not found' });
|
if (!result.changes) return res.status(404).json({ error: 'Note not found' });
|
||||||
audit(req.user.id, 'delete', 'asset_note', req.params.noteId, null, { asset_id: req.params.id });
|
audit(req.user.id, 'delete', 'asset_note', req.params.noteId, null, { asset_id: req.params.id });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { requireRole } = require('../middleware/auth');
|
const { requireCapability } = require('../middleware/auth');
|
||||||
const {
|
const {
|
||||||
assignAsset,
|
assignAsset,
|
||||||
assignmentRows,
|
assignmentRows,
|
||||||
@@ -15,7 +15,7 @@ router.get('/employees', (req, res) => {
|
|||||||
res.json({ employees: listEmployees(req.query) });
|
res.json({ employees: listEmployees(req.query) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/employees', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
router.post('/employees', requireCapability('assets.assign'), (req, res) => {
|
||||||
res.status(201).json({ employee: upsertEmployee({ ...req.body, source: req.body.source || 'manual' }) });
|
res.status(201).json({ employee: upsertEmployee({ ...req.body, source: req.body.source || 'manual' }) });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ router.get('/assignments', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/assignments', requireRole('admin', 'finance', 'operations'), (req, res, next) => {
|
router.post('/assignments', requireCapability('assets.assign'), (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
res.status(201).json({ assignment: assignAsset(req.body, req.user.id) });
|
res.status(201).json({ assignment: assignAsset(req.body, req.user.id) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -38,7 +38,7 @@ router.post('/assignments', requireRole('admin', 'finance', 'operations'), (req,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/assignments/:id/release', requireRole('admin', 'finance', 'operations'), (req, res) => {
|
router.put('/assignments/:id/release', requireCapability('assets.assign'), (req, res) => {
|
||||||
const assignment = releaseAssignment(req.params.id, req.body, req.user.id);
|
const assignment = releaseAssignment(req.params.id, req.body, req.user.id);
|
||||||
if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
|
if (!assignment) return res.status(404).json({ error: 'Assignment not found' });
|
||||||
return res.json({ assignment });
|
return res.json({ assignment });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const { audit, one } = require('../db');
|
const { audit, one } = require('../db');
|
||||||
const { publicUser, tokenFor } = require('../middleware/auth');
|
const { publicUser, tokenFor } = require('../middleware/auth');
|
||||||
|
const { capabilitiesForRole } = require('../services/roles');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ router.post('/auth/login', (req, res) => {
|
|||||||
return res.status(401).json({ error: 'Invalid email or password' });
|
return res.status(401).json({ error: 'Invalid email or password' });
|
||||||
}
|
}
|
||||||
audit(user.id, 'login', 'user', user.id, null, { email: user.email });
|
audit(user.id, 'login', 'user', user.id, null, { email: user.email });
|
||||||
return res.json({ token: tokenFor(user), user: publicUser(user) });
|
return res.json({ token: tokenFor(user), user: publicUser(user), capabilities: capabilitiesForRole(user.role) });
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
46
server/routes/books.js
Normal file
46
server/routes/books.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { requireCapability } = require('../middleware/auth');
|
||||||
|
const { bookLedger, createBook, deleteBook, ledgerReport, listBooks, updateBook } = require('../services/books');
|
||||||
|
const { exportReport } = require('../services/reportExport');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/books', (req, res) => {
|
||||||
|
res.json(listBooks());
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/books', requireCapability('finance.manage'), (req, res) => {
|
||||||
|
res.status(201).json({ book: createBook(req.body, req.user.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/books/:id', requireCapability('finance.manage'), (req, res) => {
|
||||||
|
const book = updateBook(req.params.id, req.body, req.user.id);
|
||||||
|
if (!book) return res.status(404).json({ error: 'Book not found' });
|
||||||
|
return res.json({ book });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/books/:id', requireCapability('finance.manage'), (req, res) => {
|
||||||
|
if (!deleteBook(req.params.id, req.user.id)) return res.status(404).json({ error: 'Book not found' });
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/books/:code/ledger', (req, res) => {
|
||||||
|
const year = Number(req.query.year) || new Date().getFullYear();
|
||||||
|
const ledger = bookLedger(req.params.code, year);
|
||||||
|
if (!ledger) return res.status(404).json({ error: 'Book not found' });
|
||||||
|
return res.json({ ledger });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/books/:code/ledger/export', async (req, res) => {
|
||||||
|
const year = Number(req.body.year) || new Date().getFullYear();
|
||||||
|
const report = ledgerReport(req.params.code, year);
|
||||||
|
if (!report) return res.status(404).json({ error: 'Book not found' });
|
||||||
|
const format = String(req.body.format || 'csv').toLowerCase();
|
||||||
|
const output = await exportReport(report, format);
|
||||||
|
const extension = { csv: 'csv', xlsx: 'xlsx', pdf: 'pdf' }[format] || 'txt';
|
||||||
|
res.setHeader('Content-Type', output.contentType);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="mixedassets-${req.params.code}-ledger-${year}.${extension}"`);
|
||||||
|
return res.send(output.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
53
server/routes/contacts.js
Normal file
53
server/routes/contacts.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { requireCapability } = require('../middleware/auth');
|
||||||
|
const {
|
||||||
|
addNote,
|
||||||
|
createContact,
|
||||||
|
deleteContact,
|
||||||
|
deleteNote,
|
||||||
|
getContact,
|
||||||
|
listContacts,
|
||||||
|
updateContact
|
||||||
|
} = require('../services/contacts');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const editor = requireCapability('contacts.manage');
|
||||||
|
|
||||||
|
router.get('/contacts', (req, res) => {
|
||||||
|
res.json({ contacts: listContacts(req.query) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/contacts/:id', (req, res) => {
|
||||||
|
const contact = getContact(req.params.id);
|
||||||
|
if (!contact) return res.status(404).json({ error: 'Contact not found' });
|
||||||
|
return res.json({ contact });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/contacts', editor, (req, res) => {
|
||||||
|
res.status(201).json({ contact: createContact(req.body, req.user.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/contacts/:id', editor, (req, res) => {
|
||||||
|
const contact = updateContact(req.params.id, req.body, req.user.id);
|
||||||
|
if (!contact) return res.status(404).json({ error: 'Contact not found' });
|
||||||
|
return res.json({ contact });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/contacts/:id', requireCapability('contacts.manage'), (req, res) => {
|
||||||
|
if (!deleteContact(req.params.id, req.user.id)) return res.status(404).json({ error: 'Contact not found' });
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/contacts/:id/notes', editor, (req, res) => {
|
||||||
|
if (!req.body.body) return res.status(400).json({ error: 'Note body is required' });
|
||||||
|
const note = addNote(req.params.id, req.body.body, req.user.id);
|
||||||
|
if (!note) return res.status(404).json({ error: 'Contact not found' });
|
||||||
|
return res.status(201).json({ note });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/contacts/:id/notes/:noteId', editor, (req, res) => {
|
||||||
|
if (!deleteNote(req.params.id, req.params.noteId, req.user.id)) return res.status(404).json({ error: 'Note not found' });
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const { audit } = require('../db');
|
const { audit } = require('../db');
|
||||||
const { requireRole } = require('../middleware/auth');
|
const { requireCapability } = require('../middleware/auth');
|
||||||
const { upsertImportedAssets } = require('../services/assets');
|
const { upsertImportedAssets } = require('../services/assets');
|
||||||
const { assetExport, extensionForUpload, rowsFromImport } = require('../services/importExport');
|
const { assetExport, extensionForUpload, rowsFromImport } = require('../services/importExport');
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ router.get('/export/assets', async (req, res) => {
|
|||||||
return res.send(output.body);
|
return res.send(output.body);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/import/assets', requireRole('admin', 'finance'), upload.single('file'), async (req, res) => {
|
router.post('/import/assets', requireCapability('assets.bulk'), upload.single('file'), async (req, res) => {
|
||||||
if (!req.file) return res.status(400).json({ error: 'File is required' });
|
if (!req.file) return res.status(400).json({ error: 'File is required' });
|
||||||
const rows = await rowsFromImport(extensionForUpload(req.file.originalname), req.file.buffer);
|
const rows = await rowsFromImport(extensionForUpload(req.file.originalname), req.file.buffer);
|
||||||
const imported = upsertImportedAssets(rows, req.user.id);
|
const imported = upsertImportedAssets(rows, req.user.id);
|
||||||
|
|||||||
44
server/routes/disposals.js
Normal file
44
server/routes/disposals.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { requireCapability } = require('../middleware/auth');
|
||||||
|
const {
|
||||||
|
deleteAdjustment,
|
||||||
|
previewDisposal,
|
||||||
|
recordAdjustment,
|
||||||
|
recordDisposal,
|
||||||
|
reverseDisposal
|
||||||
|
} = require('../services/disposals');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/assets/:id/disposal/preview', (req, res) => {
|
||||||
|
const preview = previewDisposal(req.params.id, req.body);
|
||||||
|
if (!preview) return res.status(404).json({ error: 'Asset not found' });
|
||||||
|
return res.json({ preview });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/assets/:id/disposals', requireCapability('finance.manage'), (req, res) => {
|
||||||
|
const result = recordDisposal(req.params.id, req.body, req.user.id);
|
||||||
|
if (!result) return res.status(404).json({ error: 'Asset not found' });
|
||||||
|
return res.status(201).json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/assets/:id/disposals/:disposalId', requireCapability('finance.manage'), (req, res) => {
|
||||||
|
const asset = reverseDisposal(req.params.id, req.params.disposalId, req.user.id);
|
||||||
|
if (!asset) return res.status(404).json({ error: 'Disposal not found' });
|
||||||
|
return res.json({ asset });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/assets/:id/adjustments', requireCapability('finance.manage'), (req, res) => {
|
||||||
|
const adjustment = recordAdjustment(req.params.id, req.body, req.user.id);
|
||||||
|
if (!adjustment) return res.status(404).json({ error: 'Asset not found' });
|
||||||
|
return res.status(201).json({ adjustment });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/assets/:id/adjustments/:adjustmentId', requireCapability('finance.manage'), (req, res) => {
|
||||||
|
if (!deleteAdjustment(req.params.id, req.params.adjustmentId, req.user.id)) {
|
||||||
|
return res.status(404).json({ error: 'Adjustment not found' });
|
||||||
|
}
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
39
server/routes/leases.js
Normal file
39
server/routes/leases.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { requireCapability } = require('../middleware/auth');
|
||||||
|
const {
|
||||||
|
createLease,
|
||||||
|
deleteLease,
|
||||||
|
getLease,
|
||||||
|
leaseSchedule,
|
||||||
|
listLeases,
|
||||||
|
updateLease
|
||||||
|
} = require('../services/leases');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/leases', (req, res) => {
|
||||||
|
res.json({ leases: listLeases(req.query) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/leases', requireCapability('finance.manage'), (req, res) => {
|
||||||
|
res.status(201).json({ lease: createLease(req.body, req.user.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/leases/:id', requireCapability('finance.manage'), (req, res) => {
|
||||||
|
const lease = updateLease(req.params.id, req.body, req.user.id);
|
||||||
|
if (!lease) return res.status(404).json({ error: 'Lease not found' });
|
||||||
|
return res.json({ lease });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/leases/:id', requireCapability('finance.manage'), (req, res) => {
|
||||||
|
if (!deleteLease(req.params.id, req.user.id)) return res.status(404).json({ error: 'Lease not found' });
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/leases/:id/schedule', (req, res) => {
|
||||||
|
const lease = getLease(req.params.id);
|
||||||
|
if (!lease) return res.status(404).json({ error: 'Lease not found' });
|
||||||
|
return res.json({ lease, schedule: leaseSchedule(lease) });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
77
server/routes/parts.js
Normal file
77
server/routes/parts.js
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { requireCapability } = require('../middleware/auth');
|
||||||
|
const {
|
||||||
|
addPhoto,
|
||||||
|
createPart,
|
||||||
|
deletePart,
|
||||||
|
deletePhoto,
|
||||||
|
deleteStock,
|
||||||
|
getPart,
|
||||||
|
listParts,
|
||||||
|
recordTransaction,
|
||||||
|
saveStock,
|
||||||
|
updatePart
|
||||||
|
} = require('../services/parts');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const editor = requireCapability('parts.manage');
|
||||||
|
|
||||||
|
router.get('/parts', (req, res) => {
|
||||||
|
res.json({ parts: listParts(req.query) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/parts/:id', (req, res) => {
|
||||||
|
const part = getPart(req.params.id);
|
||||||
|
if (!part) return res.status(404).json({ error: 'Part not found' });
|
||||||
|
return res.json({ part });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/parts', editor, (req, res) => {
|
||||||
|
res.status(201).json({ part: createPart(req.body, req.user.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/parts/:id', editor, (req, res) => {
|
||||||
|
const part = updatePart(req.params.id, req.body, req.user.id);
|
||||||
|
if (!part) return res.status(404).json({ error: 'Part not found' });
|
||||||
|
return res.json({ part });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/parts/:id', requireCapability('parts.manage'), (req, res) => {
|
||||||
|
if (!deletePart(req.params.id, req.user.id)) return res.status(404).json({ error: 'Part not found' });
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stock locations (aisle/bin + on-hand/reserved per location)
|
||||||
|
router.post('/parts/:id/stock', editor, (req, res) => {
|
||||||
|
const part = saveStock(req.params.id, req.body, req.user.id);
|
||||||
|
if (!part) return res.status(404).json({ error: 'Part or stock location not found' });
|
||||||
|
return res.status(201).json({ part });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/parts/:id/stock/:stockId', editor, (req, res) => {
|
||||||
|
const part = deleteStock(req.params.id, req.params.stockId, req.user.id);
|
||||||
|
if (!part) return res.status(404).json({ error: 'Stock location not found' });
|
||||||
|
return res.json({ part });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stock movements (receive/issue/adjust/reserve/unreserve)
|
||||||
|
router.post('/parts/:id/transactions', editor, (req, res) => {
|
||||||
|
const part = recordTransaction(req.params.id, req.body, req.user.id);
|
||||||
|
if (!part) return res.status(404).json({ error: 'Part not found' });
|
||||||
|
return res.status(201).json({ part });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Photos
|
||||||
|
router.post('/parts/:id/photos', editor, (req, res) => {
|
||||||
|
const part = addPhoto(req.params.id, req.body, req.user.id);
|
||||||
|
if (!part) return res.status(404).json({ error: 'Part not found' });
|
||||||
|
return res.status(201).json({ part });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/parts/:id/photos/:photoId', editor, (req, res) => {
|
||||||
|
const part = deletePhoto(req.params.id, req.params.photoId, req.user.id);
|
||||||
|
if (!part) return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
return res.json({ part });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
96
server/routes/pm.js
Normal file
96
server/routes/pm.js
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { requireCapability } = require('../middleware/auth');
|
||||||
|
const {
|
||||||
|
assetPmRows,
|
||||||
|
attachPlan,
|
||||||
|
completePm,
|
||||||
|
createPlan,
|
||||||
|
deletePlan,
|
||||||
|
detachAssetPm,
|
||||||
|
getCompletion,
|
||||||
|
getPlan,
|
||||||
|
getSettings,
|
||||||
|
listCompletions,
|
||||||
|
listPlans,
|
||||||
|
saveSettings,
|
||||||
|
updateAssetPm,
|
||||||
|
updatePlan
|
||||||
|
} = require('../services/pm');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const planEditor = requireCapability('pm.manage'); // create/edit PM plan templates & defaults
|
||||||
|
const assetPm = requireCapability('pm.complete'); // attach to assets & complete services
|
||||||
|
|
||||||
|
// PM plan templates
|
||||||
|
router.get('/pm-plans', (req, res) => {
|
||||||
|
res.json({ plans: listPlans() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/pm-plans/:id', (req, res) => {
|
||||||
|
const plan = getPlan(req.params.id);
|
||||||
|
if (!plan) return res.status(404).json({ error: 'PM plan not found' });
|
||||||
|
return res.json({ plan });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/pm-plans', planEditor, (req, res) => {
|
||||||
|
res.status(201).json({ plan: createPlan(req.body, req.user.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/pm-plans/:id', planEditor, (req, res) => {
|
||||||
|
const plan = updatePlan(req.params.id, req.body, req.user.id);
|
||||||
|
if (!plan) return res.status(404).json({ error: 'PM plan not found' });
|
||||||
|
return res.json({ plan });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/pm-plans/:id', planEditor, (req, res) => {
|
||||||
|
if (!deletePlan(req.params.id, req.user.id)) return res.status(404).json({ error: 'PM plan not found' });
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// PM defaults (readable by any authenticated user; pm.manage can change)
|
||||||
|
router.get('/pm-settings', (req, res) => {
|
||||||
|
res.json({ settings: getSettings() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/pm-settings', planEditor, (req, res) => {
|
||||||
|
res.json({ settings: saveSettings(req.body, req.user.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Completed PM forms
|
||||||
|
router.get('/pm-completions', (req, res) => {
|
||||||
|
res.json({ completions: listCompletions(req.query) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/pm-completions/:id', (req, res) => {
|
||||||
|
const completion = getCompletion(req.params.id);
|
||||||
|
if (!completion) return res.status(404).json({ error: 'Completion not found' });
|
||||||
|
return res.json({ completion });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-asset PM schedules
|
||||||
|
router.get('/assets/:id/pm', (req, res) => {
|
||||||
|
res.json({ pm: assetPmRows(req.params.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/assets/:id/pm', assetPm, (req, res) => {
|
||||||
|
res.status(201).json({ pm: attachPlan(req.params.id, req.body, req.user.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/assets/:id/pm/:apId', assetPm, (req, res) => {
|
||||||
|
const pm = updateAssetPm(req.params.id, req.params.apId, req.body, req.user.id);
|
||||||
|
if (!pm) return res.status(404).json({ error: 'PM schedule not found' });
|
||||||
|
return res.json({ pm });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/assets/:id/pm/:apId', assetPm, (req, res) => {
|
||||||
|
if (!detachAssetPm(req.params.id, req.params.apId, req.user.id)) return res.status(404).json({ error: 'PM schedule not found' });
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/assets/:id/pm/:apId/complete', assetPm, (req, res) => {
|
||||||
|
const pm = completePm(req.params.id, req.params.apId, req.body, req.user.id);
|
||||||
|
if (!pm) return res.status(404).json({ error: 'PM schedule not found' });
|
||||||
|
return res.json({ pm });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,14 +1,75 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { all, audit, now, one, parseJson, run } = require('../db');
|
const { all, audit, json, now, one, parseJson, run } = require('../db');
|
||||||
const { requireRole } = require('../middleware/auth');
|
const { requireCapability } = require('../middleware/auth');
|
||||||
|
const { formatAssetId } = require('../services/assets');
|
||||||
|
const assetClasses = require('../services/assetClasses');
|
||||||
|
const zones = require('../services/zones');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Special depreciation zones (e.g. New York Liberty Zone). GET is reference data used by the
|
||||||
|
// asset form; mutations require config.manage.
|
||||||
|
router.get('/depreciation-zones', (req, res) => {
|
||||||
|
res.json({ zones: zones.list() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/depreciation-zones', requireCapability('config.manage'), (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.status(201).json({ zone: zones.createZone(req.body, req.user.id) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/depreciation-zones/:code', requireCapability('config.manage'), (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const zone = zones.updateZone(req.params.code, req.body, req.user.id);
|
||||||
|
if (!zone) return res.status(404).json({ error: 'Zone not found' });
|
||||||
|
return res.json({ zone });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/depreciation-zones/:code', requireCapability('config.manage'), (req, res) => {
|
||||||
|
if (!zones.deleteZone(req.params.code, req.user.id)) return res.status(404).json({ error: 'Zone not found' });
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Asset class catalog (seeded from the IRS tables, then user-managed). GET is reference
|
||||||
|
// data used by the category picker; mutations require config.manage.
|
||||||
|
router.get('/asset-classes', (req, res) => {
|
||||||
|
res.json({ assetClasses: assetClasses.list() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/asset-classes', requireCapability('config.manage'), (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.status(201).json({ assetClass: assetClasses.createClass(req.body, req.user.id) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/asset-classes/:id', requireCapability('config.manage'), (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const assetClass = assetClasses.updateClass(req.params.id, req.body, req.user.id);
|
||||||
|
if (!assetClass) return res.status(404).json({ error: 'Asset class not found' });
|
||||||
|
return res.json({ assetClass });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/asset-classes/:id', requireCapability('config.manage'), (req, res) => {
|
||||||
|
if (!assetClasses.deleteClass(req.params.id, req.user.id)) return res.status(404).json({ error: 'Asset class not found' });
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/entities', (req, res) => {
|
router.get('/entities', (req, res) => {
|
||||||
res.json({ entities: all('SELECT * FROM entities ORDER BY name') });
|
res.json({ entities: all('SELECT * FROM entities ORDER BY name') });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/entities', requireRole('admin', 'finance'), (req, res) => {
|
router.post('/entities', requireCapability('config.manage'), (req, res) => {
|
||||||
const created = now();
|
const created = now();
|
||||||
const result = run(
|
const result = run(
|
||||||
'INSERT INTO entities (name, legal_name, tax_id, fiscal_year_end_month, base_currency, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO entities (name, legal_name, tax_id, fiscal_year_end_month, base_currency, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
@@ -31,4 +92,325 @@ router.get('/references', (req, res) => {
|
|||||||
res.json({ references: rows.map((row) => ({ ...row, metadata: parseJson(row.metadata, {}) })) });
|
res.json({ references: rows.map((row) => ({ ...row, metadata: parseJson(row.metadata, {}) })) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Asset categories (managed reference_values of type 'category') ----------
|
||||||
|
// Asset category is stored on each asset by name, so a rename cascades to assets.
|
||||||
|
|
||||||
|
function categoryRow(row) {
|
||||||
|
const meta = parseJson(row.metadata, {});
|
||||||
|
const templateId = meta.id_template_id || null;
|
||||||
|
const template = templateId ? one('SELECT name, pattern FROM asset_id_templates WHERE id = ?', [templateId]) : null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
code: row.code,
|
||||||
|
asset_class_number: meta.asset_class_number || null,
|
||||||
|
id_template_id: template ? templateId : null,
|
||||||
|
id_template_name: template ? template.name : null,
|
||||||
|
id_template_pattern: template ? template.pattern : null,
|
||||||
|
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE category = ?', [row.name]).c
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryEditor = requireCapability('config.manage');
|
||||||
|
|
||||||
|
router.get('/asset-categories', (req, res) => {
|
||||||
|
const rows = all("SELECT * FROM reference_values WHERE type = 'category' ORDER BY name");
|
||||||
|
res.json({ categories: rows.map(categoryRow) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/asset-categories', categoryEditor, (req, res) => {
|
||||||
|
const name = String(req.body.name || '').trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||||
|
if (one("SELECT id FROM reference_values WHERE type = 'category' AND name = ?", [name])) {
|
||||||
|
return res.status(400).json({ error: 'That category already exists' });
|
||||||
|
}
|
||||||
|
const result = run(
|
||||||
|
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('category', ?, ?, ?)",
|
||||||
|
[name, req.body.code || null, json({ id_template_id: req.body.id_template_id || null, asset_class_number: req.body.asset_class_number || null })]
|
||||||
|
);
|
||||||
|
audit(req.user.id, 'create', 'asset_category', result.lastInsertRowid, null, { name });
|
||||||
|
return res.status(201).json({ category: categoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/asset-categories/:id', categoryEditor, (req, res) => {
|
||||||
|
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'category'", [req.params.id]);
|
||||||
|
if (!before) return res.status(404).json({ error: 'Category not found' });
|
||||||
|
const name = String(req.body.name ?? before.name).trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||||
|
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'category' AND name = ?", [name])) {
|
||||||
|
return res.status(400).json({ error: 'That category already exists' });
|
||||||
|
}
|
||||||
|
const meta = parseJson(before.metadata, {});
|
||||||
|
if (req.body.id_template_id !== undefined) meta.id_template_id = req.body.id_template_id || null;
|
||||||
|
if (req.body.asset_class_number !== undefined) meta.asset_class_number = req.body.asset_class_number || null;
|
||||||
|
run('UPDATE reference_values SET name = ?, code = ?, metadata = ? WHERE id = ?', [name, req.body.code ?? before.code, json(meta), req.params.id]);
|
||||||
|
let renamed = 0;
|
||||||
|
if (name !== before.name) {
|
||||||
|
renamed = run('UPDATE assets SET category = ?, updated_at = ? WHERE category = ?', [name, now(), before.name]).changes || 0;
|
||||||
|
}
|
||||||
|
// The class number is shared by every asset in the category — cascade any change.
|
||||||
|
if (req.body.asset_class_number !== undefined) {
|
||||||
|
run('UPDATE assets SET asset_class_number = ?, updated_at = ? WHERE category = ?', [meta.asset_class_number, now(), name]);
|
||||||
|
}
|
||||||
|
audit(req.user.id, 'update', 'asset_category', req.params.id, before, { name, renamed_assets: renamed });
|
||||||
|
return res.json({ category: categoryRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_assets: renamed });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/asset-categories/:id', categoryEditor, (req, res) => {
|
||||||
|
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'category'", [req.params.id]);
|
||||||
|
if (!before) return res.status(404).json({ error: 'Category not found' });
|
||||||
|
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE category = ?', [before.name]).c;
|
||||||
|
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
|
||||||
|
audit(req.user.id, 'delete', 'asset_category', req.params.id, before, { asset_count: affected });
|
||||||
|
return res.json({ deleted: true, asset_count: affected });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Asset departments (managed reference_values of type 'department') -------
|
||||||
|
// Department is stored on each asset by name, so a rename cascades to assets.
|
||||||
|
|
||||||
|
function departmentRow(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
code: row.code,
|
||||||
|
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE department = ?', [row.name]).c
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/asset-departments', (req, res) => {
|
||||||
|
const rows = all("SELECT * FROM reference_values WHERE type = 'department' ORDER BY name");
|
||||||
|
res.json({ departments: rows.map(departmentRow) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/asset-departments', categoryEditor, (req, res) => {
|
||||||
|
const name = String(req.body.name || '').trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'Department name is required' });
|
||||||
|
if (one("SELECT id FROM reference_values WHERE type = 'department' AND name = ?", [name])) {
|
||||||
|
return res.status(400).json({ error: 'That department already exists' });
|
||||||
|
}
|
||||||
|
const result = run(
|
||||||
|
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('department', ?, ?, '{}')",
|
||||||
|
[name, req.body.code || null]
|
||||||
|
);
|
||||||
|
audit(req.user.id, 'create', 'asset_department', result.lastInsertRowid, null, { name });
|
||||||
|
return res.status(201).json({ department: departmentRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/asset-departments/:id', categoryEditor, (req, res) => {
|
||||||
|
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'department'", [req.params.id]);
|
||||||
|
if (!before) return res.status(404).json({ error: 'Department not found' });
|
||||||
|
const name = String(req.body.name ?? before.name).trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'Department name is required' });
|
||||||
|
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'department' AND name = ?", [name])) {
|
||||||
|
return res.status(400).json({ error: 'That department already exists' });
|
||||||
|
}
|
||||||
|
run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
|
||||||
|
let renamed = 0;
|
||||||
|
if (name !== before.name) {
|
||||||
|
renamed = run('UPDATE assets SET department = ?, updated_at = ? WHERE department = ?', [name, now(), before.name]).changes || 0;
|
||||||
|
}
|
||||||
|
audit(req.user.id, 'update', 'asset_department', req.params.id, before, { name, renamed_assets: renamed });
|
||||||
|
return res.json({ department: departmentRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_assets: renamed });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/asset-departments/:id', categoryEditor, (req, res) => {
|
||||||
|
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'department'", [req.params.id]);
|
||||||
|
if (!before) return res.status(404).json({ error: 'Department not found' });
|
||||||
|
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE department = ?', [before.name]).c;
|
||||||
|
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
|
||||||
|
audit(req.user.id, 'delete', 'asset_department', req.params.id, before, { asset_count: affected });
|
||||||
|
return res.json({ deleted: true, asset_count: affected });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- PM categories (managed reference_values of type 'pm_category') ----------
|
||||||
|
// Separate from asset categories. Stored on each PM plan by name, so a rename cascades.
|
||||||
|
|
||||||
|
function pmCategoryRow(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
code: row.code,
|
||||||
|
plan_count: one('SELECT COUNT(*) AS c FROM pm_plans WHERE category = ?', [row.name]).c
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/pm-categories', (req, res) => {
|
||||||
|
const rows = all("SELECT * FROM reference_values WHERE type = 'pm_category' ORDER BY name");
|
||||||
|
res.json({ categories: rows.map(pmCategoryRow) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/pm-categories', categoryEditor, (req, res) => {
|
||||||
|
const name = String(req.body.name || '').trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||||
|
if (one("SELECT id FROM reference_values WHERE type = 'pm_category' AND name = ?", [name])) {
|
||||||
|
return res.status(400).json({ error: 'That PM category already exists' });
|
||||||
|
}
|
||||||
|
const result = run(
|
||||||
|
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('pm_category', ?, ?, '{}')",
|
||||||
|
[name, req.body.code || null]
|
||||||
|
);
|
||||||
|
audit(req.user.id, 'create', 'pm_category', result.lastInsertRowid, null, { name });
|
||||||
|
return res.status(201).json({ category: pmCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/pm-categories/:id', categoryEditor, (req, res) => {
|
||||||
|
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'pm_category'", [req.params.id]);
|
||||||
|
if (!before) return res.status(404).json({ error: 'PM category not found' });
|
||||||
|
const name = String(req.body.name ?? before.name).trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||||
|
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'pm_category' AND name = ?", [name])) {
|
||||||
|
return res.status(400).json({ error: 'That PM category already exists' });
|
||||||
|
}
|
||||||
|
run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
|
||||||
|
let renamed = 0;
|
||||||
|
if (name !== before.name) {
|
||||||
|
renamed = run('UPDATE pm_plans SET category = ?, updated_at = ? WHERE category = ?', [name, now(), before.name]).changes || 0;
|
||||||
|
}
|
||||||
|
audit(req.user.id, 'update', 'pm_category', req.params.id, before, { name, renamed_plans: renamed });
|
||||||
|
return res.json({ category: pmCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_plans: renamed });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/pm-categories/:id', categoryEditor, (req, res) => {
|
||||||
|
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'pm_category'", [req.params.id]);
|
||||||
|
if (!before) return res.status(404).json({ error: 'PM category not found' });
|
||||||
|
const affected = one('SELECT COUNT(*) AS c FROM pm_plans WHERE category = ?', [before.name]).c;
|
||||||
|
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
|
||||||
|
audit(req.user.id, 'delete', 'pm_category', req.params.id, before, { plan_count: affected });
|
||||||
|
return res.json({ deleted: true, plan_count: affected });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Part categories (managed reference_values of type 'part_category') ------
|
||||||
|
// Separate from asset/PM categories. Stored on each part by name, so a rename cascades.
|
||||||
|
|
||||||
|
function partCategoryRow(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
code: row.code,
|
||||||
|
part_count: one('SELECT COUNT(*) AS c FROM parts WHERE category = ?', [row.name]).c
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/part-categories', (req, res) => {
|
||||||
|
const rows = all("SELECT * FROM reference_values WHERE type = 'part_category' ORDER BY name");
|
||||||
|
res.json({ categories: rows.map(partCategoryRow) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/part-categories', categoryEditor, (req, res) => {
|
||||||
|
const name = String(req.body.name || '').trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||||
|
if (one("SELECT id FROM reference_values WHERE type = 'part_category' AND name = ?", [name])) {
|
||||||
|
return res.status(400).json({ error: 'That part category already exists' });
|
||||||
|
}
|
||||||
|
const result = run(
|
||||||
|
"INSERT INTO reference_values (type, name, code, metadata) VALUES ('part_category', ?, ?, '{}')",
|
||||||
|
[name, req.body.code || null]
|
||||||
|
);
|
||||||
|
audit(req.user.id, 'create', 'part_category', result.lastInsertRowid, null, { name });
|
||||||
|
return res.status(201).json({ category: partCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [result.lastInsertRowid])) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/part-categories/:id', categoryEditor, (req, res) => {
|
||||||
|
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'part_category'", [req.params.id]);
|
||||||
|
if (!before) return res.status(404).json({ error: 'Part category not found' });
|
||||||
|
const name = String(req.body.name ?? before.name).trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'Category name is required' });
|
||||||
|
if (name !== before.name && one("SELECT id FROM reference_values WHERE type = 'part_category' AND name = ?", [name])) {
|
||||||
|
return res.status(400).json({ error: 'That part category already exists' });
|
||||||
|
}
|
||||||
|
run('UPDATE reference_values SET name = ?, code = ? WHERE id = ?', [name, req.body.code ?? before.code, req.params.id]);
|
||||||
|
let renamed = 0;
|
||||||
|
if (name !== before.name) {
|
||||||
|
renamed = run('UPDATE parts SET category = ?, updated_at = ? WHERE category = ?', [name, now(), before.name]).changes || 0;
|
||||||
|
}
|
||||||
|
audit(req.user.id, 'update', 'part_category', req.params.id, before, { name, renamed_parts: renamed });
|
||||||
|
return res.json({ category: partCategoryRow(one('SELECT * FROM reference_values WHERE id = ?', [req.params.id])), renamed_parts: renamed });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/part-categories/:id', categoryEditor, (req, res) => {
|
||||||
|
const before = one("SELECT * FROM reference_values WHERE id = ? AND type = 'part_category'", [req.params.id]);
|
||||||
|
if (!before) return res.status(404).json({ error: 'Part category not found' });
|
||||||
|
const affected = one('SELECT COUNT(*) AS c FROM parts WHERE category = ?', [before.name]).c;
|
||||||
|
run('DELETE FROM reference_values WHERE id = ?', [req.params.id]);
|
||||||
|
audit(req.user.id, 'delete', 'part_category', req.params.id, before, { part_count: affected });
|
||||||
|
return res.json({ deleted: true, part_count: affected });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Asset ID templates -----------------------------------------------------
|
||||||
|
// Naming patterns (e.g. 'DT-######') used to auto-assign asset tags. The first run of
|
||||||
|
// '#' is replaced with a zero-padded, auto-incrementing number. Assigned to categories.
|
||||||
|
|
||||||
|
function idTemplateCategoryCount(templateId) {
|
||||||
|
return all("SELECT metadata FROM reference_values WHERE type = 'category'")
|
||||||
|
.filter((row) => parseJson(row.metadata, {}).id_template_id === templateId).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function idTemplateRow(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
pattern: row.pattern,
|
||||||
|
next_number: row.next_number,
|
||||||
|
preview: formatAssetId(row.pattern, row.next_number),
|
||||||
|
category_count: idTemplateCategoryCount(row.id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/asset-id-templates', (req, res) => {
|
||||||
|
res.json({ templates: all('SELECT * FROM asset_id_templates ORDER BY name').map(idTemplateRow) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/asset-id-templates', categoryEditor, (req, res) => {
|
||||||
|
const name = String(req.body.name || '').trim();
|
||||||
|
const pattern = String(req.body.pattern || '').trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'A template name is required' });
|
||||||
|
if (!pattern.includes('#')) return res.status(400).json({ error: 'Pattern must include at least one # for the number' });
|
||||||
|
const next = Math.max(1, Number(req.body.next_number) || 1);
|
||||||
|
const ts = now();
|
||||||
|
const result = run(
|
||||||
|
'INSERT INTO asset_id_templates (name, pattern, next_number, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[name, pattern, next, ts, ts]
|
||||||
|
);
|
||||||
|
audit(req.user.id, 'create', 'asset_id_template', result.lastInsertRowid, null, { name, pattern });
|
||||||
|
return res.status(201).json({ template: idTemplateRow(one('SELECT * FROM asset_id_templates WHERE id = ?', [result.lastInsertRowid])) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/asset-id-templates/:id', categoryEditor, (req, res) => {
|
||||||
|
const before = one('SELECT * FROM asset_id_templates WHERE id = ?', [req.params.id]);
|
||||||
|
if (!before) return res.status(404).json({ error: 'Template not found' });
|
||||||
|
const pattern = req.body.pattern !== undefined ? String(req.body.pattern).trim() : before.pattern;
|
||||||
|
if (!pattern.includes('#')) return res.status(400).json({ error: 'Pattern must include at least one # for the number' });
|
||||||
|
run(
|
||||||
|
'UPDATE asset_id_templates SET name = ?, pattern = ?, next_number = ?, updated_at = ? WHERE id = ?',
|
||||||
|
[
|
||||||
|
req.body.name !== undefined ? String(req.body.name).trim() || before.name : before.name,
|
||||||
|
pattern,
|
||||||
|
req.body.next_number !== undefined ? Math.max(1, Number(req.body.next_number) || 1) : before.next_number,
|
||||||
|
now(),
|
||||||
|
req.params.id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
audit(req.user.id, 'update', 'asset_id_template', req.params.id, before, req.body);
|
||||||
|
return res.json({ template: idTemplateRow(one('SELECT * FROM asset_id_templates WHERE id = ?', [req.params.id])) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/asset-id-templates/:id', categoryEditor, (req, res) => {
|
||||||
|
const before = one('SELECT * FROM asset_id_templates WHERE id = ?', [req.params.id]);
|
||||||
|
if (!before) return res.status(404).json({ error: 'Template not found' });
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
// Unassign from any categories that referenced it.
|
||||||
|
let unassigned = 0;
|
||||||
|
for (const row of all("SELECT id, metadata FROM reference_values WHERE type = 'category'")) {
|
||||||
|
const meta = parseJson(row.metadata, {});
|
||||||
|
if (meta.id_template_id === id) {
|
||||||
|
meta.id_template_id = null;
|
||||||
|
run('UPDATE reference_values SET metadata = ? WHERE id = ?', [json(meta), row.id]);
|
||||||
|
unassigned += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run('DELETE FROM asset_id_templates WHERE id = ?', [id]);
|
||||||
|
audit(req.user.id, 'delete', 'asset_id_template', id, before, { unassigned_categories: unassigned });
|
||||||
|
return res.json({ deleted: true, category_count: unassigned });
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,12 +1,61 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { all, audit, json, now, one, parseJson, run } = require('../db');
|
||||||
|
const { requireCapability } = require('../middleware/auth');
|
||||||
const {
|
const {
|
||||||
depreciationReport,
|
depreciationReport,
|
||||||
monthlyDepreciationReport,
|
monthlyDepreciationReport,
|
||||||
writeDepreciationPdf
|
writeDepreciationPdf
|
||||||
} = require('../services/reports');
|
} = require('../services/reports');
|
||||||
|
const { catalog, runReport } = require('../services/reportEngine');
|
||||||
|
const { exportReport } = require('../services/reportExport');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
const EXPORT_EXTENSIONS = { csv: 'csv', xlsx: 'xlsx', pdf: 'pdf', json: 'json' };
|
||||||
|
|
||||||
|
router.get('/reports/catalog', (req, res) => {
|
||||||
|
res.json(catalog());
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/reports/run', (req, res) => {
|
||||||
|
res.json({ report: runReport(req.body.type, req.body.options || {}) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/reports/export', async (req, res) => {
|
||||||
|
const format = String(req.body.format || 'csv').toLowerCase();
|
||||||
|
const report = runReport(req.body.type, req.body.options || {});
|
||||||
|
const output = await exportReport(report, format);
|
||||||
|
const extension = EXPORT_EXTENSIONS[format] || 'txt';
|
||||||
|
res.setHeader('Content-Type', output.contentType);
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="mixedassets-${req.body.type}.${extension}"`);
|
||||||
|
res.send(output.body);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/saved-reports', (req, res) => {
|
||||||
|
const reports = all(
|
||||||
|
`SELECT s.*, u.name AS created_by_name FROM saved_reports s
|
||||||
|
LEFT JOIN users u ON u.id = s.created_by ORDER BY s.name`
|
||||||
|
).map((row) => ({ ...row, options_json: parseJson(row.options_json, {}) }));
|
||||||
|
res.json({ reports });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/saved-reports', requireCapability('reports.save'), (req, res) => {
|
||||||
|
const result = run(
|
||||||
|
'INSERT INTO saved_reports (name, report_type, options_json, created_by, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[req.body.name || 'Saved report', req.body.report_type, json(req.body.options || {}), req.user.id, now()]
|
||||||
|
);
|
||||||
|
audit(req.user.id, 'create', 'saved_report', result.lastInsertRowid, null, req.body);
|
||||||
|
const saved = one('SELECT * FROM saved_reports WHERE id = ?', [result.lastInsertRowid]);
|
||||||
|
res.status(201).json({ report: { ...saved, options_json: parseJson(saved.options_json, {}) } });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/saved-reports/:id', requireCapability('reports.save'), (req, res) => {
|
||||||
|
const result = run('DELETE FROM saved_reports WHERE id = ?', [req.params.id]);
|
||||||
|
if (!result.changes) return res.status(404).json({ error: 'Saved report not found' });
|
||||||
|
audit(req.user.id, 'delete', 'saved_report', req.params.id, null, null);
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/reports/depreciation', (req, res) => {
|
router.get('/reports/depreciation', (req, res) => {
|
||||||
const year = Number(req.query.year || new Date().getFullYear());
|
const year = Number(req.query.year || new Date().getFullYear());
|
||||||
const bookType = req.query.book || 'GAAP';
|
const bookType = req.query.book || 'GAAP';
|
||||||
|
|||||||
31
server/routes/servicenow.js
Normal file
31
server/routes/servicenow.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { requireCapability } = require('../middleware/auth');
|
||||||
|
const { publicConfig, saveConfig, syncCmdb, testConnection } = require('../services/servicenow');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/servicenow/settings', requireCapability('admin.integrations'), (req, res) => {
|
||||||
|
res.json({ settings: publicConfig() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/servicenow/settings', requireCapability('admin.integrations'), (req, res) => {
|
||||||
|
res.json({ settings: saveConfig(req.body, req.user.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/servicenow/test', requireCapability('admin.integrations'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(await testConnection(req.user.id));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/servicenow/cmdb-sync', requireCapability('assets.bulk'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(await syncCmdb(req.user.id));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -1,34 +1,120 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { all, audit, now, one, parseJson, run } = require('../db');
|
const { all, audit, now, one, parseJson, run, tx } = require('../db');
|
||||||
const { requireRole } = require('../middleware/auth');
|
const { requireCapability } = require('../middleware/auth');
|
||||||
|
const { expandRuleSet } = require('../rule-catalog');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
function coerceRules(value) {
|
||||||
|
if (value === undefined || value === null) return {};
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
const error = new Error('rules_json is not valid JSON');
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof value !== 'object') {
|
||||||
|
const error = new Error('rules_json must be a JSON object');
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serialize(row) {
|
||||||
|
return { ...row, active: Boolean(row.active), rules_json: parseJson(row.rules_json, {}) };
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/tax-rules', (req, res) => {
|
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) => ({
|
const ruleSets = all('SELECT * FROM tax_rule_sets ORDER BY active DESC, effective_date DESC, id DESC').map(serialize);
|
||||||
...row,
|
|
||||||
active: Boolean(row.active),
|
|
||||||
rules_json: parseJson(row.rules_json, {})
|
|
||||||
}));
|
|
||||||
res.json({ ruleSets });
|
res.json({ ruleSets });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/tax-rules', requireRole('admin', 'finance'), (req, res) => {
|
router.post('/tax-rules', requireCapability('finance.manage'), (req, res, next) => {
|
||||||
const result = run(
|
try {
|
||||||
'INSERT INTO tax_rule_sets (jurisdiction, name, effective_date, version, rules_json, active, source_note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
const rules = coerceRules(req.body.rules_json ?? req.body.rules);
|
||||||
[
|
const result = run(
|
||||||
req.body.jurisdiction,
|
'INSERT INTO tax_rule_sets (jurisdiction, name, effective_date, version, rules_json, active, source_note, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
req.body.name,
|
[
|
||||||
req.body.effective_date,
|
req.body.jurisdiction || rules.jurisdiction || 'US-FED',
|
||||||
req.body.version,
|
req.body.name || rules.name || 'Untitled rule set',
|
||||||
JSON.stringify(req.body.rules_json || req.body.rules || {}),
|
req.body.effective_date || rules.effectiveDate || rules.effective_date || new Date().toISOString().slice(0, 10),
|
||||||
req.body.active === false ? 0 : 1,
|
req.body.version || rules.version || '1.0',
|
||||||
req.body.source_note || null,
|
JSON.stringify(rules),
|
||||||
now()
|
req.body.active === false ? 0 : 1,
|
||||||
]
|
req.body.source_note || rules.sourceNote || 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]) });
|
);
|
||||||
|
audit(req.user.id, 'create', 'tax_rule_set', result.lastInsertRowid, null, { ...req.body, rules_json: '[stored]' });
|
||||||
|
res.status(201).json({ ruleSet: serialize(one('SELECT * FROM tax_rule_sets WHERE id = ?', [result.lastInsertRowid])) });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/tax-rules/:id', requireCapability('finance.manage'), (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const before = one('SELECT * FROM tax_rule_sets WHERE id = ?', [req.params.id]);
|
||||||
|
if (!before) return res.status(404).json({ error: 'Rule set not found' });
|
||||||
|
const rules = req.body.rules_json !== undefined || req.body.rules !== undefined
|
||||||
|
? coerceRules(req.body.rules_json ?? req.body.rules)
|
||||||
|
: parseJson(before.rules_json, {});
|
||||||
|
run(
|
||||||
|
`UPDATE tax_rule_sets SET
|
||||||
|
jurisdiction = ?, name = ?, effective_date = ?, version = ?, rules_json = ?, active = ?, source_note = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[
|
||||||
|
req.body.jurisdiction ?? before.jurisdiction,
|
||||||
|
req.body.name ?? before.name,
|
||||||
|
req.body.effective_date ?? before.effective_date,
|
||||||
|
req.body.version ?? before.version,
|
||||||
|
JSON.stringify(rules),
|
||||||
|
req.body.active === undefined ? before.active : (req.body.active ? 1 : 0),
|
||||||
|
req.body.source_note ?? before.source_note,
|
||||||
|
req.params.id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
audit(req.user.id, 'update', 'tax_rule_set', req.params.id, { ...before, rules_json: '[stored]' }, { ...req.body, rules_json: '[stored]' });
|
||||||
|
return res.json({ ruleSet: serialize(one('SELECT * FROM tax_rule_sets WHERE id = ?', [req.params.id])) });
|
||||||
|
} catch (error) {
|
||||||
|
if (/UNIQUE/.test(error.message || '')) {
|
||||||
|
return res.status(400).json({ error: 'A rule set with this jurisdiction and version already exists' });
|
||||||
|
}
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/tax-rules/:id', requireCapability('finance.manage'), (req, res) => {
|
||||||
|
const before = one('SELECT * FROM tax_rule_sets WHERE id = ?', [req.params.id]);
|
||||||
|
if (!before) return res.status(404).json({ error: 'Rule set not found' });
|
||||||
|
run('DELETE FROM tax_rule_sets WHERE id = ?', [req.params.id]);
|
||||||
|
audit(req.user.id, 'delete', 'tax_rule_set', req.params.id, { ...before, rules_json: '[stored]' }, null);
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/tax-rules/:id/activate', requireCapability('finance.manage'), (req, res) => {
|
||||||
|
const target = one('SELECT * FROM tax_rule_sets WHERE id = ?', [req.params.id]);
|
||||||
|
if (!target) return res.status(404).json({ error: 'Rule set not found' });
|
||||||
|
tx(() => {
|
||||||
|
run('UPDATE tax_rule_sets SET active = 0 WHERE jurisdiction = ? AND id != ?', [target.jurisdiction, target.id]);
|
||||||
|
run('UPDATE tax_rule_sets SET active = 1 WHERE id = ?', [target.id]);
|
||||||
|
});
|
||||||
|
audit(req.user.id, 'activate', 'tax_rule_set', target.id, null, { jurisdiction: target.jurisdiction });
|
||||||
|
return res.json({ ruleSets: all('SELECT * FROM tax_rule_sets ORDER BY active DESC, effective_date DESC, id DESC').map(serialize) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge the generated 68-method depreciation catalog into a rule set (works on unsaved drafts too).
|
||||||
|
router.post('/tax-rules/expand', requireCapability('finance.manage'), (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rules = coerceRules(req.body.rules_json ?? req.body.rules);
|
||||||
|
return res.json({ rules_json: expandRuleSet(rules) });
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { all, audit, json, now, one, parseJson, run } = require('../db');
|
const { all, audit, json, now, one, parseJson, run } = require('../db');
|
||||||
const { requireRole } = require('../middleware/auth');
|
const { requireCapability } = require('../middleware/auth');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/templates', (req, res) => {
|
router.get('/templates', (req, res) => {
|
||||||
const templates = all('SELECT * FROM asset_templates ORDER BY name').map((row) => ({
|
const templates = all(
|
||||||
|
`SELECT t.*, (SELECT COUNT(*) FROM assets a WHERE a.template_id = t.id) AS asset_count
|
||||||
|
FROM asset_templates t ORDER BY t.name`
|
||||||
|
).map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
defaults: parseJson(row.defaults, {}),
|
defaults: parseJson(row.defaults, {}),
|
||||||
custom_fields: parseJson(row.custom_fields, [])
|
custom_fields: parseJson(row.custom_fields, [])
|
||||||
@@ -13,7 +16,7 @@ router.get('/templates', (req, res) => {
|
|||||||
res.json({ templates });
|
res.json({ templates });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/templates', requireRole('admin', 'finance'), (req, res) => {
|
router.post('/templates', requireCapability('config.manage'), (req, res) => {
|
||||||
const created = now();
|
const created = now();
|
||||||
const result = run(
|
const result = run(
|
||||||
'INSERT INTO asset_templates (name, description, defaults, custom_fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
'INSERT INTO asset_templates (name, description, defaults, custom_fields, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
@@ -23,4 +26,34 @@ router.post('/templates', requireRole('admin', 'finance'), (req, res) => {
|
|||||||
res.status(201).json({ template: one('SELECT * FROM asset_templates WHERE id = ?', [result.lastInsertRowid]) });
|
res.status(201).json({ template: one('SELECT * FROM asset_templates WHERE id = ?', [result.lastInsertRowid]) });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.put('/templates/:id', requireCapability('config.manage'), (req, res) => {
|
||||||
|
const before = one('SELECT * FROM asset_templates WHERE id = ?', [req.params.id]);
|
||||||
|
if (!before) return res.status(404).json({ error: 'Template not found' });
|
||||||
|
run(
|
||||||
|
'UPDATE asset_templates SET name = ?, description = ?, defaults = ?, custom_fields = ?, updated_at = ? WHERE id = ?',
|
||||||
|
[
|
||||||
|
req.body.name ?? before.name,
|
||||||
|
req.body.description ?? before.description,
|
||||||
|
json(req.body.defaults || {}),
|
||||||
|
json(req.body.custom_fields || []),
|
||||||
|
now(),
|
||||||
|
req.params.id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
audit(req.user.id, 'update', 'asset_template', req.params.id, before, req.body);
|
||||||
|
return res.json({ template: one('SELECT * FROM asset_templates WHERE id = ?', [req.params.id]) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deleting a template unlinks it from any assets (their stored field values are kept)
|
||||||
|
// and removes it from the picker for future assets.
|
||||||
|
router.delete('/templates/:id', requireCapability('config.manage'), (req, res) => {
|
||||||
|
const before = one('SELECT * FROM asset_templates WHERE id = ?', [req.params.id]);
|
||||||
|
if (!before) return res.status(404).json({ error: 'Template not found' });
|
||||||
|
const affected = one('SELECT COUNT(*) AS c FROM assets WHERE template_id = ?', [req.params.id]).c;
|
||||||
|
run('UPDATE assets SET template_id = NULL, updated_at = ? WHERE template_id = ?', [now(), req.params.id]);
|
||||||
|
run('DELETE FROM asset_templates WHERE id = ?', [req.params.id]);
|
||||||
|
audit(req.user.id, 'delete', 'asset_template', req.params.id, before, { unlinked_assets: affected });
|
||||||
|
return res.json({ deleted: true, unlinked_assets: affected });
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { requireRole } = require('../middleware/auth');
|
const { requireCapability } = require('../middleware/auth');
|
||||||
const {
|
const {
|
||||||
getConnection,
|
getConnection,
|
||||||
importWorkersFromPayload,
|
importWorkersFromPayload,
|
||||||
@@ -9,15 +9,15 @@ const {
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/workday/connection', requireRole('admin'), (req, res) => {
|
router.get('/workday/connection', requireCapability('admin.integrations'), (req, res) => {
|
||||||
res.json({ connection: getConnection() });
|
res.json({ connection: getConnection() });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.put('/workday/connection', requireRole('admin'), (req, res) => {
|
router.put('/workday/connection', requireCapability('admin.integrations'), (req, res) => {
|
||||||
res.json({ connection: saveConnection(req.body, req.user.id) });
|
res.json({ connection: saveConnection(req.body, req.user.id) });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/workday/sync-workers', requireRole('admin'), async (req, res, next) => {
|
router.post('/workday/sync-workers', requireCapability('admin.integrations'), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
res.json(await syncWorkers(req.user.id));
|
res.json(await syncWorkers(req.user.id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -25,7 +25,7 @@ router.post('/workday/sync-workers', requireRole('admin'), async (req, res, next
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/workday/import-workers', requireRole('admin'), (req, res) => {
|
router.post('/workday/import-workers', requireCapability('admin.integrations'), (req, res) => {
|
||||||
res.json(importWorkersFromPayload(req.body.workers || req.body, req.user.id));
|
res.json(importWorkersFromPayload(req.body.workers || req.body, req.user.id));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -111,8 +111,35 @@ function buildSpecialMethods() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sage-style 200% declining-balance method codes. The MACRS family (MF/MT/MI) ignores
|
||||||
|
// salvage value and recovers the full basis; the standard family (DB/DH/DD) honors salvage
|
||||||
|
// (depreciates only down to it). All use a 200% rate and switch to straight-line.
|
||||||
|
function build200Methods() {
|
||||||
|
return [
|
||||||
|
method('MF200', 'MACRS', 'MF200 — MACRS Formula, 200% DB (GDS)', 'macrs_declining_balance', {
|
||||||
|
system: 'GDS', rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'half_year', ignoreSalvage: true
|
||||||
|
}),
|
||||||
|
method('MT200', 'MACRS', 'MT200 — MACRS Tables, 200% DB (GDS)', 'macrs_declining_balance', {
|
||||||
|
system: 'GDS', rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'half_year', ignoreSalvage: true, usesTables: true
|
||||||
|
}),
|
||||||
|
method('MI200', 'MACRS', 'MI200 — MACRS 200% DB, Indian Reservation', 'macrs_declining_balance', {
|
||||||
|
system: 'GDS', rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'half_year', ignoreSalvage: true, indianReservation: true
|
||||||
|
}),
|
||||||
|
method('DB200', 'Declining Balance', 'DB200 — 200% Declining Balance to SL (uses book convention)', 'declining_balance', {
|
||||||
|
rateMultiplier: 2, switchToStraightLine: true
|
||||||
|
}),
|
||||||
|
method('DH200', 'Declining Balance', 'DH200 — 200% Declining Balance to SL, Half-Year', 'declining_balance', {
|
||||||
|
rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'half_year'
|
||||||
|
}),
|
||||||
|
method('DD200', 'Declining Balance', 'DD200 — 200% Declining Balance to SL, Full-Year', 'declining_balance', {
|
||||||
|
rateMultiplier: 2, switchToStraightLine: true, defaultConvention: 'none'
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function buildDepreciationMethods() {
|
function buildDepreciationMethods() {
|
||||||
return [
|
return [
|
||||||
|
...build200Methods(),
|
||||||
...buildMacrsMethods(),
|
...buildMacrsMethods(),
|
||||||
...buildAcrsMethods(),
|
...buildAcrsMethods(),
|
||||||
...buildGaapMethods(),
|
...buildGaapMethods(),
|
||||||
|
|||||||
@@ -1,47 +1,81 @@
|
|||||||
const roles = ['admin', 'finance', 'operations', 'viewer'];
|
// Capability catalog — the granular permissions the application understands. Routes are
|
||||||
|
// guarded by these keys (see middleware/auth requireCapability); roles are sets of them.
|
||||||
|
// This is pure data (no DB) so it can be required anywhere without circular dependencies.
|
||||||
|
|
||||||
const rolePermissions = {
|
const CAPABILITIES = [
|
||||||
admin: [
|
// Assets
|
||||||
'Manage users, teams, roles, settings, and integrations',
|
{ key: 'assets.view', label: 'View assets', group: 'Assets' },
|
||||||
'Create, edit, delete, import, export, and assign assets',
|
{ key: 'assets.edit', label: 'Create & edit assets (files, photos, warranties, tasks, notes)', group: 'Assets' },
|
||||||
'Manage templates, tax rules, reports, and Workday sync'
|
{ key: 'assets.delete', label: 'Delete assets', group: 'Assets' },
|
||||||
],
|
{ key: 'assets.bulk', label: 'Import, export, mass-update & CMDB sync', group: 'Assets' },
|
||||||
finance: [
|
{ key: 'assets.assign', label: 'Assign assets & manage employees', group: 'Assets' },
|
||||||
'Create, edit, delete, import, and export assets',
|
// Maintenance
|
||||||
'Manage templates, tax rules, reports, and depreciation workflows',
|
{ key: 'pm.view', label: 'View maintenance', group: 'Maintenance' },
|
||||||
'Assign and release assets'
|
{ key: 'pm.manage', label: 'Create & edit PM plans and defaults', group: 'Maintenance' },
|
||||||
],
|
{ key: 'pm.complete', label: 'Attach & complete PM on assets', group: 'Maintenance' },
|
||||||
operations: [
|
{ key: 'parts.manage', label: 'Manage parts inventory', group: 'Maintenance' },
|
||||||
'Create and edit assets',
|
{ key: 'alerts.manage', label: 'Work alerts (scan, acknowledge, dismiss, ticket)', group: 'Maintenance' },
|
||||||
'Assign and release assets',
|
// Contacts
|
||||||
'Manage asset tasks, files, employees, and custody details'
|
{ key: 'contacts.manage', label: 'Manage contacts & CRM', group: 'Contacts' },
|
||||||
],
|
// Finance
|
||||||
viewer: [
|
{ key: 'finance.view', label: 'View reports, books & tax rules', group: 'Finance' },
|
||||||
'View assets, assignments, employees, templates, tax rules, and reports',
|
{ key: 'finance.manage', label: 'Manage books, tax rules, disposals & leases', group: 'Finance' },
|
||||||
'No create, update, delete, import, export, or integration access'
|
{ key: 'reports.save', label: 'Save & delete reports', group: 'Finance' },
|
||||||
]
|
// Configuration
|
||||||
};
|
{ key: 'config.manage', label: 'Manage templates, categories & ID templates', group: 'Configuration' },
|
||||||
|
// Administration
|
||||||
const roleCapabilities = [
|
{ key: 'admin.users', label: 'Manage users & teams', group: 'Administration' },
|
||||||
{ key: 'view_assets', label: 'View assets', roles: ['admin', 'finance', 'operations', 'viewer'] },
|
{ key: 'admin.roles', label: 'Manage roles', group: 'Administration' },
|
||||||
{ key: 'edit_assets', label: 'Create/edit assets', roles: ['admin', 'finance', 'operations'] },
|
{ key: 'admin.settings', label: 'Manage application settings', group: 'Administration' },
|
||||||
{ key: 'delete_assets', label: 'Delete assets', roles: ['admin', 'finance'] },
|
{ key: 'admin.integrations', label: 'Manage integrations (email, webhook, ServiceNow, Workday)', group: 'Administration' }
|
||||||
{ 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) {
|
const CAPABILITY_KEYS = CAPABILITIES.map((c) => c.key);
|
||||||
return roles.includes(role);
|
|
||||||
|
// '*' is a wildcard granting every capability (used by the admin role and future-proofs new ones).
|
||||||
|
const WILDCARD = '*';
|
||||||
|
|
||||||
|
const FINANCE_CAPS = [
|
||||||
|
'assets.view', 'assets.edit', 'assets.delete', 'assets.bulk', 'assets.assign',
|
||||||
|
'pm.view', 'pm.manage', 'pm.complete', 'parts.manage', 'alerts.manage',
|
||||||
|
'contacts.manage', 'finance.view', 'finance.manage', 'reports.save', 'config.manage'
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPERATIONS_CAPS = [
|
||||||
|
'assets.view', 'assets.edit', 'assets.assign',
|
||||||
|
'pm.view', 'pm.manage', 'pm.complete', 'parts.manage', 'alerts.manage',
|
||||||
|
'contacts.manage', 'reports.save'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Built-in roles. is_system roles cannot be deleted (admin/built-ins) but their
|
||||||
|
// capabilities can be edited, except admin which always keeps the wildcard.
|
||||||
|
const DEFAULT_ROLES = [
|
||||||
|
{ key: 'admin', name: 'Administrator', description: 'Full system access, including users, roles, settings, and integrations.', capabilities: [WILDCARD], is_system: 1, locked: 1, sort_order: 0 },
|
||||||
|
{ key: 'finance', name: 'Finance', description: 'Full asset, maintenance, and financial management; no system administration.', capabilities: FINANCE_CAPS, is_system: 1, locked: 0, sort_order: 1 },
|
||||||
|
{ key: 'operations', name: 'Operations', description: 'Day-to-day asset, maintenance, parts, contacts, and alert work.', capabilities: OPERATIONS_CAPS, is_system: 1, locked: 0, sort_order: 2 },
|
||||||
|
{ key: 'pm_admin', name: 'PM Admin', description: 'Manages preventative maintenance: plans, defaults, completion, parts, and alerts.', capabilities: ['assets.view', 'pm.view', 'pm.manage', 'pm.complete', 'parts.manage', 'alerts.manage', 'reports.save'], is_system: 1, locked: 0, sort_order: 3 },
|
||||||
|
{ key: 'pm_user', name: 'PM User', description: 'Completes preventative maintenance and views assets, parts, and alerts.', capabilities: ['assets.view', 'pm.view', 'pm.complete', 'alerts.manage'], is_system: 1, locked: 0, sort_order: 4 },
|
||||||
|
{ key: 'viewer', name: 'Viewer', description: 'Read-only access to assets, maintenance, and reports.', capabilities: ['assets.view', 'pm.view', 'finance.view'], is_system: 1, locked: 0, sort_order: 5 }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Does a capability set satisfy a required capability? Honors the wildcard.
|
||||||
|
function capabilitySetIncludes(capabilities, required) {
|
||||||
|
if (!Array.isArray(capabilities)) return false;
|
||||||
|
return capabilities.includes(WILDCARD) || capabilities.includes(required);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize an arbitrary capability list to known keys (admin/wildcard preserved).
|
||||||
|
function sanitizeCapabilities(list) {
|
||||||
|
if (Array.isArray(list) && list.includes(WILDCARD)) return [WILDCARD];
|
||||||
|
const set = new Set((Array.isArray(list) ? list : []).filter((key) => CAPABILITY_KEYS.includes(key)));
|
||||||
|
return CAPABILITY_KEYS.filter((key) => set.has(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
isValidRole,
|
CAPABILITIES,
|
||||||
roleCapabilities,
|
CAPABILITY_KEYS,
|
||||||
rolePermissions,
|
DEFAULT_ROLES,
|
||||||
roles
|
WILDCARD,
|
||||||
|
capabilitySetIncludes,
|
||||||
|
sanitizeCapabilities
|
||||||
};
|
};
|
||||||
|
|||||||
45
server/services/accessControl.test.js
Normal file
45
server/services/accessControl.test.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* Tests for the capability catalog and default roles used by the RBAC system.
|
||||||
|
* Roles are stored in the database and editable at runtime; this validates the
|
||||||
|
* static catalog/defaults and the pure capability-matching helpers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const accessControl = require('./accessControl');
|
||||||
|
|
||||||
|
describe('access control', () => {
|
||||||
|
test('capability catalog is well-formed', () => {
|
||||||
|
const { CAPABILITIES, CAPABILITY_KEYS } = accessControl;
|
||||||
|
expect(CAPABILITIES.length).toBeGreaterThan(0);
|
||||||
|
for (const cap of CAPABILITIES) {
|
||||||
|
expect(typeof cap.key).toBe('string');
|
||||||
|
expect(typeof cap.label).toBe('string');
|
||||||
|
expect(typeof cap.group).toBe('string');
|
||||||
|
}
|
||||||
|
// Keys are unique.
|
||||||
|
expect(new Set(CAPABILITY_KEYS).size).toBe(CAPABILITY_KEYS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('default roles include the built-ins and PM roles', () => {
|
||||||
|
const keys = accessControl.DEFAULT_ROLES.map((r) => r.key);
|
||||||
|
for (const key of ['admin', 'finance', 'operations', 'pm_admin', 'pm_user', 'viewer']) {
|
||||||
|
expect(keys).toContain(key);
|
||||||
|
}
|
||||||
|
const admin = accessControl.DEFAULT_ROLES.find((r) => r.key === 'admin');
|
||||||
|
expect(admin.capabilities).toEqual(['*']);
|
||||||
|
|
||||||
|
const pmUser = accessControl.DEFAULT_ROLES.find((r) => r.key === 'pm_user');
|
||||||
|
expect(pmUser.capabilities).toContain('pm.complete');
|
||||||
|
expect(pmUser.capabilities).not.toContain('pm.manage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('capabilitySetIncludes honors the wildcard', () => {
|
||||||
|
expect(accessControl.capabilitySetIncludes(['*'], 'anything')).toBe(true);
|
||||||
|
expect(accessControl.capabilitySetIncludes(['assets.view'], 'assets.view')).toBe(true);
|
||||||
|
expect(accessControl.capabilitySetIncludes(['assets.view'], 'assets.delete')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeCapabilities drops unknown keys and collapses wildcard', () => {
|
||||||
|
expect(accessControl.sanitizeCapabilities(['assets.view', 'made.up'])).toEqual(['assets.view']);
|
||||||
|
expect(accessControl.sanitizeCapabilities(['assets.view', '*'])).toEqual(['*']);
|
||||||
|
});
|
||||||
|
});
|
||||||
213
server/services/alerts.js
Normal file
213
server/services/alerts.js
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
const { all, audit, now, one, run } = require('../db');
|
||||||
|
const { getConfig, sendMail } = require('./notifications');
|
||||||
|
const { deliverAlerts } = require('./webhooks');
|
||||||
|
const servicenow = require('./servicenow');
|
||||||
|
const { pmAlertCandidates } = require('./pm');
|
||||||
|
|
||||||
|
function todayStr() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysUntil(dateStr) {
|
||||||
|
return Math.round((new Date(`${dateStr}T00:00:00`) - new Date(`${todayStr()}T00:00:00`)) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mk(type, referenceType, referenceId, assetId, severity, title, message, dueDate) {
|
||||||
|
return { type, reference_type: referenceType, reference_id: referenceId, asset_id: assetId, severity, title, message, due_date: dueDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the current set of due/overdue/expiring items that warrant an alert.
|
||||||
|
function candidates(leadDays) {
|
||||||
|
const today = todayStr();
|
||||||
|
const horizon = new Date(Date.now() + leadDays * 86400000).toISOString().slice(0, 10);
|
||||||
|
const list = [];
|
||||||
|
|
||||||
|
const tasks = all(
|
||||||
|
`SELECT t.*, a.asset_id AS asset_code FROM tasks t LEFT JOIN assets a ON a.id = t.asset_id
|
||||||
|
WHERE t.status != 'complete' AND t.due_date IS NOT NULL`
|
||||||
|
);
|
||||||
|
for (const task of tasks) {
|
||||||
|
const where = task.asset_code ? ` on ${task.asset_code}` : '';
|
||||||
|
if (task.due_date < today) {
|
||||||
|
list.push(mk('maintenance_overdue', 'task', task.id, task.asset_id, 'critical', `Overdue: ${task.title}`, `"${task.title}"${where} was due ${task.due_date}.`, task.due_date));
|
||||||
|
} else if (task.due_date <= horizon) {
|
||||||
|
list.push(mk('maintenance_due', 'task', task.id, task.asset_id, 'warning', `Upcoming: ${task.title}`, `"${task.title}"${where} is due ${task.due_date}.`, task.due_date));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const warranties = all(
|
||||||
|
`SELECT w.*, a.asset_id AS asset_code FROM warranties w LEFT JOIN assets a ON a.id = w.asset_id
|
||||||
|
WHERE w.expiration_date IS NOT NULL`
|
||||||
|
);
|
||||||
|
for (const w of warranties) {
|
||||||
|
const label = w.provider || 'Warranty';
|
||||||
|
const where = w.asset_code ? ` on ${w.asset_code}` : '';
|
||||||
|
if (w.expiration_date < today) {
|
||||||
|
list.push(mk('warranty_expired', 'warranty', w.id, w.asset_id, 'critical', 'Warranty expired', `${label}${where} expired ${w.expiration_date}.`, w.expiration_date));
|
||||||
|
} else if (w.expiration_date <= horizon) {
|
||||||
|
const sev = daysUntil(w.expiration_date) <= 7 ? 'critical' : 'warning';
|
||||||
|
list.push(mk('warranty_expiring', 'warranty', w.id, w.asset_id, sev, 'Warranty expiring', `${label}${where} expires ${w.expiration_date}.`, w.expiration_date));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const leases = all(
|
||||||
|
`SELECT l.*, a.asset_id AS asset_code FROM leases l LEFT JOIN assets a ON a.id = l.asset_id
|
||||||
|
WHERE l.end_date IS NOT NULL`
|
||||||
|
);
|
||||||
|
for (const l of leases) {
|
||||||
|
if (l.end_date < today || l.end_date > horizon) continue;
|
||||||
|
const where = l.asset_code ? ` on ${l.asset_code}` : '';
|
||||||
|
const sev = daysUntil(l.end_date) <= 7 ? 'critical' : 'warning';
|
||||||
|
list.push(mk('lease_expiring', 'lease', l.id, l.asset_id, sev, 'Lease ending', `Lease with ${l.lessor || 'lessor'}${where} ends ${l.end_date}.`, l.end_date));
|
||||||
|
}
|
||||||
|
|
||||||
|
list.push(...pmAlertCandidates());
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile computed candidates against stored alerts: create new, refresh existing,
|
||||||
|
// resolve ones that no longer apply, and respect dismissals.
|
||||||
|
function scanAlerts(userId) {
|
||||||
|
const cfg = getConfig();
|
||||||
|
const ts = now();
|
||||||
|
const cands = candidates(cfg.leadDays);
|
||||||
|
const seen = new Set();
|
||||||
|
const created = [];
|
||||||
|
|
||||||
|
for (const c of cands) {
|
||||||
|
seen.add(`${c.reference_type}:${c.reference_id}:${c.type}`);
|
||||||
|
const existing = one('SELECT * FROM alerts WHERE reference_type = ? AND reference_id = ? AND type = ?', [c.reference_type, c.reference_id, c.type]);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.status === 'dismissed') continue;
|
||||||
|
run(
|
||||||
|
`UPDATE alerts SET asset_id = ?, severity = ?, title = ?, message = ?, due_date = ?,
|
||||||
|
status = CASE WHEN status = 'resolved' THEN 'open' ELSE status END, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[c.asset_id, c.severity, c.title, c.message, c.due_date, ts, existing.id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const result = run(
|
||||||
|
`INSERT INTO alerts (type, reference_type, reference_id, asset_id, severity, title, message, due_date, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)`,
|
||||||
|
[c.type, c.reference_type, c.reference_id, c.asset_id, c.severity, c.title, c.message, c.due_date, ts, ts]
|
||||||
|
);
|
||||||
|
created.push(one('SELECT * FROM alerts WHERE id = ?', [result.lastInsertRowid]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const alert of all("SELECT * FROM alerts WHERE status IN ('open', 'acknowledged')")) {
|
||||||
|
if (!seen.has(`${alert.reference_type}:${alert.reference_id}:${alert.type}`)) {
|
||||||
|
run("UPDATE alerts SET status = 'resolved', updated_at = ? WHERE id = ?", [ts, alert.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId) audit(userId, 'scan', 'alerts', null, null, { created: created.length });
|
||||||
|
return { created, openCount: one("SELECT COUNT(*) AS c FROM alerts WHERE status = 'open'").c };
|
||||||
|
}
|
||||||
|
|
||||||
|
function listAlerts(query = {}) {
|
||||||
|
return all(
|
||||||
|
`SELECT al.*, a.asset_id AS asset_code, a.description AS asset_description, u.name AS acknowledged_by_name
|
||||||
|
FROM alerts al
|
||||||
|
LEFT JOIN assets a ON a.id = al.asset_id
|
||||||
|
LEFT JOIN users u ON u.id = al.acknowledged_by
|
||||||
|
WHERE (? IS NULL OR al.status = ?) AND (? IS NULL OR al.type = ?)
|
||||||
|
ORDER BY CASE al.severity WHEN 'critical' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END, al.due_date IS NULL, al.due_date`,
|
||||||
|
[query.status || null, query.status || null, query.type || null, query.type || null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summary() {
|
||||||
|
const rows = all("SELECT status, severity, COUNT(*) AS count FROM alerts GROUP BY status, severity");
|
||||||
|
const open = rows.filter((r) => r.status === 'open');
|
||||||
|
return {
|
||||||
|
open: open.reduce((s, r) => s + r.count, 0),
|
||||||
|
critical: open.filter((r) => r.severity === 'critical').reduce((s, r) => s + r.count, 0),
|
||||||
|
acknowledged: rows.filter((r) => r.status === 'acknowledged').reduce((s, r) => s + r.count, 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(id, status, userId) {
|
||||||
|
const before = one('SELECT * FROM alerts WHERE id = ?', [id]);
|
||||||
|
if (!before) return null;
|
||||||
|
run(
|
||||||
|
'UPDATE alerts SET status = ?, acknowledged_by = ?, acknowledged_at = ?, updated_at = ? WHERE id = ?',
|
||||||
|
[
|
||||||
|
status,
|
||||||
|
status === 'acknowledged' ? userId : before.acknowledged_by,
|
||||||
|
status === 'acknowledged' ? now() : before.acknowledged_at,
|
||||||
|
now(),
|
||||||
|
id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
audit(userId, status, 'alert', id, before, { status });
|
||||||
|
return one('SELECT * FROM alerts WHERE id = ?', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function digestHtml(alerts) {
|
||||||
|
const rows = alerts.map((a) =>
|
||||||
|
`<tr><td style="padding:6px 10px;color:${a.severity === 'critical' ? '#b3261e' : '#a56a00'};font-weight:700">${a.severity.toUpperCase()}</td>` +
|
||||||
|
`<td style="padding:6px 10px">${a.title}</td><td style="padding:6px 10px">${a.message || ''}</td>` +
|
||||||
|
`<td style="padding:6px 10px">${a.due_date || ''}</td></tr>`
|
||||||
|
).join('');
|
||||||
|
return `<h2>MixedAssets alerts</h2><p>${alerts.length} item(s) need attention.</p>` +
|
||||||
|
`<table style="border-collapse:collapse;font-family:Arial,sans-serif;font-size:13px">` +
|
||||||
|
`<thead><tr><th align="left" style="padding:6px 10px">Severity</th><th align="left" style="padding:6px 10px">Alert</th>` +
|
||||||
|
`<th align="left" style="padding:6px 10px">Detail</th><th align="left" style="padding:6px 10px">Due</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function digestText(alerts) {
|
||||||
|
return `MixedAssets alerts (${alerts.length}):\n` +
|
||||||
|
alerts.map((a) => `- [${a.severity}] ${a.title} — ${a.message || ''} ${a.due_date ? `(due ${a.due_date})` : ''}`).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan, then notify on newly raised, not-yet-notified open alerts: an email digest
|
||||||
|
// (when SMTP is enabled) and/or a per-alert JSON webhook POST (when enabled). An alert
|
||||||
|
// is marked notified once any channel has handled it, so it is delivered at most once.
|
||||||
|
async function runAlertCycle(userId) {
|
||||||
|
const { created } = scanAlerts(userId);
|
||||||
|
const cfg = getConfig();
|
||||||
|
const result = { created: created.length, emailed: false, webhooked: false };
|
||||||
|
|
||||||
|
const snCfg = servicenow.getConfig();
|
||||||
|
const emailReady = cfg.enabled && cfg.host && cfg.recipients.length;
|
||||||
|
const webhookReady = cfg.webhookEnabled && cfg.webhookUrl;
|
||||||
|
const ticketReady = snCfg.ticketEnabled && snCfg.autoTicket && snCfg.instanceUrl;
|
||||||
|
if (!emailReady && !webhookReady && !ticketReady) return result;
|
||||||
|
|
||||||
|
const pending = all(
|
||||||
|
`SELECT al.*, a.asset_id AS asset_code
|
||||||
|
FROM alerts al LEFT JOIN assets a ON a.id = al.asset_id
|
||||||
|
WHERE al.status = 'open' AND al.notified_at IS NULL`
|
||||||
|
);
|
||||||
|
if (!pending.length) return result;
|
||||||
|
|
||||||
|
if (emailReady) {
|
||||||
|
await sendMail({
|
||||||
|
subject: `MixedAssets: ${pending.length} new alert(s)`,
|
||||||
|
html: digestHtml(pending),
|
||||||
|
text: digestText(pending)
|
||||||
|
});
|
||||||
|
result.emailed = true;
|
||||||
|
result.count = pending.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (webhookReady) {
|
||||||
|
const delivery = await deliverAlerts(pending);
|
||||||
|
result.webhooked = delivery.attempted;
|
||||||
|
result.delivered = delivery.delivered;
|
||||||
|
result.failed = delivery.failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ticketReady) {
|
||||||
|
const tickets = await servicenow.autoTicketAlerts(pending, userId);
|
||||||
|
result.ticketed = tickets.created;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = now();
|
||||||
|
for (const alert of pending) run('UPDATE alerts SET notified_at = ? WHERE id = ?', [ts, alert.id]);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listAlerts, runAlertCycle, scanAlerts, setStatus, summary };
|
||||||
30
server/services/alerts.test.js
Normal file
30
server/services/alerts.test.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
let alerts = require('./alerts')
|
||||||
|
|
||||||
|
describe('alerts', () => {
|
||||||
|
test('creates and lists alerts correctly', () => {
|
||||||
|
const c1 = {
|
||||||
|
type: 'test_alert',
|
||||||
|
reference_type: 'asset',
|
||||||
|
reference_id: 1,
|
||||||
|
severity: 'warning',
|
||||||
|
title: 'Test Alert 1',
|
||||||
|
message: 'This is a test alert for asset 1'
|
||||||
|
};
|
||||||
|
const c2 = {
|
||||||
|
type: 'test_alert',
|
||||||
|
reference_type: 'asset',
|
||||||
|
reference_id: 2,
|
||||||
|
severity: 'critical',
|
||||||
|
title: 'Test Alert 2',
|
||||||
|
message: 'This is a test alert for asset 2'
|
||||||
|
};
|
||||||
|
const result = alerts.scan([c1, c2], 1);
|
||||||
|
expect(result.created.length).toBe(2);
|
||||||
|
expect(result.openCount).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
|
const list = alerts.listAlerts({ status: 'open', type: 'test_alert' });
|
||||||
|
expect(list.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(list.some(a => a.reference_id === 1 && a.severity === 'warning')).toBe(true);
|
||||||
|
expect(list.some(a => a.reference_id === 2 && a.severity === 'critical')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
93
server/services/assetClasses.js
Normal file
93
server/services/assetClasses.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
const { all, audit, now, one, parseJson, run } = require('../db');
|
||||||
|
|
||||||
|
const SOURCES = ['common', 'industry', 'business', 'custom'];
|
||||||
|
|
||||||
|
// How many asset categories reference a given class number (categories store it in metadata).
|
||||||
|
function categoryUsage() {
|
||||||
|
const counts = {};
|
||||||
|
for (const row of all("SELECT metadata FROM reference_values WHERE type = 'category'")) {
|
||||||
|
const num = parseJson(row.metadata, {}).asset_class_number;
|
||||||
|
if (num) counts[num] = (counts[num] || 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRow(row, usage) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
class_number: row.class_number,
|
||||||
|
description: row.description,
|
||||||
|
gds: row.gds,
|
||||||
|
ads: row.ads,
|
||||||
|
source: row.source,
|
||||||
|
table: row.source, // back-compat alias used by the category picker
|
||||||
|
category_count: row.class_number ? (usage[row.class_number] || 0) : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function list() {
|
||||||
|
const usage = categoryUsage();
|
||||||
|
return all('SELECT * FROM asset_classes ORDER BY description').map((row) => fromRow(row, usage));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClass(id) {
|
||||||
|
return fromRow(one('SELECT * FROM asset_classes WHERE id = ?', [id]), categoryUsage());
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(body) {
|
||||||
|
const num = (value) => {
|
||||||
|
if (value === '' || value === null || value === undefined) return null;
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
class_number: body.class_number ? String(body.class_number).trim() : null,
|
||||||
|
description: String(body.description || '').trim(),
|
||||||
|
gds: num(body.gds),
|
||||||
|
ads: num(body.ads),
|
||||||
|
source: SOURCES.includes(body.source) ? body.source : 'custom'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClass(body, userId) {
|
||||||
|
const input = normalize(body);
|
||||||
|
if (!input.description) throw Object.assign(new Error('A description is required'), { status: 400 });
|
||||||
|
const ts = now();
|
||||||
|
const result = run(
|
||||||
|
'INSERT INTO asset_classes (class_number, description, gds, ads, source, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[input.class_number, input.description, input.gds, input.ads, input.source, ts, ts]
|
||||||
|
);
|
||||||
|
audit(userId, 'create', 'asset_class', result.lastInsertRowid, null, input);
|
||||||
|
return getClass(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateClass(id, body, userId) {
|
||||||
|
const before = one('SELECT * FROM asset_classes WHERE id = ?', [id]);
|
||||||
|
if (!before) return null;
|
||||||
|
const input = normalize({ ...before, ...body });
|
||||||
|
if (!input.description) throw Object.assign(new Error('A description is required'), { status: 400 });
|
||||||
|
run(
|
||||||
|
'UPDATE asset_classes SET class_number = ?, description = ?, gds = ?, ads = ?, source = ?, updated_at = ? WHERE id = ?',
|
||||||
|
[input.class_number, input.description, input.gds, input.ads, input.source, now(), id]
|
||||||
|
);
|
||||||
|
audit(userId, 'update', 'asset_class', id, before, input);
|
||||||
|
return getClass(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteClass(id, userId) {
|
||||||
|
const before = one('SELECT * FROM asset_classes WHERE id = ?', [id]);
|
||||||
|
if (!before) return false;
|
||||||
|
run('DELETE FROM asset_classes WHERE id = ?', [id]);
|
||||||
|
audit(userId, 'delete', 'asset_class', id, before, null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SOURCES,
|
||||||
|
createClass,
|
||||||
|
deleteClass,
|
||||||
|
getClass,
|
||||||
|
list,
|
||||||
|
updateClass
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const { all, audit, json, now, one, parseJson, run, tx } = require('../db');
|
const { all, audit, json, now, one, parseJson, run, tx } = require('../db');
|
||||||
|
const { assetPmRows } = require('./pm');
|
||||||
|
|
||||||
const assetColumns = [
|
const assetColumns = [
|
||||||
'asset_id',
|
'asset_id',
|
||||||
@@ -35,12 +36,24 @@ const assetColumns = [
|
|||||||
'disposal_expense',
|
'disposal_expense',
|
||||||
'disposal_type',
|
'disposal_type',
|
||||||
'property_type',
|
'property_type',
|
||||||
|
'special_zone',
|
||||||
'notes',
|
'notes',
|
||||||
'custom_fields'
|
'custom_fields'
|
||||||
];
|
];
|
||||||
|
|
||||||
const defaultBooks = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
|
const defaultBooks = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
|
||||||
|
|
||||||
|
// Depreciation books currently enabled in the registry (excludes the maintenance/PM book).
|
||||||
|
function enabledBookCodes() {
|
||||||
|
try {
|
||||||
|
const rows = all("SELECT code FROM books WHERE enabled = 1 AND book_type != 'maintenance' ORDER BY sort_order, id");
|
||||||
|
if (rows.length) return rows.map((row) => row.code);
|
||||||
|
} catch {
|
||||||
|
// books table may not exist on a very old database
|
||||||
|
}
|
||||||
|
return defaultBooks;
|
||||||
|
}
|
||||||
|
|
||||||
function assetFromRow(row) {
|
function assetFromRow(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return {
|
return {
|
||||||
@@ -51,6 +64,36 @@ function assetFromRow(row) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assetPhotos(id) {
|
||||||
|
return all('SELECT id, name, mime_type, caption, data_base64 FROM asset_photos WHERE asset_id = ? ORDER BY sort_order, id', [id])
|
||||||
|
.map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, caption: p.caption, data: p.data_base64 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAssetPhoto(assetId, body, userId) {
|
||||||
|
if (!one('SELECT id FROM assets WHERE id = ?', [assetId])) return null;
|
||||||
|
if (!body.data) throw Object.assign(new Error('Photo data is required'), { status: 400 });
|
||||||
|
const result = run(
|
||||||
|
'INSERT INTO asset_photos (asset_id, name, mime_type, caption, data_base64, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[assetId, body.name || null, body.mime || body.mime_type || 'image/jpeg', body.caption || null, body.data, userId, now()]
|
||||||
|
);
|
||||||
|
audit(userId, 'create', 'asset_photo', result.lastInsertRowid, null, { asset_id: assetId, name: body.name });
|
||||||
|
return one('SELECT id, name, mime_type, caption FROM asset_photos WHERE id = ?', [result.lastInsertRowid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAssetPhoto(assetId, photoId, body, userId) {
|
||||||
|
const result = run('UPDATE asset_photos SET caption = ? WHERE id = ? AND asset_id = ?', [body.caption || null, photoId, assetId]);
|
||||||
|
if (!result.changes) return null;
|
||||||
|
audit(userId, 'update', 'asset_photo', photoId, null, { asset_id: assetId });
|
||||||
|
return one('SELECT id, name, mime_type, caption FROM asset_photos WHERE id = ?', [photoId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteAssetPhoto(assetId, photoId, userId) {
|
||||||
|
const result = run('DELETE FROM asset_photos WHERE id = ? AND asset_id = ?', [photoId, assetId]);
|
||||||
|
if (!result.changes) return false;
|
||||||
|
audit(userId, 'delete', 'asset_photo', photoId, null, { asset_id: assetId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function hydrateAsset(id) {
|
function hydrateAsset(id) {
|
||||||
const asset = assetFromRow(one('SELECT * FROM assets WHERE id = ?', [id]));
|
const asset = assetFromRow(one('SELECT * FROM assets WHERE id = ?', [id]));
|
||||||
if (!asset) return null;
|
if (!asset) return null;
|
||||||
@@ -60,9 +103,13 @@ function hydrateAsset(id) {
|
|||||||
manual_depreciation: parseJson(book.manual_depreciation, {})
|
manual_depreciation: parseJson(book.manual_depreciation, {})
|
||||||
}));
|
}));
|
||||||
asset.leases = all('SELECT * FROM leases WHERE asset_id = ? ORDER BY start_date DESC', [id]);
|
asset.leases = all('SELECT * FROM leases WHERE asset_id = ? ORDER BY start_date DESC', [id]);
|
||||||
|
asset.disposals = all('SELECT * FROM disposals WHERE asset_id = ? ORDER BY disposal_date DESC, id DESC', [id]);
|
||||||
|
asset.adjustments = all('SELECT * FROM asset_adjustments WHERE asset_id = ? ORDER BY adjustment_date DESC, id DESC', [id]);
|
||||||
|
asset.pm = assetPmRows(id);
|
||||||
asset.tasks = all('SELECT * FROM tasks WHERE asset_id = ? ORDER BY due_date IS NULL, due_date', [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.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]);
|
asset.files = all('SELECT id, file_name, mime_type, size, uploaded_at FROM asset_files WHERE asset_id = ?', [id]);
|
||||||
|
asset.photos = assetPhotos(id);
|
||||||
asset.notes_log = all(
|
asset.notes_log = all(
|
||||||
`SELECT n.id, n.body, n.created_at, u.name AS author
|
`SELECT n.id, n.body, n.created_at, u.name AS author
|
||||||
FROM asset_notes n
|
FROM asset_notes n
|
||||||
@@ -74,12 +121,75 @@ function hydrateAsset(id) {
|
|||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lookupAssetByCode(code) {
|
||||||
|
const value = String(code || '').trim();
|
||||||
|
if (!value) return null;
|
||||||
|
const row = one(
|
||||||
|
`SELECT id FROM assets
|
||||||
|
WHERE barcode_value = ? COLLATE NOCASE
|
||||||
|
OR asset_id = ? COLLATE NOCASE
|
||||||
|
OR serial_number = ? COLLATE NOCASE
|
||||||
|
ORDER BY (barcode_value = ? COLLATE NOCASE) DESC, id
|
||||||
|
LIMIT 1`,
|
||||||
|
[value, value, value, value]
|
||||||
|
);
|
||||||
|
return row ? hydrateAsset(row.id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
function nextAssetId() {
|
function nextAssetId() {
|
||||||
const latest = one("SELECT asset_id FROM assets WHERE asset_id LIKE 'MA-%' ORDER BY id DESC LIMIT 1");
|
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;
|
const number = latest ? Number(String(latest.asset_id).replace('MA-', '')) + 1 : 1;
|
||||||
return `MA-${String(number).padStart(5, '0')}`;
|
return `MA-${String(number).padStart(5, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Replace the first run of '#' in a pattern with the zero-padded number.
|
||||||
|
// e.g. formatAssetId('DT-######', 42) -> 'DT-000042'
|
||||||
|
function formatAssetId(pattern, n) {
|
||||||
|
return String(pattern).replace(/#+/, (hashes) => String(n).padStart(hashes.length, '0'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the asset's category has an assigned ID template, generate the next unique tag from it
|
||||||
|
// and advance the template's counter. Returns null when the category has no template.
|
||||||
|
function nextAssetIdFromCategory(categoryName) {
|
||||||
|
if (!categoryName) return null;
|
||||||
|
let templateId = null;
|
||||||
|
try {
|
||||||
|
const category = one("SELECT metadata FROM reference_values WHERE type = 'category' AND name = ?", [categoryName]);
|
||||||
|
templateId = category ? parseJson(category.metadata, {}).id_template_id : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!templateId) return null;
|
||||||
|
const template = one('SELECT * FROM asset_id_templates WHERE id = ?', [templateId]);
|
||||||
|
if (!template || !/#/.test(template.pattern || '')) return null;
|
||||||
|
let n = Number(template.next_number) || 1;
|
||||||
|
let candidate = formatAssetId(template.pattern, n);
|
||||||
|
while (one('SELECT id FROM assets WHERE asset_id = ?', [candidate])) {
|
||||||
|
n += 1;
|
||||||
|
candidate = formatAssetId(template.pattern, n);
|
||||||
|
}
|
||||||
|
run('UPDATE asset_id_templates SET next_number = ?, updated_at = ? WHERE id = ?', [n + 1, now(), templateId]);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the asset tag for a new asset: an explicit value wins; otherwise use the
|
||||||
|
// category's ID template, falling back to the default MA-##### sequence.
|
||||||
|
function resolveNewAssetId(input) {
|
||||||
|
if (input.asset_id) return input.asset_id;
|
||||||
|
return nextAssetIdFromCategory(input.category) || nextAssetId();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Asset Class Number is a property of the category — every asset in the category shares it.
|
||||||
|
function assetClassNumberForCategory(categoryName) {
|
||||||
|
if (!categoryName) return null;
|
||||||
|
try {
|
||||||
|
const category = one("SELECT metadata FROM reference_values WHERE type = 'category' AND name = ?", [categoryName]);
|
||||||
|
return category ? (parseJson(category.metadata, {}).asset_class_number || null) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAssetInput(body) {
|
function normalizeAssetInput(body) {
|
||||||
const input = {};
|
const input = {};
|
||||||
for (const column of assetColumns) {
|
for (const column of assetColumns) {
|
||||||
@@ -88,7 +198,7 @@ function normalizeAssetInput(body) {
|
|||||||
input.description = input.description || body.name || 'Untitled asset';
|
input.description = input.description || body.name || 'Untitled asset';
|
||||||
input.category = input.category || 'Uncategorized';
|
input.category = input.category || 'Uncategorized';
|
||||||
input.status = input.status || 'in_service';
|
input.status = input.status || 'in_service';
|
||||||
input.asset_id = input.asset_id || nextAssetId();
|
input.asset_id = input.asset_id || null; // resolved by the caller (template or default sequence)
|
||||||
input.entity_id = input.entity_id || one('SELECT id FROM entities ORDER BY id LIMIT 1')?.id;
|
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.acquisition_cost = Number(input.acquisition_cost || 0);
|
||||||
input.useful_life_months = Number(input.useful_life_months || 60);
|
input.useful_life_months = Number(input.useful_life_months || 60);
|
||||||
@@ -103,7 +213,7 @@ function normalizeAssetInput(body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createBooks(assetId, asset, books = []) {
|
function createBooks(assetId, asset, books = []) {
|
||||||
const selectedBooks = books.length ? books : defaultBooks.map((book) => ({ book_type: book }));
|
const selectedBooks = books.length ? books : enabledBookCodes().map((book) => ({ book_type: book }));
|
||||||
for (const book of selectedBooks) {
|
for (const book of selectedBooks) {
|
||||||
run(
|
run(
|
||||||
`INSERT OR REPLACE INTO asset_books (
|
`INSERT OR REPLACE INTO asset_books (
|
||||||
@@ -156,12 +266,14 @@ function createAsset(body, userId) {
|
|||||||
const created = now();
|
const created = now();
|
||||||
const input = normalizeAssetInput(body);
|
const input = normalizeAssetInput(body);
|
||||||
const result = tx(() => {
|
const result = tx(() => {
|
||||||
|
input.asset_id = resolveNewAssetId(input); // category ID template, else default sequence
|
||||||
const insert = run(
|
const insert = run(
|
||||||
`INSERT INTO assets (${assetColumns.join(', ')}, created_by, created_at, updated_at)
|
`INSERT INTO assets (${assetColumns.join(', ')}, created_by, created_at, updated_at)
|
||||||
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
|
VALUES (${assetColumns.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||||
[...assetColumns.map((column) => input[column] ?? null), userId, created, created]
|
[...assetColumns.map((column) => input[column] ?? null), userId, created, created]
|
||||||
);
|
);
|
||||||
createBooks(insert.lastInsertRowid, input, body.books || []);
|
createBooks(insert.lastInsertRowid, input, body.books || []);
|
||||||
|
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), insert.lastInsertRowid]);
|
||||||
return insert;
|
return insert;
|
||||||
});
|
});
|
||||||
const asset = hydrateAsset(result.lastInsertRowid);
|
const asset = hydrateAsset(result.lastInsertRowid);
|
||||||
@@ -174,6 +286,10 @@ function updateAsset(id, body, userId) {
|
|||||||
if (!before) return null;
|
if (!before) return null;
|
||||||
|
|
||||||
const input = normalizeAssetInput({ ...before, ...body });
|
const input = normalizeAssetInput({ ...before, ...body });
|
||||||
|
// When depreciation-critical fields change, the caller chooses when the change applies.
|
||||||
|
// 'placed_in_service' rebuilds the whole schedule by clearing prior depreciation;
|
||||||
|
// 'going_forward' keeps depreciation already recorded (the stored prior depreciation).
|
||||||
|
if (body.depreciation_apply === 'placed_in_service') input.prior_depreciation = 0;
|
||||||
const updated = now();
|
const updated = now();
|
||||||
tx(() => {
|
tx(() => {
|
||||||
run(
|
run(
|
||||||
@@ -181,6 +297,7 @@ function updateAsset(id, body, userId) {
|
|||||||
[...assetColumns.map((column) => input[column] ?? null), updated, id]
|
[...assetColumns.map((column) => input[column] ?? null), updated, id]
|
||||||
);
|
);
|
||||||
if (Array.isArray(body.books)) createBooks(id, input, body.books);
|
if (Array.isArray(body.books)) createBooks(id, input, body.books);
|
||||||
|
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [assetClassNumberForCategory(input.category), id]);
|
||||||
});
|
});
|
||||||
|
|
||||||
const asset = hydrateAsset(id);
|
const asset = hydrateAsset(id);
|
||||||
@@ -231,11 +348,13 @@ function upsertImportedAssets(rows, userId) {
|
|||||||
tx(() => {
|
tx(() => {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const input = normalizeAssetInput(row);
|
const input = normalizeAssetInput(row);
|
||||||
|
if (!input.asset_id) input.asset_id = resolveNewAssetId(input);
|
||||||
const existing = one('SELECT id FROM assets WHERE asset_id = ?', [input.asset_id]);
|
const existing = one('SELECT id FROM assets WHERE asset_id = ?', [input.asset_id]);
|
||||||
|
const classNumber = assetClassNumberForCategory(input.category);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
run(
|
run(
|
||||||
`UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
|
`UPDATE assets SET ${assetColumns.map((column) => `${column} = ?`).join(', ')}, asset_class_number = ?, updated_at = ? WHERE id = ?`,
|
||||||
[...assetColumns.map((column) => input[column] ?? null), now(), existing.id]
|
[...assetColumns.map((column) => input[column] ?? null), classNumber, now(), existing.id]
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const result = run(
|
const result = run(
|
||||||
@@ -244,6 +363,7 @@ function upsertImportedAssets(rows, userId) {
|
|||||||
[...assetColumns.map((column) => input[column] ?? null), userId, now(), now()]
|
[...assetColumns.map((column) => input[column] ?? null), userId, now(), now()]
|
||||||
);
|
);
|
||||||
createBooks(result.lastInsertRowid, input, []);
|
createBooks(result.lastInsertRowid, input, []);
|
||||||
|
run('UPDATE assets SET asset_class_number = ? WHERE id = ?', [classNumber, result.lastInsertRowid]);
|
||||||
}
|
}
|
||||||
imported += 1;
|
imported += 1;
|
||||||
}
|
}
|
||||||
@@ -252,6 +372,7 @@ function upsertImportedAssets(rows, userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
addAssetPhoto,
|
||||||
assetColumns,
|
assetColumns,
|
||||||
assetExportRows,
|
assetExportRows,
|
||||||
assetFromRow,
|
assetFromRow,
|
||||||
@@ -259,8 +380,12 @@ module.exports = {
|
|||||||
createBooks,
|
createBooks,
|
||||||
defaultBooks,
|
defaultBooks,
|
||||||
deleteAsset,
|
deleteAsset,
|
||||||
|
deleteAssetPhoto,
|
||||||
|
formatAssetId,
|
||||||
hydrateAsset,
|
hydrateAsset,
|
||||||
|
updateAssetPhoto,
|
||||||
listAssets,
|
listAssets,
|
||||||
|
lookupAssetByCode,
|
||||||
massUpdateAssets,
|
massUpdateAssets,
|
||||||
normalizeAssetInput,
|
normalizeAssetInput,
|
||||||
upsertImportedAssets,
|
upsertImportedAssets,
|
||||||
|
|||||||
10
server/services/assets.test.js
Normal file
10
server/services/assets.test.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
let assets = require('./assets')
|
||||||
|
|
||||||
|
describe('assets', () => {
|
||||||
|
test('looks up asset by code correctly', () => {
|
||||||
|
const asset = assets.lookupAssetByCode('TESTCODE123');
|
||||||
|
expect(asset).toBeDefined();
|
||||||
|
expect(asset.asset_id).toBe(1);
|
||||||
|
expect(asset.code).toBe('TESTCODE123');
|
||||||
|
});
|
||||||
|
});
|
||||||
41
server/services/assignments.test.js
Normal file
41
server/services/assignments.test.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
let assignments = require('./assignments')
|
||||||
|
|
||||||
|
describe('assignments', () => {
|
||||||
|
test('assigns asset to employee correctly', () => {
|
||||||
|
const body = {
|
||||||
|
asset_id: 1,
|
||||||
|
employee_id: 1,
|
||||||
|
department: 'IT',
|
||||||
|
location: 'Headquarters',
|
||||||
|
notes: 'Assigned for testing'
|
||||||
|
};
|
||||||
|
const userId = 1;
|
||||||
|
const assignment = assignments.assignAsset(body, userId);
|
||||||
|
expect(assignment).toBeDefined();
|
||||||
|
expect(assignment.asset_id).toBe(body.asset_id);
|
||||||
|
expect(assignment.employee_id).toBe(body.employee_id);
|
||||||
|
expect(assignment.department).toBe(body.department);
|
||||||
|
expect(assignment.location).toBe(body.location);
|
||||||
|
expect(assignment.status).toBe('assigned');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('releases asset assignment correctly', () => {
|
||||||
|
const body = {
|
||||||
|
release_reason: 'No longer needed',
|
||||||
|
notes: 'Released after testing'
|
||||||
|
};
|
||||||
|
const userId = 1;
|
||||||
|
const assignment = assignments.assignAsset({
|
||||||
|
asset_id: 1,
|
||||||
|
employee_id: 1,
|
||||||
|
department: 'IT',
|
||||||
|
location: 'Headquarters'
|
||||||
|
}, userId);
|
||||||
|
const released = assignments.releaseAssignment(assignment.id, body, userId);
|
||||||
|
expect(released).toBeDefined();
|
||||||
|
expect(released.id).toBe(assignment.id);
|
||||||
|
expect(released.status).toBe('released');
|
||||||
|
expect(released.release_reason).toBe(body.release_reason);
|
||||||
|
expect(released.notes).toBe(body.notes);
|
||||||
|
});
|
||||||
|
});
|
||||||
221
server/services/books.js
Normal file
221
server/services/books.js
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
const { all, audit, now, one, run } = require('../db');
|
||||||
|
const { money } = require('../depreciation');
|
||||||
|
const { ruleSetForBook } = require('./ruleSets');
|
||||||
|
const { computeYear } = require('./reportEngine');
|
||||||
|
const { assetFromRow } = require('./assets');
|
||||||
|
const { pmBookLedger } = require('./pm');
|
||||||
|
|
||||||
|
const DEFAULT_ACCOUNTS = {
|
||||||
|
asset: '1500',
|
||||||
|
accumulated: '1590',
|
||||||
|
expense: '6400'
|
||||||
|
};
|
||||||
|
|
||||||
|
function bookFromRow(row) {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
enabled: Boolean(row.enabled),
|
||||||
|
is_primary: Boolean(row.is_primary)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listBooks() {
|
||||||
|
const books = all(
|
||||||
|
`SELECT b.*, t.name AS tax_rule_set_name, t.version AS tax_rule_set_version,
|
||||||
|
(SELECT COUNT(*) FROM asset_books ab WHERE ab.book_type = b.code AND ab.active = 1) AS asset_count
|
||||||
|
FROM books b
|
||||||
|
LEFT JOIN tax_rule_sets t ON t.id = b.tax_rule_set_id
|
||||||
|
ORDER BY b.sort_order, b.id`
|
||||||
|
).map(bookFromRow);
|
||||||
|
const ruleSets = all('SELECT id, name, jurisdiction, version, active FROM tax_rule_sets ORDER BY active DESC, name')
|
||||||
|
.map((row) => ({ ...row, active: Boolean(row.active) }));
|
||||||
|
return { books, ruleSets };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBook(code) {
|
||||||
|
return bookFromRow(one('SELECT * FROM books WHERE code = ? OR id = ?', [code, code]) || null) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBook(body, userId) {
|
||||||
|
const code = String(body.code || '').trim().toUpperCase().replace(/[^A-Z0-9_]/g, '').slice(0, 20);
|
||||||
|
if (!code) throw Object.assign(new Error('Book code is required'), { status: 400 });
|
||||||
|
if (one('SELECT id FROM books WHERE code = ?', [code])) {
|
||||||
|
throw Object.assign(new Error(`A book with code "${code}" already exists`), { status: 400 });
|
||||||
|
}
|
||||||
|
const ts = now();
|
||||||
|
const next = one('SELECT MAX(sort_order) AS max FROM books');
|
||||||
|
const result = run(
|
||||||
|
`INSERT INTO books (code, name, description, book_type, enabled, is_primary, tax_rule_set_id, default_method, default_convention, fiscal_year_end_month, sort_order, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
code,
|
||||||
|
body.name || code,
|
||||||
|
body.description || null,
|
||||||
|
['financial', 'tax', 'internal'].includes(body.book_type) ? body.book_type : 'internal',
|
||||||
|
body.enabled === false ? 0 : 1,
|
||||||
|
body.tax_rule_set_id || null,
|
||||||
|
body.default_method || 'straight_line',
|
||||||
|
body.default_convention || 'half_year',
|
||||||
|
Number(body.fiscal_year_end_month) || 12,
|
||||||
|
(next?.max || 0) + 1,
|
||||||
|
ts, ts
|
||||||
|
]
|
||||||
|
);
|
||||||
|
audit(userId, 'create', 'book', result.lastInsertRowid, null, { code, name: body.name });
|
||||||
|
return bookFromRow(one('SELECT * FROM books WHERE id = ?', [result.lastInsertRowid]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteBook(id, userId) {
|
||||||
|
const before = one('SELECT * FROM books WHERE id = ?', [id]);
|
||||||
|
if (!before) return false;
|
||||||
|
if (before.is_primary) throw Object.assign(new Error('The primary book cannot be deleted'), { status: 400 });
|
||||||
|
if (before.book_type === 'maintenance') throw Object.assign(new Error('The PM book cannot be deleted'), { status: 400 });
|
||||||
|
const used = one('SELECT COUNT(*) AS count FROM asset_books WHERE book_type = ?', [before.code]).count;
|
||||||
|
if (used) throw Object.assign(new Error(`This book is used by ${used} asset record(s); disable it instead`), { status: 400 });
|
||||||
|
run('DELETE FROM books WHERE id = ?', [id]);
|
||||||
|
audit(userId, 'delete', 'book', id, before, null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBook(id, body, userId) {
|
||||||
|
const before = one('SELECT * FROM books WHERE id = ?', [id]);
|
||||||
|
if (!before) return null;
|
||||||
|
run(
|
||||||
|
`UPDATE books SET
|
||||||
|
name = ?, description = ?, book_type = ?, enabled = ?, tax_rule_set_id = ?,
|
||||||
|
default_method = ?, default_convention = ?, fiscal_year_end_month = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[
|
||||||
|
body.name ?? before.name,
|
||||||
|
body.description ?? before.description,
|
||||||
|
body.book_type ?? before.book_type,
|
||||||
|
body.enabled === undefined ? before.enabled : (body.enabled ? 1 : 0),
|
||||||
|
body.tax_rule_set_id === undefined ? before.tax_rule_set_id : (body.tax_rule_set_id || null),
|
||||||
|
body.default_method ?? before.default_method,
|
||||||
|
body.default_convention ?? before.default_convention,
|
||||||
|
body.fiscal_year_end_month ?? before.fiscal_year_end_month,
|
||||||
|
now(),
|
||||||
|
id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
audit(userId, 'update', 'book', id, before, body);
|
||||||
|
return bookFromRow(one('SELECT * FROM books WHERE id = ?', [id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// General-ledger view of a book: per-asset basis, accumulated depreciation and
|
||||||
|
// current-year expense rolled up to GL accounts, plus an asset-level detail.
|
||||||
|
function bookLedger(code, year) {
|
||||||
|
const book = getBook(code);
|
||||||
|
if (!book) return null;
|
||||||
|
if (book.book_type === 'maintenance') {
|
||||||
|
return { ...pmBookLedger(year), book };
|
||||||
|
}
|
||||||
|
const rules = ruleSetForBook(code);
|
||||||
|
const rows = all(
|
||||||
|
`SELECT a.*, b.cost AS book_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_bup, b.investment_use_percent AS book_ivp, b.manual_depreciation
|
||||||
|
FROM assets a JOIN asset_books b ON b.asset_id = a.id
|
||||||
|
WHERE b.book_type = ? AND b.active = 1
|
||||||
|
ORDER BY a.asset_id`,
|
||||||
|
[code]
|
||||||
|
);
|
||||||
|
|
||||||
|
const accountMap = new Map();
|
||||||
|
const addLine = (account, type, debit, credit) => {
|
||||||
|
const key = `${type}::${account}`;
|
||||||
|
const entry = accountMap.get(key) || { account, type, debit: 0, credit: 0 };
|
||||||
|
entry.debit += debit;
|
||||||
|
entry.credit += credit;
|
||||||
|
accountMap.set(key, entry);
|
||||||
|
};
|
||||||
|
|
||||||
|
let totalCost = 0;
|
||||||
|
let totalAccum = 0;
|
||||||
|
let totalDepr = 0;
|
||||||
|
const assets = rows.map((row) => {
|
||||||
|
const asset = assetFromRow(row);
|
||||||
|
const bk = {
|
||||||
|
book_type: code,
|
||||||
|
cost: row.book_cost,
|
||||||
|
depreciation_method: row.depreciation_method,
|
||||||
|
convention: row.convention,
|
||||||
|
useful_life_months: row.book_life,
|
||||||
|
section_179_amount: row.section_179_amount,
|
||||||
|
bonus_percent: row.bonus_percent,
|
||||||
|
business_use_percent: row.book_bup,
|
||||||
|
investment_use_percent: row.book_ivp,
|
||||||
|
manual_depreciation: row.manual_depreciation
|
||||||
|
};
|
||||||
|
const c = computeYear(asset, bk, rules, year);
|
||||||
|
const assetAccount = asset.gl_asset_account || DEFAULT_ACCOUNTS.asset;
|
||||||
|
const accumAccount = asset.gl_accumulated_account || DEFAULT_ACCOUNTS.accumulated;
|
||||||
|
const expenseAccount = asset.gl_expense_account || DEFAULT_ACCOUNTS.expense;
|
||||||
|
|
||||||
|
addLine(assetAccount, 'Asset cost', c.cost, 0);
|
||||||
|
addLine(accumAccount, 'Accumulated depreciation', 0, c.accumulated_end);
|
||||||
|
if (c.depreciation) addLine(expenseAccount, 'Depreciation expense', c.depreciation, 0);
|
||||||
|
|
||||||
|
totalCost += c.cost;
|
||||||
|
totalAccum += c.accumulated_end;
|
||||||
|
totalDepr += c.depreciation;
|
||||||
|
|
||||||
|
return {
|
||||||
|
asset_id: asset.asset_id,
|
||||||
|
description: asset.description,
|
||||||
|
asset_account: assetAccount,
|
||||||
|
accumulated_account: accumAccount,
|
||||||
|
expense_account: expenseAccount,
|
||||||
|
cost: c.cost,
|
||||||
|
depreciation: c.depreciation,
|
||||||
|
accumulated: c.accumulated_end,
|
||||||
|
nbv: c.nbv_end
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const accounts = [...accountMap.values()]
|
||||||
|
.map((entry) => ({ account: entry.account, type: entry.type, debit: money(entry.debit), credit: money(entry.credit) }))
|
||||||
|
.sort((a, b) => a.account.localeCompare(b.account) || a.type.localeCompare(b.type));
|
||||||
|
|
||||||
|
return {
|
||||||
|
book,
|
||||||
|
year,
|
||||||
|
rule_set: { id: book.tax_rule_set_id, name: book.tax_rule_set_name, version: book.tax_rule_set_version },
|
||||||
|
summary: {
|
||||||
|
assets: assets.length,
|
||||||
|
cost: money(totalCost),
|
||||||
|
accumulated: money(totalAccum),
|
||||||
|
depreciation: money(totalDepr),
|
||||||
|
nbv: money(totalCost - totalAccum)
|
||||||
|
},
|
||||||
|
accounts,
|
||||||
|
accountTotals: { debit: money(accounts.reduce((s, a) => s + a.debit, 0)), credit: money(accounts.reduce((s, a) => s + a.credit, 0)) },
|
||||||
|
assets
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shape the ledger account rollup as a generic report for PDF/XLSX/CSV export.
|
||||||
|
function ledgerReport(code, year) {
|
||||||
|
const ledger = bookLedger(code, year);
|
||||||
|
if (!ledger) return null;
|
||||||
|
return {
|
||||||
|
title: `${ledger.book.name} general ledger — ${year}`,
|
||||||
|
generatedAt: now(),
|
||||||
|
options: { book: code, year },
|
||||||
|
columns: [
|
||||||
|
{ key: 'account', label: 'GL account', format: 'text' },
|
||||||
|
{ key: 'type', label: 'Account type', format: 'text' },
|
||||||
|
{ key: 'debit', label: 'Debit', format: 'currency' },
|
||||||
|
{ key: 'credit', label: 'Credit', format: 'currency' }
|
||||||
|
],
|
||||||
|
rows: ledger.accounts,
|
||||||
|
totals: ledger.accountTotals,
|
||||||
|
meta: {
|
||||||
|
note: ledger.kind === 'maintenance'
|
||||||
|
? `${ledger.summary.assets} asset(s) · ${ledger.summary.completions} service(s) · total PM spend ${ledger.summary.cost}`
|
||||||
|
: `Cost ${ledger.summary.cost} · Accumulated ${ledger.summary.accumulated} · NBV ${ledger.summary.nbv} · ${year} depreciation ${ledger.summary.depreciation}`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { bookLedger, createBook, deleteBook, getBook, ledgerReport, listBooks, updateBook };
|
||||||
175
server/services/contacts.js
Normal file
175
server/services/contacts.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
const { all, audit, json, now, one, parseJson, run } = require('../db');
|
||||||
|
|
||||||
|
const TYPES = ['employee', 'vendor', 'service_provider', 'contractor', 'work_location', 'other'];
|
||||||
|
const CREDIT_TERMS = ['na', 'net_30', 'net_60', 'net_90', 'net_180'];
|
||||||
|
|
||||||
|
const COLUMNS = [
|
||||||
|
'type', 'first_name', 'last_name', 'organization', 'email', 'phone',
|
||||||
|
'address_line1', 'address_line2', 'city', 'state_region', 'postal_code', 'country',
|
||||||
|
'notes', 'status', 'employee_id', 'tax_id', 'department', 'work_location',
|
||||||
|
'manager_first_name', 'manager_last_name', 'manager_email', 'start_date',
|
||||||
|
'rating', 'credit_term', 'source', 'workday_worker_id'
|
||||||
|
];
|
||||||
|
|
||||||
|
function contactNotes(contactId) {
|
||||||
|
return all(
|
||||||
|
`SELECT n.id, n.body, n.created_at, u.name AS author
|
||||||
|
FROM contact_notes n LEFT JOIN users u ON u.id = n.created_by
|
||||||
|
WHERE n.contact_id = ? ORDER BY n.id DESC`,
|
||||||
|
[contactId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayName(row) {
|
||||||
|
const person = [row.first_name, row.last_name].filter(Boolean).join(' ').trim();
|
||||||
|
return row.organization || person || row.email || `Contact ${row.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRow(row, withNotes = false) {
|
||||||
|
if (!row) return null;
|
||||||
|
const contact = { ...row, display_name: displayName(row) };
|
||||||
|
if (withNotes) contact.contact_notes = contactNotes(row.id);
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(body) {
|
||||||
|
const input = {};
|
||||||
|
for (const column of COLUMNS) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, column)) input[column] = body[column];
|
||||||
|
}
|
||||||
|
input.type = TYPES.includes(input.type) ? input.type : 'other';
|
||||||
|
input.status = input.status || 'active';
|
||||||
|
input.source = input.source || 'manual';
|
||||||
|
if (input.credit_term && !CREDIT_TERMS.includes(input.credit_term)) input.credit_term = 'na';
|
||||||
|
if (input.rating != null && input.rating !== '') input.rating = Math.max(0, Math.min(5, Number(input.rating)));
|
||||||
|
else input.rating = null;
|
||||||
|
if (input.workday_worker_id === '') input.workday_worker_id = null;
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listContacts(query = {}) {
|
||||||
|
return all(
|
||||||
|
`SELECT * FROM contacts
|
||||||
|
WHERE (? IS NULL OR type = ?)
|
||||||
|
AND (? IS NULL OR status = ?)
|
||||||
|
AND (? IS NULL OR (
|
||||||
|
first_name LIKE '%' || ? || '%' OR last_name LIKE '%' || ? || '%' OR
|
||||||
|
organization LIKE '%' || ? || '%' OR email LIKE '%' || ? || '%' OR
|
||||||
|
employee_id LIKE '%' || ? || '%'
|
||||||
|
))
|
||||||
|
ORDER BY type, organization, last_name, first_name`,
|
||||||
|
[
|
||||||
|
query.type || null, query.type || null,
|
||||||
|
query.status || null, query.status || null,
|
||||||
|
query.search || null, query.search || null, query.search || null,
|
||||||
|
query.search || null, query.search || null, query.search || null
|
||||||
|
]
|
||||||
|
).map((row) => fromRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContact(id) {
|
||||||
|
return fromRow(one('SELECT * FROM contacts WHERE id = ?', [id]), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createContact(body, userId) {
|
||||||
|
const input = normalize(body);
|
||||||
|
const ts = now();
|
||||||
|
const cols = COLUMNS.filter((c) => input[c] !== undefined);
|
||||||
|
const result = run(
|
||||||
|
`INSERT INTO contacts (${cols.join(', ')}, created_by, created_at, updated_at)
|
||||||
|
VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||||
|
[...cols.map((c) => input[c]), userId, ts, ts]
|
||||||
|
);
|
||||||
|
audit(userId, 'create', 'contact', result.lastInsertRowid, null, { type: input.type, name: displayName(input) });
|
||||||
|
return getContact(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateContact(id, body, userId) {
|
||||||
|
const before = one('SELECT * FROM contacts WHERE id = ?', [id]);
|
||||||
|
if (!before) return null;
|
||||||
|
const input = normalize({ ...before, ...body });
|
||||||
|
run(
|
||||||
|
`UPDATE contacts SET ${COLUMNS.map((c) => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
|
||||||
|
[...COLUMNS.map((c) => input[c] ?? null), now(), id]
|
||||||
|
);
|
||||||
|
audit(userId, 'update', 'contact', id, before, body);
|
||||||
|
return getContact(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteContact(id, userId) {
|
||||||
|
const before = one('SELECT * FROM contacts WHERE id = ?', [id]);
|
||||||
|
if (!before) return false;
|
||||||
|
run('DELETE FROM contacts WHERE id = ?', [id]);
|
||||||
|
audit(userId, 'delete', 'contact', id, before, null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNote(contactId, bodyText, userId) {
|
||||||
|
if (!one('SELECT id FROM contacts WHERE id = ?', [contactId])) return null;
|
||||||
|
const result = run('INSERT INTO contact_notes (contact_id, body, created_by, created_at) VALUES (?, ?, ?, ?)', [contactId, bodyText, userId, now()]);
|
||||||
|
audit(userId, 'create', 'contact_note', result.lastInsertRowid, null, { contact_id: contactId });
|
||||||
|
return one('SELECT n.id, n.body, n.created_at, u.name AS author FROM contact_notes n LEFT JOIN users u ON u.id = n.created_by WHERE n.id = ?', [result.lastInsertRowid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteNote(contactId, noteId, userId) {
|
||||||
|
const result = run('DELETE FROM contact_notes WHERE id = ? AND contact_id = ?', [noteId, contactId]);
|
||||||
|
if (!result.changes) return false;
|
||||||
|
audit(userId, 'delete', 'contact_note', noteId, null, { contact_id: contactId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert an employee contact from a normalized Workday worker record.
|
||||||
|
function upsertWorkdayContact(worker) {
|
||||||
|
const ts = now();
|
||||||
|
const existing = worker.workday_worker_id
|
||||||
|
? one('SELECT id FROM contacts WHERE workday_worker_id = ?', [worker.workday_worker_id])
|
||||||
|
: (worker.email ? one('SELECT id FROM contacts WHERE type = ? AND lower(email) = lower(?)', ['employee', worker.email]) : null);
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
type: 'employee',
|
||||||
|
first_name: worker.first_name || null,
|
||||||
|
last_name: worker.last_name || null,
|
||||||
|
organization: null,
|
||||||
|
email: worker.email || null,
|
||||||
|
department: worker.department || null,
|
||||||
|
work_location: worker.location || null,
|
||||||
|
employee_id: worker.employee_number || null,
|
||||||
|
manager_first_name: worker.manager_first_name || null,
|
||||||
|
manager_last_name: worker.manager_last_name || null,
|
||||||
|
manager_email: worker.manager_email || null,
|
||||||
|
start_date: worker.start_date || null,
|
||||||
|
status: worker.status || 'active',
|
||||||
|
source: 'workday',
|
||||||
|
workday_worker_id: worker.workday_worker_id || null,
|
||||||
|
source_payload: json(worker.source_payload || worker)
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const cols = Object.keys(fields);
|
||||||
|
run(
|
||||||
|
`UPDATE contacts SET ${cols.map((c) => `${c} = COALESCE(?, ${c})`).join(', ')}, last_synced_at = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
[...cols.map((c) => fields[c]), ts, ts, existing.id]
|
||||||
|
);
|
||||||
|
return existing.id;
|
||||||
|
}
|
||||||
|
const cols = Object.keys(fields);
|
||||||
|
const result = run(
|
||||||
|
`INSERT INTO contacts (${cols.join(', ')}, last_synced_at, created_at, updated_at) VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||||
|
[...cols.map((c) => fields[c]), ts, ts, ts]
|
||||||
|
);
|
||||||
|
return result.lastInsertRowid;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CREDIT_TERMS,
|
||||||
|
TYPES,
|
||||||
|
addNote,
|
||||||
|
createContact,
|
||||||
|
deleteContact,
|
||||||
|
deleteNote,
|
||||||
|
getContact,
|
||||||
|
listContacts,
|
||||||
|
parsePayload: (value) => parseJson(value, null),
|
||||||
|
updateContact,
|
||||||
|
upsertWorkdayContact
|
||||||
|
};
|
||||||
187
server/services/disposals.js
Normal file
187
server/services/disposals.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
const { all, audit, now, one, run, tx } = require('../db');
|
||||||
|
const { annualSchedule, money } = require('../depreciation');
|
||||||
|
const { ruleSetForBook } = require('./ruleSets');
|
||||||
|
const { hydrateAsset } = require('./assets');
|
||||||
|
|
||||||
|
const DISPOSAL_METHODS = ['sale', 'exchange', 'involuntary_conversion', 'like_kind', 'abandonment'];
|
||||||
|
|
||||||
|
function yearOf(value) {
|
||||||
|
if (!value) return new Date().getFullYear();
|
||||||
|
return new Date(`${value}T00:00:00`).getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function netAdjustments(assetId, bookType) {
|
||||||
|
// Impairments and write-downs reduce carrying value; write-ups/revaluations restore it.
|
||||||
|
const rows = all('SELECT type, amount FROM asset_adjustments WHERE asset_id = ? AND book_type = ?', [assetId, bookType]);
|
||||||
|
return rows.reduce((sum, row) => {
|
||||||
|
const sign = row.type === 'write_up' || row.type === 'revaluation' ? -1 : 1;
|
||||||
|
return sum + sign * Number(row.amount || 0);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookFor(asset, bookType) {
|
||||||
|
return asset.books.find((book) => book.book_type === bookType) || asset.books[0] || { book_type: bookType };
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDisposal(asset, input = {}) {
|
||||||
|
const bookType = input.book_type || 'GAAP';
|
||||||
|
const book = bookFor(asset, bookType);
|
||||||
|
const rules = ruleSetForBook(bookType);
|
||||||
|
const disposalDate = input.disposal_date || new Date().toISOString().slice(0, 10);
|
||||||
|
const year = yearOf(disposalDate);
|
||||||
|
|
||||||
|
const totalCost = Number(book.cost ?? asset.acquisition_cost ?? 0);
|
||||||
|
const partial = Boolean(input.partial);
|
||||||
|
const disposedCost = partial ? Math.min(totalCost, Number(input.disposed_cost || 0)) : totalCost;
|
||||||
|
const proportion = totalCost > 0 ? disposedCost / totalCost : 1;
|
||||||
|
|
||||||
|
const row = annualSchedule(asset, book, rules, { startYear: year, endYear: year })[0] || {};
|
||||||
|
const accumulatedFull = Number(row.accumulated || 0);
|
||||||
|
const nbvFull = row.netBookValue != null ? Number(row.netBookValue) : Math.max(0, totalCost - accumulatedFull);
|
||||||
|
|
||||||
|
const accumulated = money(accumulatedFull * proportion);
|
||||||
|
const impairment = money(netAdjustments(asset.id, bookType) * proportion);
|
||||||
|
const netBookValue = money(Math.max(0, nbvFull * proportion - impairment));
|
||||||
|
|
||||||
|
const price = Number(input.disposal_price || 0);
|
||||||
|
const expense = Number(input.disposal_expense || 0);
|
||||||
|
const realized = money(price - expense);
|
||||||
|
const gainLoss = money(realized - netBookValue);
|
||||||
|
|
||||||
|
return {
|
||||||
|
book_type: bookType,
|
||||||
|
disposal_date: disposalDate,
|
||||||
|
method: DISPOSAL_METHODS.includes(input.method) ? input.method : 'sale',
|
||||||
|
property_type: input.property_type || asset.property_type || null,
|
||||||
|
partial: partial ? 1 : 0,
|
||||||
|
disposed_cost: money(disposedCost),
|
||||||
|
total_cost: money(totalCost),
|
||||||
|
disposal_price: money(price),
|
||||||
|
disposal_expense: money(expense),
|
||||||
|
accumulated_depreciation: accumulated,
|
||||||
|
impairment,
|
||||||
|
net_book_value: netBookValue,
|
||||||
|
realized,
|
||||||
|
gain_loss: gainLoss
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewDisposal(assetId, input) {
|
||||||
|
const asset = hydrateAsset(assetId);
|
||||||
|
if (!asset) return null;
|
||||||
|
return computeDisposal(asset, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordDisposal(assetId, input, userId) {
|
||||||
|
const asset = hydrateAsset(assetId);
|
||||||
|
if (!asset) return null;
|
||||||
|
const result = computeDisposal(asset, input);
|
||||||
|
const timestamp = now();
|
||||||
|
|
||||||
|
return tx(() => {
|
||||||
|
const inserted = run(
|
||||||
|
`INSERT INTO disposals (
|
||||||
|
asset_id, disposal_date, method, property_type, partial, disposed_cost,
|
||||||
|
disposal_price, disposal_expense, accumulated_depreciation, net_book_value,
|
||||||
|
gain_loss, book_type, notes, created_by, created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
assetId, result.disposal_date, result.method, result.property_type, result.partial,
|
||||||
|
result.disposed_cost, result.disposal_price, result.disposal_expense,
|
||||||
|
result.accumulated_depreciation, result.net_book_value, result.gain_loss,
|
||||||
|
result.book_type, input.notes || null, userId, timestamp
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.partial) {
|
||||||
|
const totalCost = result.total_cost;
|
||||||
|
const remainingProportion = totalCost > 0 ? Math.max(0, (totalCost - result.disposed_cost) / totalCost) : 1;
|
||||||
|
run('UPDATE assets SET acquisition_cost = acquisition_cost - ?, updated_at = ? WHERE id = ?', [
|
||||||
|
result.disposed_cost,
|
||||||
|
timestamp,
|
||||||
|
assetId
|
||||||
|
]);
|
||||||
|
run('UPDATE asset_books SET cost = cost * ? WHERE asset_id = ? AND cost IS NOT NULL', [remainingProportion, assetId]);
|
||||||
|
} else {
|
||||||
|
run(
|
||||||
|
`UPDATE assets SET
|
||||||
|
status = 'disposed', disposal_date = ?, disposal_price = ?, disposal_expense = ?,
|
||||||
|
disposal_type = ?, property_type = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[result.disposal_date, result.disposal_price, result.disposal_expense, result.method, result.property_type, timestamp, assetId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const disposal = one('SELECT * FROM disposals WHERE id = ?', [inserted.lastInsertRowid]);
|
||||||
|
audit(userId, 'dispose', 'asset', assetId, null, disposal);
|
||||||
|
return { disposal, asset: hydrateAsset(assetId) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reverseDisposal(assetId, disposalId, userId) {
|
||||||
|
const disposal = one('SELECT * FROM disposals WHERE id = ? AND asset_id = ?', [disposalId, assetId]);
|
||||||
|
if (!disposal) return null;
|
||||||
|
const timestamp = now();
|
||||||
|
|
||||||
|
return tx(() => {
|
||||||
|
run('DELETE FROM disposals WHERE id = ?', [disposalId]);
|
||||||
|
if (disposal.partial) {
|
||||||
|
run('UPDATE assets SET acquisition_cost = acquisition_cost + ?, updated_at = ? WHERE id = ?', [
|
||||||
|
disposal.disposed_cost,
|
||||||
|
timestamp,
|
||||||
|
assetId
|
||||||
|
]);
|
||||||
|
} else if (!one('SELECT id FROM disposals WHERE asset_id = ? AND partial = 0', [assetId])) {
|
||||||
|
run(
|
||||||
|
`UPDATE assets SET
|
||||||
|
status = 'in_service', disposal_date = NULL, disposal_price = NULL,
|
||||||
|
disposal_expense = NULL, disposal_type = NULL, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[timestamp, assetId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
audit(userId, 'reverse_disposal', 'asset', assetId, disposal, null);
|
||||||
|
return hydrateAsset(assetId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordAdjustment(assetId, input, userId) {
|
||||||
|
const asset = one('SELECT id FROM assets WHERE id = ?', [assetId]);
|
||||||
|
if (!asset) return null;
|
||||||
|
const timestamp = now();
|
||||||
|
const result = run(
|
||||||
|
`INSERT INTO asset_adjustments (asset_id, adjustment_date, type, amount, book_type, reason, created_by, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
assetId,
|
||||||
|
input.adjustment_date || timestamp.slice(0, 10),
|
||||||
|
input.type || 'impairment',
|
||||||
|
Number(input.amount || 0),
|
||||||
|
input.book_type || 'GAAP',
|
||||||
|
input.reason || null,
|
||||||
|
userId,
|
||||||
|
timestamp
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const adjustment = one('SELECT * FROM asset_adjustments WHERE id = ?', [result.lastInsertRowid]);
|
||||||
|
audit(userId, 'adjust', 'asset', assetId, null, adjustment);
|
||||||
|
return adjustment;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteAdjustment(assetId, adjustmentId, userId) {
|
||||||
|
const result = run('DELETE FROM asset_adjustments WHERE id = ? AND asset_id = ?', [adjustmentId, assetId]);
|
||||||
|
if (!result.changes) return false;
|
||||||
|
audit(userId, 'delete', 'asset_adjustment', adjustmentId, null, { asset_id: assetId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DISPOSAL_METHODS,
|
||||||
|
computeDisposal,
|
||||||
|
deleteAdjustment,
|
||||||
|
netAdjustments,
|
||||||
|
previewDisposal,
|
||||||
|
recordAdjustment,
|
||||||
|
recordDisposal,
|
||||||
|
reverseDisposal
|
||||||
|
};
|
||||||
146
server/services/leases.js
Normal file
146
server/services/leases.js
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
const { all, audit, now, one, run } = require('../db');
|
||||||
|
const { money } = require('../depreciation');
|
||||||
|
|
||||||
|
const FREQUENCIES = { monthly: 12, quarterly: 4, semiannual: 2, annual: 1 };
|
||||||
|
|
||||||
|
function periodsPerYear(frequency) {
|
||||||
|
return FREQUENCIES[frequency] || 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(date, count) {
|
||||||
|
const next = new Date(date.getTime());
|
||||||
|
next.setMonth(next.getMonth() + count);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listLeases(query = {}) {
|
||||||
|
return all(
|
||||||
|
`SELECT l.*, a.asset_id AS asset_code, a.description AS asset_description
|
||||||
|
FROM leases l
|
||||||
|
LEFT JOIN assets a ON a.id = l.asset_id
|
||||||
|
WHERE (? IS NULL OR l.asset_id = ?)
|
||||||
|
ORDER BY l.start_date DESC, l.id DESC`,
|
||||||
|
[query.asset_id || null, query.asset_id || null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLease(id) {
|
||||||
|
return one('SELECT * FROM leases WHERE id = ?', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLease(input, userId) {
|
||||||
|
const timestamp = now();
|
||||||
|
const result = run(
|
||||||
|
`INSERT INTO leases (asset_id, lessor, contract_value, start_date, end_date, discount_rate, payment_frequency, notes, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
input.asset_id || null,
|
||||||
|
input.lessor || null,
|
||||||
|
Number(input.contract_value || 0),
|
||||||
|
input.start_date || null,
|
||||||
|
input.end_date || null,
|
||||||
|
Number(input.discount_rate || 0),
|
||||||
|
input.payment_frequency || 'monthly',
|
||||||
|
input.notes || null,
|
||||||
|
timestamp,
|
||||||
|
timestamp
|
||||||
|
]
|
||||||
|
);
|
||||||
|
audit(userId, 'create', 'lease', result.lastInsertRowid, null, input);
|
||||||
|
return getLease(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLease(id, input, userId) {
|
||||||
|
const before = getLease(id);
|
||||||
|
if (!before) return null;
|
||||||
|
run(
|
||||||
|
`UPDATE leases SET
|
||||||
|
lessor = ?, contract_value = ?, start_date = ?, end_date = ?, discount_rate = ?,
|
||||||
|
payment_frequency = ?, notes = ?, updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[
|
||||||
|
input.lessor ?? before.lessor,
|
||||||
|
Number(input.contract_value ?? before.contract_value),
|
||||||
|
input.start_date ?? before.start_date,
|
||||||
|
input.end_date ?? before.end_date,
|
||||||
|
Number(input.discount_rate ?? before.discount_rate),
|
||||||
|
input.payment_frequency ?? before.payment_frequency,
|
||||||
|
input.notes ?? before.notes,
|
||||||
|
now(),
|
||||||
|
id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
audit(userId, 'update', 'lease', id, before, input);
|
||||||
|
return getLease(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteLease(id, userId) {
|
||||||
|
const before = getLease(id);
|
||||||
|
if (!before) return false;
|
||||||
|
run('DELETE FROM leases WHERE id = ?', [id]);
|
||||||
|
audit(userId, 'delete', 'lease', id, before, null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Straight-line ROU amortization with an ASC 842-style liability schedule.
|
||||||
|
function leaseSchedule(lease) {
|
||||||
|
if (!lease) return null;
|
||||||
|
const ppy = periodsPerYear(lease.payment_frequency);
|
||||||
|
const monthsPerPeriod = 12 / ppy;
|
||||||
|
const start = lease.start_date ? new Date(`${lease.start_date}T00:00:00`) : new Date();
|
||||||
|
const end = lease.end_date ? new Date(`${lease.end_date}T00:00:00`) : addMonths(start, 12);
|
||||||
|
|
||||||
|
const totalMonths = Math.max(monthsPerPeriod, (end.getFullYear() - start.getFullYear()) * 12 + (end.getMonth() - start.getMonth()));
|
||||||
|
const periods = Math.max(1, Math.round(totalMonths / monthsPerPeriod));
|
||||||
|
|
||||||
|
const contractValue = Number(lease.contract_value || 0);
|
||||||
|
const payment = contractValue / periods;
|
||||||
|
const rate = Number(lease.discount_rate || 0) / 100 / ppy;
|
||||||
|
const presentValue = rate > 0 ? payment * (1 - (1 + rate) ** -periods) / rate : contractValue;
|
||||||
|
|
||||||
|
const rouAmortization = presentValue / periods;
|
||||||
|
let liability = presentValue;
|
||||||
|
let rouBalance = presentValue;
|
||||||
|
let totalInterest = 0;
|
||||||
|
const rows = [];
|
||||||
|
|
||||||
|
for (let period = 1; period <= periods; period += 1) {
|
||||||
|
const interest = liability * rate;
|
||||||
|
const principal = payment - interest;
|
||||||
|
liability = Math.max(0, liability - principal);
|
||||||
|
rouBalance = Math.max(0, rouBalance - rouAmortization);
|
||||||
|
totalInterest += interest;
|
||||||
|
rows.push({
|
||||||
|
period,
|
||||||
|
date: addMonths(start, period * monthsPerPeriod).toISOString().slice(0, 10),
|
||||||
|
payment: money(payment),
|
||||||
|
interest: money(interest),
|
||||||
|
principal: money(principal),
|
||||||
|
liability_balance: money(liability),
|
||||||
|
rou_amortization: money(rouAmortization),
|
||||||
|
rou_balance: money(rouBalance)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
periods,
|
||||||
|
frequency: lease.payment_frequency,
|
||||||
|
periodic_payment: money(payment),
|
||||||
|
present_value: money(presentValue),
|
||||||
|
total_payments: money(payment * periods),
|
||||||
|
total_interest: money(totalInterest)
|
||||||
|
},
|
||||||
|
rows
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
FREQUENCIES,
|
||||||
|
createLease,
|
||||||
|
deleteLease,
|
||||||
|
getLease,
|
||||||
|
leaseSchedule,
|
||||||
|
listLeases,
|
||||||
|
updateLease
|
||||||
|
};
|
||||||
118
server/services/notifications.js
Normal file
118
server/services/notifications.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const { all, audit, now, run } = require('../db');
|
||||||
|
|
||||||
|
function readSettings() {
|
||||||
|
const rows = all('SELECT key, value FROM app_settings');
|
||||||
|
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw config (includes password) — for sending mail only.
|
||||||
|
function getConfig() {
|
||||||
|
const s = readSettings();
|
||||||
|
return {
|
||||||
|
host: s.smtp_host || '',
|
||||||
|
port: Number(s.smtp_port || 587),
|
||||||
|
secure: s.smtp_secure === 'true',
|
||||||
|
user: s.smtp_user || '',
|
||||||
|
password: s.smtp_password || '',
|
||||||
|
from: s.smtp_from || s.smtp_user || '',
|
||||||
|
enabled: s.alerts_enabled === 'true',
|
||||||
|
leadDays: Number(s.alerts_lead_days || 30),
|
||||||
|
recipients: String(s.alerts_recipients || '').split(',').map((x) => x.trim()).filter(Boolean),
|
||||||
|
webhookEnabled: s.webhook_enabled === 'true',
|
||||||
|
webhookUrl: s.webhook_url || '',
|
||||||
|
webhookSecret: s.webhook_secret || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe config for the API/UI — password is never returned, only whether it is set.
|
||||||
|
function publicConfig() {
|
||||||
|
const c = getConfig();
|
||||||
|
return {
|
||||||
|
smtp_host: c.host,
|
||||||
|
smtp_port: c.port,
|
||||||
|
smtp_secure: c.secure,
|
||||||
|
smtp_user: c.user,
|
||||||
|
smtp_from: c.from,
|
||||||
|
smtp_password_set: Boolean(c.password),
|
||||||
|
alerts_enabled: c.enabled,
|
||||||
|
alerts_lead_days: c.leadDays,
|
||||||
|
alerts_recipients: c.recipients.join(', '),
|
||||||
|
configured: Boolean(c.host),
|
||||||
|
webhook_enabled: c.webhookEnabled,
|
||||||
|
webhook_url: c.webhookUrl,
|
||||||
|
webhook_secret_set: Boolean(c.webhookSecret),
|
||||||
|
webhook_configured: Boolean(c.webhookUrl)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setValue(key, value) {
|
||||||
|
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()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfig(body, userId) {
|
||||||
|
if (body.smtp_host !== undefined) setValue('smtp_host', body.smtp_host || '');
|
||||||
|
if (body.smtp_port !== undefined) setValue('smtp_port', Number(body.smtp_port) || 587);
|
||||||
|
if (body.smtp_secure !== undefined) setValue('smtp_secure', body.smtp_secure ? 'true' : 'false');
|
||||||
|
if (body.smtp_user !== undefined) setValue('smtp_user', body.smtp_user || '');
|
||||||
|
if (body.smtp_from !== undefined) setValue('smtp_from', body.smtp_from || '');
|
||||||
|
if (body.smtp_password) setValue('smtp_password', body.smtp_password); // only overwrite when a new value is supplied
|
||||||
|
if (body.alerts_enabled !== undefined) setValue('alerts_enabled', body.alerts_enabled ? 'true' : 'false');
|
||||||
|
if (body.alerts_lead_days !== undefined) setValue('alerts_lead_days', Number(body.alerts_lead_days) || 30);
|
||||||
|
if (body.alerts_recipients !== undefined) setValue('alerts_recipients', body.alerts_recipients || '');
|
||||||
|
if (body.webhook_enabled !== undefined) setValue('webhook_enabled', body.webhook_enabled ? 'true' : 'false');
|
||||||
|
if (body.webhook_url !== undefined) setValue('webhook_url', body.webhook_url || '');
|
||||||
|
if (body.webhook_secret_clear) setValue('webhook_secret', '');
|
||||||
|
else if (body.webhook_secret) setValue('webhook_secret', body.webhook_secret); // only overwrite when a new value is supplied
|
||||||
|
audit(userId, 'update', 'notification_settings', 'smtp', null, {
|
||||||
|
...body,
|
||||||
|
smtp_password: body.smtp_password ? '[set]' : undefined,
|
||||||
|
webhook_secret: body.webhook_secret ? '[set]' : undefined
|
||||||
|
});
|
||||||
|
return publicConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTransport() {
|
||||||
|
const c = getConfig();
|
||||||
|
if (!c.host) {
|
||||||
|
throw Object.assign(new Error('SMTP host is not configured'), { status: 400 });
|
||||||
|
}
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: c.host,
|
||||||
|
port: c.port,
|
||||||
|
secure: c.secure,
|
||||||
|
auth: c.user ? { user: c.user, pass: c.password } : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMail({ to, subject, html, text }) {
|
||||||
|
const c = getConfig();
|
||||||
|
const recipients = to ? (Array.isArray(to) ? to : [to]) : c.recipients;
|
||||||
|
if (!recipients.length) {
|
||||||
|
throw Object.assign(new Error('No recipients are configured'), { status: 400 });
|
||||||
|
}
|
||||||
|
const transport = getTransport();
|
||||||
|
return transport.sendMail({
|
||||||
|
from: c.from || c.user,
|
||||||
|
to: recipients.join(', '),
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTestEmail(to, userId) {
|
||||||
|
const info = await sendMail({
|
||||||
|
to: to ? [to] : null,
|
||||||
|
subject: 'MixedAssets test email',
|
||||||
|
html: '<p>Your MixedAssets SMTP settings are working. This is a test message.</p>',
|
||||||
|
text: 'Your MixedAssets SMTP settings are working. This is a test message.'
|
||||||
|
});
|
||||||
|
audit(userId, 'send', 'test_email', null, null, { to });
|
||||||
|
return { messageId: info.messageId, accepted: info.accepted };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getConfig, publicConfig, saveConfig, sendMail, sendTestEmail };
|
||||||
312
server/services/parts.js
Normal file
312
server/services/parts.js
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
const { all, audit, now, one, run, tx } = require('../db');
|
||||||
|
|
||||||
|
const STATUSES = ['active', 'inactive', 'discontinued'];
|
||||||
|
// Stock movements. receive/issue/adjust change on-hand; reserve/unreserve change the
|
||||||
|
// reserved (committed) quantity. `adjust` sets the on-hand count to an absolute value.
|
||||||
|
const TRANSACTION_TYPES = ['receive', 'issue', 'adjust', 'reserve', 'unreserve'];
|
||||||
|
|
||||||
|
const COLUMNS = [
|
||||||
|
'part_number', 'name', 'description', 'category', 'manufacturer', 'manufacturer_part_number',
|
||||||
|
'barcode', 'unit_of_measure', 'unit_cost', 'reorder_point', 'reorder_quantity', 'measurements',
|
||||||
|
'supplier_contact_id', 'status', 'notes'
|
||||||
|
];
|
||||||
|
|
||||||
|
function round2(value) {
|
||||||
|
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(value, fallback = 0) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contactName(row, prefix) {
|
||||||
|
if (!row || !row[`${prefix}_id`]) return null;
|
||||||
|
const org = row[`${prefix}_org`];
|
||||||
|
const person = [row[`${prefix}_first`], row[`${prefix}_last`]].filter(Boolean).join(' ').trim();
|
||||||
|
return org || person || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stockRows(partId) {
|
||||||
|
return all(
|
||||||
|
`SELECT s.*, c.organization AS location_org, c.first_name AS location_first, c.last_name AS location_last
|
||||||
|
FROM part_stock s LEFT JOIN contacts c ON c.id = s.location_contact_id
|
||||||
|
WHERE s.part_id = ? ORDER BY c.organization, s.aisle, s.bin, s.id`,
|
||||||
|
[partId]
|
||||||
|
).map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
part_id: s.part_id,
|
||||||
|
location_contact_id: s.location_contact_id,
|
||||||
|
location_name: s.location_org || [s.location_first, s.location_last].filter(Boolean).join(' ').trim() || 'Unassigned',
|
||||||
|
aisle: s.aisle,
|
||||||
|
bin: s.bin,
|
||||||
|
quantity: round2(s.quantity),
|
||||||
|
reserved: round2(s.reserved),
|
||||||
|
available: round2(s.quantity - s.reserved)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function partPhotos(partId) {
|
||||||
|
return all('SELECT id, name, mime_type, data_base64 FROM part_photos WHERE part_id = ? ORDER BY id', [partId])
|
||||||
|
.map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, data: p.data_base64 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function partTransactions(partId, limit = 100) {
|
||||||
|
return all(
|
||||||
|
`SELECT t.id, t.type, t.quantity, t.unit_cost, t.reference, t.notes, t.created_at, t.asset_id,
|
||||||
|
u.name AS created_by_name, a.asset_id AS asset_code,
|
||||||
|
c.organization AS location_org, c.first_name AS location_first, c.last_name AS location_last,
|
||||||
|
s.aisle, s.bin
|
||||||
|
FROM part_transactions t
|
||||||
|
LEFT JOIN users u ON u.id = t.created_by
|
||||||
|
LEFT JOIN assets a ON a.id = t.asset_id
|
||||||
|
LEFT JOIN part_stock s ON s.id = t.stock_id
|
||||||
|
LEFT JOIN contacts c ON c.id = s.location_contact_id
|
||||||
|
WHERE t.part_id = ? ORDER BY t.id DESC LIMIT ?`,
|
||||||
|
[partId, limit]
|
||||||
|
).map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
type: t.type,
|
||||||
|
quantity: round2(t.quantity),
|
||||||
|
unit_cost: t.unit_cost == null ? null : round2(t.unit_cost),
|
||||||
|
reference: t.reference,
|
||||||
|
notes: t.notes,
|
||||||
|
created_at: t.created_at,
|
||||||
|
created_by_name: t.created_by_name,
|
||||||
|
asset_code: t.asset_code,
|
||||||
|
location_name: t.location_org || [t.location_first, t.location_last].filter(Boolean).join(' ').trim() || null,
|
||||||
|
aisle: t.aisle,
|
||||||
|
bin: t.bin
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalsFor(stock) {
|
||||||
|
const on_hand = round2(stock.reduce((sum, s) => sum + s.quantity, 0));
|
||||||
|
const reserved = round2(stock.reduce((sum, s) => sum + s.reserved, 0));
|
||||||
|
return { on_hand, reserved, available: round2(on_hand - reserved), location_count: stock.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decorate(row, totals) {
|
||||||
|
const onHand = totals.on_hand;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
supplier_name: contactName(row, 'supplier'),
|
||||||
|
on_hand: onHand,
|
||||||
|
reserved: totals.reserved,
|
||||||
|
available: totals.available,
|
||||||
|
location_count: totals.location_count,
|
||||||
|
stock_value: round2(onHand * num(row.unit_cost)),
|
||||||
|
low_stock: num(row.reorder_point) > 0 && onHand <= num(row.reorder_point)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listParts(query = {}) {
|
||||||
|
const rows = all(
|
||||||
|
`SELECT p.*,
|
||||||
|
sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last,
|
||||||
|
COALESCE(st.on_hand, 0) AS on_hand, COALESCE(st.reserved, 0) AS reserved, COALESCE(st.locations, 0) AS location_count
|
||||||
|
FROM parts p
|
||||||
|
LEFT JOIN contacts sup ON sup.id = p.supplier_contact_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT part_id, SUM(quantity) AS on_hand, SUM(reserved) AS reserved, COUNT(*) AS locations
|
||||||
|
FROM part_stock GROUP BY part_id
|
||||||
|
) st ON st.part_id = p.id
|
||||||
|
WHERE (? IS NULL OR p.status = ?)
|
||||||
|
AND (? IS NULL OR p.category = ?)
|
||||||
|
AND (? IS NULL OR (
|
||||||
|
p.part_number LIKE '%' || ? || '%' OR p.name LIKE '%' || ? || '%' OR
|
||||||
|
p.description LIKE '%' || ? || '%' OR p.barcode LIKE '%' || ? || '%' OR
|
||||||
|
p.manufacturer_part_number LIKE '%' || ? || '%'
|
||||||
|
))
|
||||||
|
ORDER BY p.name`,
|
||||||
|
[
|
||||||
|
query.status || null, query.status || null,
|
||||||
|
query.category || null, query.category || null,
|
||||||
|
query.search || null, query.search || null, query.search || null,
|
||||||
|
query.search || null, query.search || null
|
||||||
|
]
|
||||||
|
).map((row) => {
|
||||||
|
const onHand = round2(row.on_hand);
|
||||||
|
const reserved = round2(row.reserved);
|
||||||
|
return decorate(row, { on_hand: onHand, reserved, available: round2(onHand - reserved), location_count: row.location_count });
|
||||||
|
});
|
||||||
|
if (query.low_stock === 'true' || query.low_stock === true) return rows.filter((r) => r.low_stock);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPart(id) {
|
||||||
|
const row = one(
|
||||||
|
`SELECT p.*, sup.id AS supplier_id, sup.organization AS supplier_org, sup.first_name AS supplier_first, sup.last_name AS supplier_last
|
||||||
|
FROM parts p LEFT JOIN contacts sup ON sup.id = p.supplier_contact_id WHERE p.id = ?`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!row) return null;
|
||||||
|
const stock = stockRows(id);
|
||||||
|
const part = decorate(row, totalsFor(stock));
|
||||||
|
part.stock = stock;
|
||||||
|
part.photos = partPhotos(id);
|
||||||
|
part.transactions = partTransactions(id);
|
||||||
|
return part;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(body) {
|
||||||
|
const input = {};
|
||||||
|
for (const column of COLUMNS) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, column)) input[column] = body[column];
|
||||||
|
}
|
||||||
|
input.status = STATUSES.includes(input.status) ? input.status : 'active';
|
||||||
|
input.unit_of_measure = input.unit_of_measure || 'each';
|
||||||
|
for (const numeric of ['unit_cost', 'reorder_point', 'reorder_quantity']) {
|
||||||
|
if (input[numeric] !== undefined) input[numeric] = num(input[numeric]);
|
||||||
|
}
|
||||||
|
if (input.supplier_contact_id === '' || input.supplier_contact_id === undefined) input.supplier_contact_id = null;
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPart(body, userId) {
|
||||||
|
const input = normalize(body);
|
||||||
|
if (!input.part_number) throw Object.assign(new Error('A part number is required'), { status: 400 });
|
||||||
|
if (one('SELECT id FROM parts WHERE part_number = ?', [input.part_number])) {
|
||||||
|
throw Object.assign(new Error('That part number already exists'), { status: 400 });
|
||||||
|
}
|
||||||
|
const ts = now();
|
||||||
|
const cols = COLUMNS.filter((c) => input[c] !== undefined);
|
||||||
|
const result = run(
|
||||||
|
`INSERT INTO parts (${cols.join(', ')}, created_by, created_at, updated_at)
|
||||||
|
VALUES (${cols.map(() => '?').join(', ')}, ?, ?, ?)`,
|
||||||
|
[...cols.map((c) => input[c]), userId, ts, ts]
|
||||||
|
);
|
||||||
|
audit(userId, 'create', 'part', result.lastInsertRowid, null, { part_number: input.part_number });
|
||||||
|
return getPart(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePart(id, body, userId) {
|
||||||
|
const before = one('SELECT * FROM parts WHERE id = ?', [id]);
|
||||||
|
if (!before) return null;
|
||||||
|
const input = normalize({ ...before, ...body });
|
||||||
|
if (input.part_number !== before.part_number && one('SELECT id FROM parts WHERE part_number = ? AND id <> ?', [input.part_number, id])) {
|
||||||
|
throw Object.assign(new Error('That part number already exists'), { status: 400 });
|
||||||
|
}
|
||||||
|
run(
|
||||||
|
`UPDATE parts SET ${COLUMNS.map((c) => `${c} = ?`).join(', ')}, updated_at = ? WHERE id = ?`,
|
||||||
|
[...COLUMNS.map((c) => input[c] ?? null), now(), id]
|
||||||
|
);
|
||||||
|
audit(userId, 'update', 'part', id, before, body);
|
||||||
|
return getPart(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePart(id, userId) {
|
||||||
|
const before = one('SELECT * FROM parts WHERE id = ?', [id]);
|
||||||
|
if (!before) return false;
|
||||||
|
run('DELETE FROM parts WHERE id = ?', [id]);
|
||||||
|
audit(userId, 'delete', 'part', id, before, null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Stock locations -------------------------------------------------------
|
||||||
|
|
||||||
|
function saveStock(partId, body, userId) {
|
||||||
|
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
|
||||||
|
const ts = now();
|
||||||
|
const location = body.location_contact_id || null;
|
||||||
|
const aisle = body.aisle || null;
|
||||||
|
const bin = body.bin || null;
|
||||||
|
const quantity = num(body.quantity);
|
||||||
|
const reserved = Math.max(0, Math.min(quantity, num(body.reserved)));
|
||||||
|
if (body.id) {
|
||||||
|
const existing = one('SELECT * FROM part_stock WHERE id = ? AND part_id = ?', [body.id, partId]);
|
||||||
|
if (!existing) return null;
|
||||||
|
run(
|
||||||
|
'UPDATE part_stock SET location_contact_id = ?, aisle = ?, bin = ?, quantity = ?, reserved = ?, updated_at = ? WHERE id = ?',
|
||||||
|
[location, aisle, bin, quantity, reserved, ts, body.id]
|
||||||
|
);
|
||||||
|
audit(userId, 'update', 'part_stock', body.id, existing, body);
|
||||||
|
} else {
|
||||||
|
const result = run(
|
||||||
|
'INSERT INTO part_stock (part_id, location_contact_id, aisle, bin, quantity, reserved, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[partId, location, aisle, bin, quantity, reserved, ts, ts]
|
||||||
|
);
|
||||||
|
audit(userId, 'create', 'part_stock', result.lastInsertRowid, null, { part_id: partId });
|
||||||
|
}
|
||||||
|
return getPart(partId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteStock(partId, stockId, userId) {
|
||||||
|
const result = run('DELETE FROM part_stock WHERE id = ? AND part_id = ?', [stockId, partId]);
|
||||||
|
if (!result.changes) return null;
|
||||||
|
audit(userId, 'delete', 'part_stock', stockId, { part_id: partId }, null);
|
||||||
|
return getPart(partId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Stock movements -------------------------------------------------------
|
||||||
|
|
||||||
|
function recordTransaction(partId, body, userId) {
|
||||||
|
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
|
||||||
|
const type = TRANSACTION_TYPES.includes(body.type) ? body.type : 'receive';
|
||||||
|
const stock = one('SELECT * FROM part_stock WHERE id = ? AND part_id = ?', [body.stock_id, partId]);
|
||||||
|
if (!stock) throw Object.assign(new Error('Select a stock location for this movement'), { status: 400 });
|
||||||
|
const qty = Math.abs(num(body.quantity));
|
||||||
|
if (!qty && type !== 'adjust') throw Object.assign(new Error('Quantity must be greater than zero'), { status: 400 });
|
||||||
|
|
||||||
|
let quantity = stock.quantity;
|
||||||
|
let reserved = stock.reserved;
|
||||||
|
switch (type) {
|
||||||
|
case 'receive': quantity += qty; break;
|
||||||
|
case 'issue': quantity = Math.max(0, quantity - qty); break;
|
||||||
|
case 'adjust': quantity = num(body.quantity); break; // absolute count correction
|
||||||
|
case 'reserve': reserved = Math.min(quantity, reserved + qty); break;
|
||||||
|
case 'unreserve': reserved = Math.max(0, reserved - qty); break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
reserved = Math.min(reserved, quantity);
|
||||||
|
|
||||||
|
return tx(() => {
|
||||||
|
run('UPDATE part_stock SET quantity = ?, reserved = ?, updated_at = ? WHERE id = ?', [quantity, reserved, now(), stock.id]);
|
||||||
|
run(
|
||||||
|
`INSERT INTO part_transactions (part_id, stock_id, type, quantity, unit_cost, asset_id, reference, notes, created_by, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
partId, stock.id, type, type === 'adjust' ? num(body.quantity) : qty,
|
||||||
|
body.unit_cost != null && body.unit_cost !== '' ? num(body.unit_cost) : null,
|
||||||
|
body.asset_id || null, body.reference || null, body.notes || null, userId, now()
|
||||||
|
]
|
||||||
|
);
|
||||||
|
audit(userId, 'movement', 'part', partId, null, { type, quantity: qty, stock_id: stock.id });
|
||||||
|
return getPart(partId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Photos ----------------------------------------------------------------
|
||||||
|
|
||||||
|
function addPhoto(partId, body, userId) {
|
||||||
|
if (!one('SELECT id FROM parts WHERE id = ?', [partId])) return null;
|
||||||
|
if (!body.data) throw Object.assign(new Error('Photo data is required'), { status: 400 });
|
||||||
|
run(
|
||||||
|
'INSERT INTO part_photos (part_id, name, mime_type, data_base64, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[partId, body.name || null, body.mime || body.mime_type || 'image/jpeg', body.data, now()]
|
||||||
|
);
|
||||||
|
audit(userId, 'create', 'part_photo', partId, null, { name: body.name });
|
||||||
|
return getPart(partId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePhoto(partId, photoId, userId) {
|
||||||
|
const result = run('DELETE FROM part_photos WHERE id = ? AND part_id = ?', [photoId, partId]);
|
||||||
|
if (!result.changes) return null;
|
||||||
|
audit(userId, 'delete', 'part_photo', photoId, { part_id: partId }, null);
|
||||||
|
return getPart(partId);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
STATUSES,
|
||||||
|
TRANSACTION_TYPES,
|
||||||
|
addPhoto,
|
||||||
|
createPart,
|
||||||
|
deletePart,
|
||||||
|
deletePhoto,
|
||||||
|
deleteStock,
|
||||||
|
getPart,
|
||||||
|
listParts,
|
||||||
|
recordTransaction,
|
||||||
|
saveStock,
|
||||||
|
updatePart
|
||||||
|
};
|
||||||
467
server/services/pm.js
Normal file
467
server/services/pm.js
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
const { all, audit, json, now, one, parseJson, run, tx } = require('../db');
|
||||||
|
|
||||||
|
const FREQUENCY_UNITS = ['days', 'weeks', 'months', 'quarters', 'semiannual', 'years'];
|
||||||
|
|
||||||
|
function todayStr() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInterval(dateStr, value, unit) {
|
||||||
|
const date = new Date(`${dateStr || todayStr()}T00:00:00`);
|
||||||
|
const v = Math.max(1, Number(value) || 1);
|
||||||
|
switch (unit) {
|
||||||
|
case 'days': date.setDate(date.getDate() + v); break;
|
||||||
|
case 'weeks': date.setDate(date.getDate() + v * 7); break;
|
||||||
|
case 'quarters': date.setMonth(date.getMonth() + v * 3); break;
|
||||||
|
case 'semiannual': date.setMonth(date.getMonth() + v * 6); break;
|
||||||
|
case 'years': date.setFullYear(date.getFullYear() + v); break;
|
||||||
|
case 'months':
|
||||||
|
default: date.setMonth(date.getMonth() + v); break;
|
||||||
|
}
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PM plan templates -----------------------------------------------------
|
||||||
|
|
||||||
|
function round2(value) {
|
||||||
|
return Math.round((Number(value || 0) + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yearOf(value) {
|
||||||
|
return value ? Number(String(value).slice(0, 4)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function planSteps(planId) {
|
||||||
|
return all('SELECT id, step_order, title, details, est_minutes FROM pm_plan_steps WHERE plan_id = ? ORDER BY step_order, id', [planId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function planComponents(planId) {
|
||||||
|
return all('SELECT id, sort_order, part_number, description, supplier, cost FROM pm_plan_components WHERE plan_id = ? ORDER BY sort_order, id', [planId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guidance photos for a plan. `withData` is false for list views (omits the base64 blob).
|
||||||
|
function planPhotos(planId, withData = true) {
|
||||||
|
const columns = withData ? 'id, name, mime_type, caption, data_base64, sort_order' : 'id, name, mime_type, caption, sort_order';
|
||||||
|
return all(`SELECT ${columns} FROM pm_plan_photos WHERE plan_id = ? ORDER BY sort_order, id`, [planId])
|
||||||
|
.map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, caption: p.caption, ...(withData ? { data: p.data_base64 } : {}) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepsMinutes(steps) {
|
||||||
|
return (steps || []).reduce((sum, step) => sum + (Number(step.est_minutes) || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function componentsTotal(components) {
|
||||||
|
return round2((components || []).reduce((sum, component) => sum + (Number(component.cost) || 0), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function planFromRow(row, { withPhotoData = true } = {}) {
|
||||||
|
if (!row) return null;
|
||||||
|
const components = planComponents(row.id);
|
||||||
|
const photos = planPhotos(row.id, withPhotoData);
|
||||||
|
return { ...row, steps: planSteps(row.id), components, components_total: componentsTotal(components), photos, photo_count: photos.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function listPlans() {
|
||||||
|
return all('SELECT * FROM pm_plans ORDER BY name').map((row) => planFromRow(row, { withPhotoData: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlan(id) {
|
||||||
|
return planFromRow(one('SELECT * FROM pm_plans WHERE id = ?', [id]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeSteps(planId, steps) {
|
||||||
|
run('DELETE FROM pm_plan_steps WHERE plan_id = ?', [planId]);
|
||||||
|
(steps || []).forEach((step, index) => {
|
||||||
|
const title = (step.title || '').trim();
|
||||||
|
if (!title) return;
|
||||||
|
const est = step.est_minutes === '' || step.est_minutes == null ? null : Number(step.est_minutes);
|
||||||
|
run('INSERT INTO pm_plan_steps (plan_id, step_order, title, details, est_minutes) VALUES (?, ?, ?, ?, ?)', [planId, index, title, step.details || null, est]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeComponents(planId, components) {
|
||||||
|
run('DELETE FROM pm_plan_components WHERE plan_id = ?', [planId]);
|
||||||
|
(components || []).forEach((component, index) => {
|
||||||
|
const part = (component.part_number || '').trim();
|
||||||
|
const description = (component.description || '').trim();
|
||||||
|
if (!part && !description && !Number(component.cost)) return;
|
||||||
|
run(
|
||||||
|
'INSERT INTO pm_plan_components (plan_id, sort_order, part_number, description, supplier, cost) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
|
[planId, index, component.part_number || null, component.description || null, component.supplier || null, Number(component.cost) || 0]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile guidance photos: existing items (carry an id) keep their stored blob and
|
||||||
|
// only update caption/order; new items (carry data) are inserted; missing ones are removed.
|
||||||
|
function writePhotos(planId, photos) {
|
||||||
|
const existing = all('SELECT id FROM pm_plan_photos WHERE plan_id = ?', [planId]).map((r) => r.id);
|
||||||
|
const keep = new Set();
|
||||||
|
(photos || []).forEach((photo, index) => {
|
||||||
|
if (photo.id && existing.includes(photo.id)) {
|
||||||
|
keep.add(photo.id);
|
||||||
|
run('UPDATE pm_plan_photos SET caption = ?, sort_order = ? WHERE id = ? AND plan_id = ?', [photo.caption || null, index, photo.id, planId]);
|
||||||
|
} else if (photo.data) {
|
||||||
|
const result = run(
|
||||||
|
'INSERT INTO pm_plan_photos (plan_id, name, mime_type, caption, data_base64, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[planId, photo.name || null, photo.mime || photo.mime_type || 'image/jpeg', photo.caption || null, photo.data, index, now()]
|
||||||
|
);
|
||||||
|
keep.add(result.lastInsertRowid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const id of existing) {
|
||||||
|
if (!keep.has(id)) run('DELETE FROM pm_plan_photos WHERE id = ?', [id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimated minutes are derived from the step times when steps are supplied.
|
||||||
|
function resolveEstimatedMinutes(body, fallback) {
|
||||||
|
if (Array.isArray(body.steps)) {
|
||||||
|
const total = stepsMinutes(body.steps);
|
||||||
|
if (total > 0) return total;
|
||||||
|
}
|
||||||
|
if (body.estimated_minutes != null && body.estimated_minutes !== '') return Number(body.estimated_minutes);
|
||||||
|
return fallback ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlan(body, userId) {
|
||||||
|
const ts = now();
|
||||||
|
return tx(() => {
|
||||||
|
const result = run(
|
||||||
|
`INSERT INTO pm_plans (name, description, category, frequency_value, frequency_unit, estimated_minutes, instructions, created_by, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
body.name || 'PM plan', body.description || null, body.category || null,
|
||||||
|
Number(body.frequency_value) || 1, FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : 'months',
|
||||||
|
resolveEstimatedMinutes(body, null), body.instructions || null,
|
||||||
|
userId, ts, ts
|
||||||
|
]
|
||||||
|
);
|
||||||
|
writeSteps(result.lastInsertRowid, body.steps);
|
||||||
|
writeComponents(result.lastInsertRowid, body.components);
|
||||||
|
writePhotos(result.lastInsertRowid, body.photos);
|
||||||
|
audit(userId, 'create', 'pm_plan', result.lastInsertRowid, null, { name: body.name });
|
||||||
|
return getPlan(result.lastInsertRowid);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlan(id, body, userId) {
|
||||||
|
const before = one('SELECT * FROM pm_plans WHERE id = ?', [id]);
|
||||||
|
if (!before) return null;
|
||||||
|
return tx(() => {
|
||||||
|
run(
|
||||||
|
`UPDATE pm_plans SET name = ?, description = ?, category = ?, frequency_value = ?, frequency_unit = ?,
|
||||||
|
estimated_minutes = ?, instructions = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
[
|
||||||
|
body.name ?? before.name, body.description ?? before.description, body.category ?? before.category,
|
||||||
|
body.frequency_value != null ? Number(body.frequency_value) : before.frequency_value,
|
||||||
|
FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : before.frequency_unit,
|
||||||
|
resolveEstimatedMinutes(body, before.estimated_minutes),
|
||||||
|
body.instructions ?? before.instructions, now(), id
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if (Array.isArray(body.steps)) writeSteps(id, body.steps);
|
||||||
|
if (Array.isArray(body.components)) writeComponents(id, body.components);
|
||||||
|
if (Array.isArray(body.photos)) writePhotos(id, body.photos);
|
||||||
|
audit(userId, 'update', 'pm_plan', id, before, { name: body.name });
|
||||||
|
return getPlan(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePlan(id, userId) {
|
||||||
|
const before = one('SELECT * FROM pm_plans WHERE id = ?', [id]);
|
||||||
|
if (!before) return false;
|
||||||
|
run('DELETE FROM pm_plans WHERE id = ?', [id]);
|
||||||
|
audit(userId, 'delete', 'pm_plan', id, before, null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Per-asset PM schedules ------------------------------------------------
|
||||||
|
|
||||||
|
function assetPmRows(assetId) {
|
||||||
|
return all(
|
||||||
|
`SELECT ap.*, p.name AS plan_name, p.description AS plan_description, p.instructions AS plan_instructions,
|
||||||
|
u.name AS assigned_to_name
|
||||||
|
FROM asset_pm_plans ap
|
||||||
|
LEFT JOIN pm_plans p ON p.id = ap.plan_id
|
||||||
|
LEFT JOIN users u ON u.id = ap.assigned_to
|
||||||
|
WHERE ap.asset_id = ?
|
||||||
|
ORDER BY ap.active DESC, ap.next_due_date IS NULL, ap.next_due_date`,
|
||||||
|
[assetId]
|
||||||
|
).map((row) => {
|
||||||
|
const components = row.plan_id ? planComponents(row.plan_id) : [];
|
||||||
|
const completions = all(
|
||||||
|
`SELECT c.id, c.completed_at, c.notes, c.notes_json, c.steps_json, c.cost, c.components_json,
|
||||||
|
c.rating_safety, c.rating_physical, c.rating_operating,
|
||||||
|
CASE WHEN c.signature IS NOT NULL THEN 1 ELSE 0 END AS has_signature, c.next_due_date,
|
||||||
|
u.name AS completed_by_name,
|
||||||
|
(SELECT COUNT(*) FROM pm_completion_photos ph WHERE ph.completion_id = c.id) AS photo_count
|
||||||
|
FROM pm_completions c LEFT JOIN users u ON u.id = c.completed_by
|
||||||
|
WHERE c.asset_pm_id = ? ORDER BY c.completed_at DESC LIMIT 12`,
|
||||||
|
[row.id]
|
||||||
|
).map((c) => ({
|
||||||
|
...c,
|
||||||
|
has_signature: Boolean(c.has_signature),
|
||||||
|
notes: parseJson(c.notes_json, c.notes ? [c.notes] : []),
|
||||||
|
steps: parseJson(c.steps_json, []),
|
||||||
|
components: parseJson(c.components_json, []),
|
||||||
|
ratings: { safety: c.rating_safety, physical: c.rating_physical, operating: c.rating_operating }
|
||||||
|
}));
|
||||||
|
const totalCost = round2(completions.reduce((sum, c) => sum + Number(c.cost || 0), 0));
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
active: Boolean(row.active),
|
||||||
|
steps: row.plan_id ? planSteps(row.plan_id) : [],
|
||||||
|
plan_photos: row.plan_id ? planPhotos(row.plan_id) : [],
|
||||||
|
components,
|
||||||
|
expected_cost: componentsTotal(components),
|
||||||
|
total_cost: totalCost,
|
||||||
|
last_cost: completions[0] ? round2(completions[0].cost) : 0,
|
||||||
|
completions
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachPlan(assetId, body, userId) {
|
||||||
|
const ts = now();
|
||||||
|
const plan = body.plan_id ? one('SELECT * FROM pm_plans WHERE id = ?', [body.plan_id]) : null;
|
||||||
|
const value = Number(body.frequency_value) || (plan ? plan.frequency_value : 1);
|
||||||
|
const unit = FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : (plan ? plan.frequency_unit : 'months');
|
||||||
|
const startDate = body.start_date || todayStr();
|
||||||
|
const nextDue = body.next_due_date || startDate;
|
||||||
|
const result = run(
|
||||||
|
`INSERT INTO asset_pm_plans (asset_id, plan_id, frequency_value, frequency_unit, start_date, end_date, next_due_date, assigned_to, active, notes, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`,
|
||||||
|
[assetId, body.plan_id || null, value, unit, startDate, body.end_date || null, nextDue, body.assigned_to || null, body.notes || null, ts, ts]
|
||||||
|
);
|
||||||
|
audit(userId, 'attach', 'asset_pm', result.lastInsertRowid, null, { asset_id: assetId, plan_id: body.plan_id });
|
||||||
|
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [result.lastInsertRowid]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAssetPm(assetId, apId, body, userId) {
|
||||||
|
const before = one('SELECT * FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]);
|
||||||
|
if (!before) return null;
|
||||||
|
run(
|
||||||
|
`UPDATE asset_pm_plans SET frequency_value = ?, frequency_unit = ?, start_date = ?, end_date = ?,
|
||||||
|
next_due_date = ?, assigned_to = ?, active = ?, notes = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
[
|
||||||
|
body.frequency_value != null ? Number(body.frequency_value) : before.frequency_value,
|
||||||
|
FREQUENCY_UNITS.includes(body.frequency_unit) ? body.frequency_unit : before.frequency_unit,
|
||||||
|
body.start_date ?? before.start_date,
|
||||||
|
body.end_date !== undefined ? body.end_date : before.end_date,
|
||||||
|
body.next_due_date ?? before.next_due_date,
|
||||||
|
body.assigned_to !== undefined ? body.assigned_to : before.assigned_to,
|
||||||
|
body.active === undefined ? before.active : (body.active ? 1 : 0),
|
||||||
|
body.notes ?? before.notes,
|
||||||
|
now(), apId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
audit(userId, 'update', 'asset_pm', apId, before, body);
|
||||||
|
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [apId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detachAssetPm(assetId, apId, userId) {
|
||||||
|
const result = run('DELETE FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]);
|
||||||
|
if (!result.changes) return false;
|
||||||
|
audit(userId, 'detach', 'asset_pm', apId, null, { asset_id: assetId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampRating(value) {
|
||||||
|
if (value == null || value === '') return null;
|
||||||
|
return Math.max(1, Math.min(5, Number(value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function completePm(assetId, apId, body, userId) {
|
||||||
|
const ap = one('SELECT * FROM asset_pm_plans WHERE id = ? AND asset_id = ?', [apId, assetId]);
|
||||||
|
if (!ap) return null;
|
||||||
|
if (!body.signature) throw Object.assign(new Error('A signature is required to complete a PM'), { status: 400 });
|
||||||
|
|
||||||
|
const ts = now();
|
||||||
|
const completedDate = (body.completed_at || ts).slice(0, 10);
|
||||||
|
let nextDue = addInterval(completedDate, ap.frequency_value, ap.frequency_unit);
|
||||||
|
let active = ap.active;
|
||||||
|
if (ap.end_date && nextDue > ap.end_date) {
|
||||||
|
nextDue = null;
|
||||||
|
active = 0; // schedule complete — past the asset's maintenance end date
|
||||||
|
}
|
||||||
|
const planComps = ap.plan_id ? planComponents(ap.plan_id) : [];
|
||||||
|
const components = Array.isArray(body.components) ? body.components : planComps;
|
||||||
|
const cost = body.cost != null && body.cost !== '' ? round2(body.cost) : componentsTotal(planComps);
|
||||||
|
const notesList = Array.isArray(body.notes_list) ? body.notes_list.filter((n) => String(n || '').trim()) : (body.notes ? [body.notes] : []);
|
||||||
|
const ratings = body.ratings || {};
|
||||||
|
const photos = Array.isArray(body.photos) ? body.photos.filter((p) => p && p.data) : [];
|
||||||
|
|
||||||
|
return tx(() => {
|
||||||
|
const inserted = run(
|
||||||
|
`INSERT INTO pm_completions (asset_pm_id, completed_at, completed_by, notes, notes_json, steps_json, cost, components_json,
|
||||||
|
signature, rating_safety, rating_physical, rating_operating, next_due_date, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
apId, ts, userId, notesList.join('\n') || null, json(notesList), json(body.steps || []), cost, json(components),
|
||||||
|
body.signature, clampRating(ratings.safety), clampRating(ratings.physical), clampRating(ratings.operating), nextDue, ts
|
||||||
|
]
|
||||||
|
);
|
||||||
|
for (const photo of photos) {
|
||||||
|
run('INSERT INTO pm_completion_photos (completion_id, name, mime_type, data_base64, created_at) VALUES (?, ?, ?, ?, ?)', [
|
||||||
|
inserted.lastInsertRowid, photo.name || null, photo.mime || photo.mime_type || 'image/jpeg', photo.data, ts
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
run('UPDATE asset_pm_plans SET last_completed_date = ?, next_due_date = ?, active = ?, updated_at = ? WHERE id = ?', [completedDate, nextDue, active, ts, apId]);
|
||||||
|
audit(userId, 'complete', 'asset_pm', apId, null, { next_due_date: nextDue, completion_id: inserted.lastInsertRowid });
|
||||||
|
return one('SELECT * FROM asset_pm_plans WHERE id = ?', [apId]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function listCompletions(query = {}) {
|
||||||
|
return all(
|
||||||
|
`SELECT c.id, c.completed_at, c.cost, c.notes_json, c.rating_safety, c.rating_physical, c.rating_operating,
|
||||||
|
CASE WHEN c.signature IS NOT NULL THEN 1 ELSE 0 END AS has_signature,
|
||||||
|
u.name AS completed_by_name, a.asset_id AS asset_code, a.description AS asset_description, p.name AS plan_name,
|
||||||
|
(SELECT COUNT(*) FROM pm_completion_photos ph WHERE ph.completion_id = c.id) AS photo_count
|
||||||
|
FROM pm_completions c
|
||||||
|
JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
|
||||||
|
JOIN assets a ON a.id = ap.asset_id
|
||||||
|
LEFT JOIN pm_plans p ON p.id = ap.plan_id
|
||||||
|
LEFT JOIN users u ON u.id = c.completed_by
|
||||||
|
WHERE (? IS NULL OR ap.asset_id = ?)
|
||||||
|
ORDER BY c.completed_at DESC, c.id DESC
|
||||||
|
LIMIT 500`,
|
||||||
|
[query.asset_id || null, query.asset_id || null]
|
||||||
|
).map((r) => ({ ...r, has_signature: Boolean(r.has_signature), notes: parseJson(r.notes_json, []) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCompletion(id) {
|
||||||
|
const row = one(
|
||||||
|
`SELECT c.*, u.name AS completed_by_name, a.asset_id AS asset_code, a.description AS asset_description, p.name AS plan_name
|
||||||
|
FROM pm_completions c
|
||||||
|
JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
|
||||||
|
JOIN assets a ON a.id = ap.asset_id
|
||||||
|
LEFT JOIN pm_plans p ON p.id = ap.plan_id
|
||||||
|
LEFT JOIN users u ON u.id = c.completed_by
|
||||||
|
WHERE c.id = ?`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
asset_code: row.asset_code,
|
||||||
|
asset_description: row.asset_description,
|
||||||
|
plan_name: row.plan_name,
|
||||||
|
completed_at: row.completed_at,
|
||||||
|
completed_by_name: row.completed_by_name,
|
||||||
|
cost: row.cost,
|
||||||
|
notes: parseJson(row.notes_json, row.notes ? [row.notes] : []),
|
||||||
|
steps: parseJson(row.steps_json, []),
|
||||||
|
components: parseJson(row.components_json, []),
|
||||||
|
signature: row.signature || null,
|
||||||
|
ratings: { safety: row.rating_safety, physical: row.rating_physical, operating: row.rating_operating },
|
||||||
|
photos: all('SELECT id, name, mime_type, data_base64 FROM pm_completion_photos WHERE completion_id = ? ORDER BY id', [row.id])
|
||||||
|
.map((p) => ({ id: p.id, name: p.name, mime: p.mime_type, data: p.data_base64 }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Settings & alert candidates ------------------------------------------
|
||||||
|
|
||||||
|
function getSettings() {
|
||||||
|
const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'pm_%'");
|
||||||
|
const map = Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
||||||
|
return {
|
||||||
|
pm_default_frequency_value: Number(map.pm_default_frequency_value || 3),
|
||||||
|
pm_default_frequency_unit: map.pm_default_frequency_unit || 'months',
|
||||||
|
pm_lead_days: Number(map.pm_lead_days || 7)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings(body, userId) {
|
||||||
|
const set = (k, v) => run('INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at', [k, String(v), now()]);
|
||||||
|
if (body.pm_default_frequency_value !== undefined) set('pm_default_frequency_value', Number(body.pm_default_frequency_value) || 3);
|
||||||
|
if (body.pm_default_frequency_unit !== undefined) set('pm_default_frequency_unit', FREQUENCY_UNITS.includes(body.pm_default_frequency_unit) ? body.pm_default_frequency_unit : 'months');
|
||||||
|
if (body.pm_lead_days !== undefined) set('pm_lead_days', Number(body.pm_lead_days) || 7);
|
||||||
|
audit(userId, 'update', 'pm_settings', null, null, body);
|
||||||
|
return getSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alert candidates for PM schedules that are due/overdue.
|
||||||
|
function pmAlertCandidates() {
|
||||||
|
const lead = getSettings().pm_lead_days;
|
||||||
|
const today = todayStr();
|
||||||
|
const horizon = new Date(Date.now() + lead * 86400000).toISOString().slice(0, 10);
|
||||||
|
const rows = all(
|
||||||
|
`SELECT ap.*, p.name AS plan_name, a.asset_id AS asset_code
|
||||||
|
FROM asset_pm_plans ap
|
||||||
|
LEFT JOIN pm_plans p ON p.id = ap.plan_id
|
||||||
|
LEFT JOIN assets a ON a.id = ap.asset_id
|
||||||
|
WHERE ap.active = 1 AND ap.next_due_date IS NOT NULL`
|
||||||
|
);
|
||||||
|
const list = [];
|
||||||
|
for (const ap of rows) {
|
||||||
|
const name = ap.plan_name || 'Preventative maintenance';
|
||||||
|
const where = ap.asset_code ? ` on ${ap.asset_code}` : '';
|
||||||
|
if (ap.next_due_date < today) {
|
||||||
|
list.push({ type: 'pm_overdue', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'critical', title: `PM overdue: ${name}`, message: `${name}${where} was due ${ap.next_due_date}.`, due_date: ap.next_due_date });
|
||||||
|
} else if (ap.next_due_date <= horizon) {
|
||||||
|
list.push({ type: 'pm_due', reference_type: 'asset_pm', reference_id: ap.id, asset_id: ap.asset_id, severity: 'warning', title: `PM due: ${name}`, message: `${name}${where} is due ${ap.next_due_date}.`, due_date: ap.next_due_date });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintenance-cost "ledger" for the PM book: actual PM spend per asset for a year.
|
||||||
|
function pmBookLedger(year) {
|
||||||
|
const rows = all(
|
||||||
|
`SELECT c.cost, c.completed_at, a.asset_id, a.description
|
||||||
|
FROM pm_completions c
|
||||||
|
JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
|
||||||
|
JOIN assets a ON a.id = ap.asset_id`
|
||||||
|
);
|
||||||
|
const byAsset = {};
|
||||||
|
let total = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (yearOf(row.completed_at) !== Number(year)) continue;
|
||||||
|
const cost = Number(row.cost || 0);
|
||||||
|
const entry = byAsset[row.asset_id] || (byAsset[row.asset_id] = { asset_id: row.asset_id, description: row.description, completions: 0, cost: 0, last_service: null });
|
||||||
|
entry.completions += 1;
|
||||||
|
entry.cost += cost;
|
||||||
|
const day = String(row.completed_at).slice(0, 10);
|
||||||
|
if (!entry.last_service || day > entry.last_service) entry.last_service = day;
|
||||||
|
total += cost;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
const assets = Object.values(byAsset).map((a) => ({ ...a, cost: round2(a.cost) })).sort((a, b) => b.cost - a.cost);
|
||||||
|
const accounts = [
|
||||||
|
{ account: '6450', type: 'PM / maintenance expense', debit: round2(total), credit: 0 },
|
||||||
|
{ account: '2000', type: 'Maintenance clearing', debit: 0, credit: round2(total) }
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
year: Number(year),
|
||||||
|
kind: 'maintenance',
|
||||||
|
rule_set: {},
|
||||||
|
summary: { assets: assets.length, completions: count, cost: round2(total) },
|
||||||
|
accounts,
|
||||||
|
accountTotals: { debit: round2(total), credit: round2(total) },
|
||||||
|
assets
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
FREQUENCY_UNITS,
|
||||||
|
addInterval,
|
||||||
|
assetPmRows,
|
||||||
|
pmBookLedger,
|
||||||
|
attachPlan,
|
||||||
|
completePm,
|
||||||
|
createPlan,
|
||||||
|
deletePlan,
|
||||||
|
detachAssetPm,
|
||||||
|
getCompletion,
|
||||||
|
getPlan,
|
||||||
|
listCompletions,
|
||||||
|
getSettings,
|
||||||
|
listPlans,
|
||||||
|
pmAlertCandidates,
|
||||||
|
saveSettings,
|
||||||
|
updateAssetPm,
|
||||||
|
updatePlan
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const { json, now, one, parseJson, run } = require('../db');
|
const { json, now, one, parseJson, run } = require('../db');
|
||||||
const { publicUser } = require('../middleware/auth');
|
const { publicUser } = require('../middleware/auth');
|
||||||
|
const { capabilitiesForRole } = require('./roles');
|
||||||
|
|
||||||
const defaultPreferences = {
|
const defaultPreferences = {
|
||||||
theme: 'mixedAssetsDark'
|
theme: 'mixedAssetsDark'
|
||||||
@@ -16,7 +17,8 @@ function getPreferences(userId) {
|
|||||||
function getProfile(user) {
|
function getProfile(user) {
|
||||||
return {
|
return {
|
||||||
user: publicUser(user),
|
user: publicUser(user),
|
||||||
preferences: getPreferences(user.id)
|
preferences: getPreferences(user.id),
|
||||||
|
capabilities: capabilitiesForRole(user.role)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
807
server/services/reportEngine.js
Normal file
807
server/services/reportEngine.js
Normal file
@@ -0,0 +1,807 @@
|
|||||||
|
const { all, now, one, parseJson } = require('../db');
|
||||||
|
const { annualSchedule, money } = require('../depreciation');
|
||||||
|
const { ruleSetForBook } = require('./ruleSets');
|
||||||
|
const { assetFromRow } = require('./assets');
|
||||||
|
|
||||||
|
const BOOKS = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
|
||||||
|
|
||||||
|
function currentYear() {
|
||||||
|
return new Date().getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function yearOf(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
return new Date(`${value}T00:00:00`).getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumBy(rows, keys) {
|
||||||
|
const totals = {};
|
||||||
|
for (const key of keys) totals[key] = money(rows.reduce((sum, row) => sum + Number(row[key] || 0), 0));
|
||||||
|
return totals;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns helper: { key, label, format } where format drives client + export rendering.
|
||||||
|
function col(key, label, format = 'text') {
|
||||||
|
return { key, label, format };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Asset/book loading ----------------------------------------------------
|
||||||
|
|
||||||
|
function assetBookRows(bookType, options = {}) {
|
||||||
|
return all(
|
||||||
|
`SELECT a.*, b.book_type, b.cost AS book_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_bup, b.investment_use_percent AS book_ivp,
|
||||||
|
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.book_type = ? AND b.active = 1
|
||||||
|
AND (? IS NULL OR a.entity_id = ?)
|
||||||
|
AND (? IS NULL OR a.category = ?)
|
||||||
|
AND (? IS NULL OR a.status = ?)
|
||||||
|
ORDER BY a.asset_id`,
|
||||||
|
[
|
||||||
|
bookType,
|
||||||
|
options.entity_id || null, options.entity_id || null,
|
||||||
|
options.category || null, options.category || null,
|
||||||
|
options.status || null, options.status || null
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toAssetBook(row) {
|
||||||
|
return {
|
||||||
|
asset: assetFromRow(row),
|
||||||
|
entity_name: row.entity_name,
|
||||||
|
book: {
|
||||||
|
book_type: row.book_type,
|
||||||
|
cost: row.book_cost,
|
||||||
|
depreciation_method: row.depreciation_method,
|
||||||
|
convention: row.convention,
|
||||||
|
useful_life_months: row.book_life,
|
||||||
|
section_179_amount: row.section_179_amount,
|
||||||
|
bonus_percent: row.bonus_percent,
|
||||||
|
business_use_percent: row.book_bup,
|
||||||
|
investment_use_percent: row.book_ivp,
|
||||||
|
manual_depreciation: row.manual_depreciation
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeYear(asset, book, rules, year) {
|
||||||
|
const rows = annualSchedule(asset, book, rules, { startYear: year - 1, endYear: year });
|
||||||
|
const end = rows.find((row) => row.year === year);
|
||||||
|
const cost = Number(book.cost ?? asset.acquisition_cost ?? 0);
|
||||||
|
const depreciation = end ? Number(end.depreciation) : 0;
|
||||||
|
const accumulatedEnd = end ? Number(end.accumulated) : Number(asset.prior_depreciation || 0);
|
||||||
|
const accumulatedBegin = money(accumulatedEnd - depreciation);
|
||||||
|
return {
|
||||||
|
cost,
|
||||||
|
depreciation: money(depreciation),
|
||||||
|
accumulated_begin: accumulatedBegin,
|
||||||
|
accumulated_end: money(accumulatedEnd),
|
||||||
|
nbv_begin: money(cost - accumulatedBegin),
|
||||||
|
nbv_end: money(cost - accumulatedEnd),
|
||||||
|
method: end ? end.method : book.depreciation_method,
|
||||||
|
methodLabel: end ? end.methodLabel : book.depreciation_method
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function lifetimeAccumulated(asset, book, rules) {
|
||||||
|
const rows = annualSchedule(asset, book, rules, { startYear: currentYear(), endYear: currentYear() });
|
||||||
|
return rows.length ? Number(rows[rows.length - 1].accumulated) : Number(asset.prior_depreciation || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Report builders -------------------------------------------------------
|
||||||
|
|
||||||
|
function depreciationExpense(options, titleBook) {
|
||||||
|
const book = titleBook || options.book || 'GAAP';
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const rules = ruleSetForBook(book);
|
||||||
|
const rows = assetBookRows(book, options).map(toAssetBook).map(({ asset, book: bk, entity_name }) => {
|
||||||
|
const c = computeYear(asset, bk, rules, year);
|
||||||
|
return {
|
||||||
|
asset_id: asset.asset_id,
|
||||||
|
description: asset.description,
|
||||||
|
entity: entity_name,
|
||||||
|
category: asset.category,
|
||||||
|
method: c.methodLabel,
|
||||||
|
cost: c.cost,
|
||||||
|
depreciation: c.depreciation,
|
||||||
|
accumulated: c.accumulated_end,
|
||||||
|
nbv: c.nbv_end
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('asset_id', 'Asset ID'), col('description', 'Description'), col('category', 'Category'),
|
||||||
|
col('method', 'Method'), col('cost', 'Cost', 'currency'), col('depreciation', 'Depreciation', 'currency'),
|
||||||
|
col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['cost', 'depreciation', 'accumulated', 'nbv'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthlyDepreciation(options) {
|
||||||
|
const book = options.book || 'GAAP';
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const annual = depreciationExpense({ ...options, book }).totals.depreciation;
|
||||||
|
const monthly = money(annual / 12);
|
||||||
|
const rows = Array.from({ length: 12 }, (_, index) => ({ period: `M${index + 1}`, depreciation: monthly }));
|
||||||
|
return {
|
||||||
|
columns: [col('period', 'Month'), col('depreciation', 'Depreciation', 'currency')],
|
||||||
|
rows,
|
||||||
|
totals: { depreciation: money(monthly * 12) },
|
||||||
|
chart: { type: 'bar', labels: rows.map((r) => r.period), values: rows.map((r) => r.depreciation) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function quarterlyDepreciation(options) {
|
||||||
|
const annual = depreciationExpense(options).totals.depreciation;
|
||||||
|
const quarter = money(annual / 4);
|
||||||
|
const rows = ['Q1', 'Q2', 'Q3', 'Q4'].map((period) => ({ period, depreciation: quarter }));
|
||||||
|
return {
|
||||||
|
columns: [col('period', 'Quarter'), col('depreciation', 'Depreciation', 'currency')],
|
||||||
|
rows,
|
||||||
|
totals: { depreciation: money(quarter * 4) },
|
||||||
|
chart: { type: 'bar', labels: rows.map((r) => r.period), values: rows.map((r) => r.depreciation) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ytdDepreciation(options) {
|
||||||
|
const month = Math.min(12, Math.max(1, Number(options.month) || 12));
|
||||||
|
const base = depreciationExpense(options);
|
||||||
|
const rows = base.rows.map((row) => ({ ...row, ytd: money((row.depreciation * month) / 12) }));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('asset_id', 'Asset ID'), col('description', 'Description'), col('method', 'Method'),
|
||||||
|
col('depreciation', 'Annual', 'currency'), col('ytd', `YTD through M${month}`, 'currency'),
|
||||||
|
col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['depreciation', 'ytd', 'accumulated', 'nbv'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function netBookValue(options) {
|
||||||
|
const book = options.book || 'GAAP';
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const rules = ruleSetForBook(book);
|
||||||
|
const rows = assetBookRows(book, options).map(toAssetBook).map(({ asset, book: bk, entity_name }) => {
|
||||||
|
const c = computeYear(asset, bk, rules, year);
|
||||||
|
return {
|
||||||
|
asset_id: asset.asset_id, description: asset.description, entity: entity_name,
|
||||||
|
cost: c.cost, accumulated: c.accumulated_end, nbv: c.nbv_end
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('asset_id', 'Asset ID'), col('description', 'Description'), col('entity', 'Entity'),
|
||||||
|
col('cost', 'Cost', 'currency'), col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['cost', 'accumulated', 'nbv'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function lifetimeDepreciation(options) {
|
||||||
|
const book = options.book || 'GAAP';
|
||||||
|
const rules = ruleSetForBook(book);
|
||||||
|
const rows = assetBookRows(book, options).map(toAssetBook).map(({ asset, book: bk, entity_name }) => {
|
||||||
|
const cost = Number(bk.cost ?? asset.acquisition_cost ?? 0);
|
||||||
|
const accumulated = lifetimeAccumulated(asset, bk, rules);
|
||||||
|
return {
|
||||||
|
asset_id: asset.asset_id, description: asset.description, entity: entity_name,
|
||||||
|
in_service_date: asset.in_service_date, cost,
|
||||||
|
lifetime_depreciation: money(accumulated), nbv: money(cost - accumulated)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('asset_id', 'Asset ID'), col('description', 'Description'), col('in_service_date', 'In service', 'date'),
|
||||||
|
col('cost', 'Cost', 'currency'), col('lifetime_depreciation', 'Lifetime depr.', 'currency'), col('nbv', 'Net book value', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['cost', 'lifetime_depreciation', 'nbv'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function acquisitions(options) {
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const rows = all(
|
||||||
|
`SELECT a.asset_id, a.description, a.category, a.acquired_date, a.vendor, a.invoice_number, a.acquisition_cost
|
||||||
|
FROM assets a WHERE a.acquired_date IS NOT NULL
|
||||||
|
AND (? IS NULL OR a.entity_id = ?)
|
||||||
|
ORDER BY a.acquired_date`,
|
||||||
|
[options.entity_id || null, options.entity_id || null]
|
||||||
|
).filter((row) => yearOf(row.acquired_date) === year)
|
||||||
|
.map((row) => ({ ...row, acquisition_cost: money(row.acquisition_cost) }));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('asset_id', 'Asset ID'), col('description', 'Description'), col('category', 'Category'),
|
||||||
|
col('acquired_date', 'Acquired', 'date'), col('vendor', 'Vendor'), col('invoice_number', 'Invoice'),
|
||||||
|
col('acquisition_cost', 'Cost', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['acquisition_cost'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposalRows(year, entityId) {
|
||||||
|
return all(
|
||||||
|
`SELECT d.*, a.asset_id, a.description, a.category, a.acquired_date
|
||||||
|
FROM disposals d JOIN assets a ON a.id = d.asset_id
|
||||||
|
WHERE (? IS NULL OR a.entity_id = ?)
|
||||||
|
ORDER BY d.disposal_date`,
|
||||||
|
[entityId || null, entityId || null]
|
||||||
|
).filter((row) => yearOf(row.disposal_date) === year);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disposals(options) {
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const rows = disposalRows(year, options.entity_id).map((row) => ({
|
||||||
|
asset_id: row.asset_id,
|
||||||
|
description: row.description,
|
||||||
|
method: row.method,
|
||||||
|
disposal_date: row.disposal_date,
|
||||||
|
proceeds: money(row.disposal_price - row.disposal_expense),
|
||||||
|
net_book_value: money(row.net_book_value),
|
||||||
|
gain_loss: money(row.gain_loss)
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('asset_id', 'Asset ID'), col('description', 'Description'), col('method', 'Type'),
|
||||||
|
col('disposal_date', 'Date', 'date'), col('proceeds', 'Net proceeds', 'currency'),
|
||||||
|
col('net_book_value', 'Net book value', 'currency'), col('gain_loss', 'Gain / (loss)', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['proceeds', 'net_book_value', 'gain_loss'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustments(options) {
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const rows = all(
|
||||||
|
`SELECT adj.*, a.asset_id, a.description FROM asset_adjustments adj
|
||||||
|
JOIN assets a ON a.id = adj.asset_id
|
||||||
|
WHERE (? IS NULL OR a.entity_id = ?)
|
||||||
|
ORDER BY adj.adjustment_date`,
|
||||||
|
[options.entity_id || null, options.entity_id || null]
|
||||||
|
).filter((row) => yearOf(row.adjustment_date) === year)
|
||||||
|
.map((row) => ({
|
||||||
|
asset_id: row.asset_id, description: row.description, type: row.type,
|
||||||
|
book_type: row.book_type, adjustment_date: row.adjustment_date,
|
||||||
|
amount: money(row.amount), reason: row.reason
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('asset_id', 'Asset ID'), col('description', 'Description'), col('type', 'Type'),
|
||||||
|
col('book_type', 'Book'), col('adjustment_date', 'Date', 'date'), col('amount', 'Amount', 'currency'), col('reason', 'Reason')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['amount'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rollforward(options) {
|
||||||
|
const book = options.book || 'GAAP';
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const rules = ruleSetForBook(book);
|
||||||
|
const groups = {};
|
||||||
|
for (const { asset, book: bk } of assetBookRows(book, options).map(toAssetBook)) {
|
||||||
|
const c = computeYear(asset, bk, rules, year);
|
||||||
|
const key = asset.category || 'Uncategorized';
|
||||||
|
groups[key] = groups[key] || { category: key, beginning_nbv: 0, additions: 0, depreciation: 0, disposals: 0, ending_nbv: 0 };
|
||||||
|
groups[key].beginning_nbv += c.nbv_begin;
|
||||||
|
groups[key].depreciation += c.depreciation;
|
||||||
|
if (yearOf(asset.acquired_date) === year) groups[key].additions += c.cost;
|
||||||
|
groups[key].ending_nbv += c.nbv_end;
|
||||||
|
}
|
||||||
|
for (const row of disposalRows(year, options.entity_id)) {
|
||||||
|
const key = row.category || 'Uncategorized';
|
||||||
|
groups[key] = groups[key] || { category: key, beginning_nbv: 0, additions: 0, depreciation: 0, disposals: 0, ending_nbv: 0 };
|
||||||
|
groups[key].disposals += Number(row.net_book_value || 0);
|
||||||
|
}
|
||||||
|
const rows = Object.values(groups).map((row) => ({
|
||||||
|
category: row.category,
|
||||||
|
beginning_nbv: money(row.beginning_nbv),
|
||||||
|
additions: money(row.additions),
|
||||||
|
depreciation: money(row.depreciation),
|
||||||
|
disposals: money(row.disposals),
|
||||||
|
ending_nbv: money(row.ending_nbv)
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('category', 'Category'), col('beginning_nbv', 'Beginning NBV', 'currency'),
|
||||||
|
col('additions', 'Additions', 'currency'), col('depreciation', 'Depreciation', 'currency'),
|
||||||
|
col('disposals', 'Disposals (NBV)', 'currency'), col('ending_nbv', 'Ending NBV', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['beginning_nbv', 'additions', 'depreciation', 'disposals', 'ending_nbv'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function projection(options) {
|
||||||
|
const book = options.book || 'GAAP';
|
||||||
|
const startYear = Number(options.startYear) || currentYear();
|
||||||
|
const endYear = Number(options.endYear) || startYear + 5;
|
||||||
|
const rules = ruleSetForBook(book);
|
||||||
|
const items = assetBookRows(book, options).map(toAssetBook);
|
||||||
|
const rows = [];
|
||||||
|
for (let year = startYear; year <= endYear; year += 1) {
|
||||||
|
let depreciation = 0;
|
||||||
|
let nbv = 0;
|
||||||
|
for (const { asset, book: bk } of items) {
|
||||||
|
const c = computeYear(asset, bk, rules, year);
|
||||||
|
depreciation += c.depreciation;
|
||||||
|
nbv += c.nbv_end;
|
||||||
|
}
|
||||||
|
rows.push({ year, depreciation: money(depreciation), ending_nbv: money(nbv) });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
columns: [col('year', 'Year', 'number'), col('depreciation', 'Projected depreciation', 'currency'), col('ending_nbv', 'Ending NBV', 'currency')],
|
||||||
|
rows,
|
||||||
|
totals: { depreciation: money(rows.reduce((sum, row) => sum + row.depreciation, 0)) },
|
||||||
|
chart: { type: 'bar', labels: rows.map((r) => String(r.year)), values: rows.map((r) => r.depreciation) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function journalDepreciation(options) {
|
||||||
|
const book = options.book || 'GAAP';
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const rules = ruleSetForBook(book);
|
||||||
|
const accounts = {};
|
||||||
|
for (const { asset, book: bk } of assetBookRows(book, options).map(toAssetBook)) {
|
||||||
|
const c = computeYear(asset, bk, rules, year);
|
||||||
|
if (!c.depreciation) continue;
|
||||||
|
const expense = asset.gl_expense_account || '6400';
|
||||||
|
const accum = asset.gl_accumulated_account || '1590';
|
||||||
|
accounts[expense] = (accounts[expense] || 0) + c.depreciation;
|
||||||
|
accounts[`__accum__${accum}`] = (accounts[`__accum__${accum}`] || 0) + c.depreciation;
|
||||||
|
}
|
||||||
|
const rows = [];
|
||||||
|
for (const [key, amount] of Object.entries(accounts)) {
|
||||||
|
if (key.startsWith('__accum__')) {
|
||||||
|
rows.push({ account: key.replace('__accum__', ''), memo: 'Accumulated depreciation', debit: 0, credit: money(amount) });
|
||||||
|
} else {
|
||||||
|
rows.push({ account: key, memo: 'Depreciation expense', debit: money(amount), credit: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.sort((a, b) => b.debit - a.debit);
|
||||||
|
return {
|
||||||
|
columns: [col('account', 'GL account'), col('memo', 'Memo'), col('debit', 'Debit', 'currency'), col('credit', 'Credit', 'currency')],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['debit', 'credit'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function journalDisposals(options) {
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const rows = [];
|
||||||
|
for (const d of disposalRows(year, options.entity_id)) {
|
||||||
|
const proceeds = money(d.disposal_price - d.disposal_expense);
|
||||||
|
rows.push({ asset_id: d.asset_id, memo: 'Cash / proceeds', debit: proceeds, credit: 0 });
|
||||||
|
rows.push({ asset_id: d.asset_id, memo: 'Accumulated depreciation', debit: money(d.accumulated_depreciation), credit: 0 });
|
||||||
|
rows.push({ asset_id: d.asset_id, memo: 'Asset cost', debit: 0, credit: money(d.disposed_cost) });
|
||||||
|
if (d.gain_loss >= 0) rows.push({ asset_id: d.asset_id, memo: 'Gain on disposal', debit: 0, credit: money(d.gain_loss) });
|
||||||
|
else rows.push({ asset_id: d.asset_id, memo: 'Loss on disposal', debit: money(Math.abs(d.gain_loss)), credit: 0 });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
columns: [col('asset_id', 'Asset ID'), col('memo', 'Memo'), col('debit', 'Debit', 'currency'), col('credit', 'Credit', 'currency')],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['debit', 'credit'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function form4562(options) {
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const rules = ruleSetForBook('FEDERAL');
|
||||||
|
const items = assetBookRows('FEDERAL', options).map(toAssetBook)
|
||||||
|
.filter(({ asset }) => yearOf(asset.in_service_date || asset.acquired_date) === year);
|
||||||
|
let section179 = 0;
|
||||||
|
let bonus = 0;
|
||||||
|
let macrs = 0;
|
||||||
|
const rows = items.map(({ asset, book: bk }) => {
|
||||||
|
const c = computeYear(asset, bk, rules, year);
|
||||||
|
const s179 = Math.min(Number(bk.cost ?? asset.acquisition_cost ?? 0), Number(bk.section_179_amount || 0));
|
||||||
|
section179 += s179;
|
||||||
|
macrs += c.depreciation;
|
||||||
|
return {
|
||||||
|
asset_id: asset.asset_id, description: asset.description, method: c.methodLabel,
|
||||||
|
cost: c.cost, section_179: money(s179), depreciation: c.depreciation
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('asset_id', 'Asset ID'), col('description', 'Description'), col('method', 'Method'),
|
||||||
|
col('cost', 'Cost', 'currency'), col('section_179', '§179', 'currency'), col('depreciation', 'Depreciation', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: { section_179: money(section179), bonus: money(bonus), depreciation: money(macrs), cost: money(rows.reduce((s, r) => s + r.cost, 0)) },
|
||||||
|
meta: { note: 'Summary for IRS Form 4562 (Federal book, current-year placements). Validate against current tax law before filing.' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function form4797(options) {
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const rows = disposalRows(year, options.entity_id).map((d) => ({
|
||||||
|
asset_id: d.asset_id,
|
||||||
|
description: d.description,
|
||||||
|
property_type: d.property_type || 'other',
|
||||||
|
acquired_date: d.acquired_date,
|
||||||
|
disposal_date: d.disposal_date,
|
||||||
|
proceeds: money(d.disposal_price - d.disposal_expense),
|
||||||
|
cost: money(d.disposed_cost),
|
||||||
|
accumulated: money(d.accumulated_depreciation),
|
||||||
|
gain_loss: money(d.gain_loss)
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('asset_id', 'Asset ID'), col('description', 'Description'), col('property_type', 'Property type'),
|
||||||
|
col('acquired_date', 'Acquired', 'date'), col('disposal_date', 'Sold', 'date'),
|
||||||
|
col('proceeds', 'Proceeds', 'currency'), col('cost', 'Cost basis', 'currency'),
|
||||||
|
col('accumulated', 'Depr. allowed', 'currency'), col('gain_loss', 'Gain / (loss)', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['proceeds', 'cost', 'accumulated', 'gain_loss']),
|
||||||
|
meta: { note: 'Summary for IRS Form 4797 (Sales of Business Property). §1245/§1250 recapture analysis should be reviewed before filing.' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function personalPropertyTax(options) {
|
||||||
|
const book = options.book || 'GAAP';
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const rules = ruleSetForBook(book);
|
||||||
|
const groups = {};
|
||||||
|
for (const { asset, book: bk } of assetBookRows(book, options).map(toAssetBook)) {
|
||||||
|
const c = computeYear(asset, bk, rules, year);
|
||||||
|
const key = asset.location || 'Unassigned';
|
||||||
|
groups[key] = groups[key] || { location: key, count: 0, cost: 0, nbv: 0 };
|
||||||
|
groups[key].count += 1;
|
||||||
|
groups[key].cost += c.cost;
|
||||||
|
groups[key].nbv += c.nbv_end;
|
||||||
|
}
|
||||||
|
const rows = Object.values(groups).map((row) => ({ ...row, cost: money(row.cost), nbv: money(row.nbv) }));
|
||||||
|
return {
|
||||||
|
columns: [col('location', 'Location'), col('count', 'Assets', 'number'), col('cost', 'Cost', 'currency'), col('nbv', 'Taxable NBV', 'currency')],
|
||||||
|
rows,
|
||||||
|
totals: { count: rows.reduce((s, r) => s + r.count, 0), ...sumBy(rows, ['cost', 'nbv']) }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function constructionInProgress(options) {
|
||||||
|
const rows = all(
|
||||||
|
`SELECT asset_id, description, category, acquired_date, acquisition_cost, vendor
|
||||||
|
FROM assets WHERE status = 'cip' AND (? IS NULL OR entity_id = ?) ORDER BY acquired_date`,
|
||||||
|
[options.entity_id || null, options.entity_id || null]
|
||||||
|
).map((row) => ({ ...row, acquisition_cost: money(row.acquisition_cost) }));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('asset_id', 'Project ID'), col('description', 'Description'), col('category', 'Category'),
|
||||||
|
col('acquired_date', 'Started', 'date'), col('vendor', 'Vendor'), col('acquisition_cost', 'Cost to date', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['acquisition_cost'])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ubia(options) {
|
||||||
|
const rows = all(
|
||||||
|
`SELECT asset_id, description, in_service_date, acquisition_cost, land_value
|
||||||
|
FROM assets WHERE status != 'disposed' AND (? IS NULL OR entity_id = ?) ORDER BY asset_id`,
|
||||||
|
[options.entity_id || null, options.entity_id || null]
|
||||||
|
).map((row) => ({
|
||||||
|
asset_id: row.asset_id, description: row.description, in_service_date: row.in_service_date,
|
||||||
|
ubia: money(Number(row.acquisition_cost || 0) - Number(row.land_value || 0))
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('asset_id', 'Asset ID'), col('description', 'Description'),
|
||||||
|
col('in_service_date', 'In service', 'date'), col('ubia', 'UBIA', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['ubia']),
|
||||||
|
meta: { note: 'Unadjusted Basis Immediately After Acquisition for the §199A qualified business income limitation.' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixedAssetCard(options) {
|
||||||
|
const asset = options.asset_id
|
||||||
|
? assetFromRow(one('SELECT * FROM assets WHERE id = ? OR asset_id = ? LIMIT 1', [options.asset_id, options.asset_id]))
|
||||||
|
: null;
|
||||||
|
if (!asset) return { columns: [], rows: [], totals: {}, meta: { note: 'Select an asset to view its card.' } };
|
||||||
|
const year = Number(options.year) || currentYear();
|
||||||
|
const books = all('SELECT * FROM asset_books WHERE asset_id = ? AND active = 1 ORDER BY book_type', [asset.id]);
|
||||||
|
const rows = books.map((bookRow) => {
|
||||||
|
const book = { ...bookRow, useful_life_months: bookRow.useful_life_months, manual_depreciation: bookRow.manual_depreciation };
|
||||||
|
const rules = ruleSetForBook(bookRow.book_type);
|
||||||
|
const c = computeYear(asset, book, rules, year);
|
||||||
|
return {
|
||||||
|
book: bookRow.book_type, method: c.methodLabel, cost: c.cost,
|
||||||
|
depreciation: c.depreciation, accumulated: c.accumulated_end, nbv: c.nbv_end
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('book', 'Book'), col('method', 'Method'), col('cost', 'Cost', 'currency'),
|
||||||
|
col('depreciation', `${year} depr.`, 'currency'), col('accumulated', 'Accum. depr.', 'currency'), col('nbv', 'Net book value', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['cost', 'depreciation', 'accumulated', 'nbv']),
|
||||||
|
meta: {
|
||||||
|
asset: {
|
||||||
|
asset_id: asset.asset_id, description: asset.description, category: asset.category, status: asset.status,
|
||||||
|
vendor: asset.vendor, serial_number: asset.serial_number, location: asset.location,
|
||||||
|
department: asset.department, custodian: asset.custodian, acquired_date: asset.acquired_date,
|
||||||
|
in_service_date: asset.in_service_date, barcode_value: asset.barcode_value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Report Builder (custom) ----------------------------------------------
|
||||||
|
|
||||||
|
const BUILDER_FIELDS = [
|
||||||
|
col('asset_id', 'Asset ID'), col('description', 'Description'), col('category', 'Category'),
|
||||||
|
col('status', 'Status'), col('entity_name', 'Entity'), col('location', 'Location'),
|
||||||
|
col('department', 'Department'), col('group_name', 'Group'), col('custodian', 'Custodian'),
|
||||||
|
col('vendor', 'Vendor'), col('serial_number', 'Serial'), col('invoice_number', 'Invoice'),
|
||||||
|
col('barcode_value', 'Barcode'), col('condition', 'Condition'), col('new_or_used', 'New/Used'),
|
||||||
|
col('acquisition_cost', 'Cost', 'currency'), col('salvage_value', 'Salvage', 'currency'),
|
||||||
|
col('land_value', 'Land value', 'currency'), col('useful_life_months', 'Life (months)', 'number'),
|
||||||
|
col('acquired_date', 'Acquired', 'date'), col('in_service_date', 'In service', 'date'),
|
||||||
|
col('gl_asset_account', 'Asset GL'), col('gl_expense_account', 'Expense GL'), col('gl_accumulated_account', 'Accum. GL')
|
||||||
|
];
|
||||||
|
|
||||||
|
function customReport(options) {
|
||||||
|
const requested = Array.isArray(options.columns) && options.columns.length
|
||||||
|
? options.columns
|
||||||
|
: ['asset_id', 'description', 'category', 'status', 'acquisition_cost'];
|
||||||
|
const columns = BUILDER_FIELDS.filter((field) => requested.includes(field.key));
|
||||||
|
const rows = 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.entity_id = ?)
|
||||||
|
AND (? IS NULL OR a.category = ?)
|
||||||
|
AND (? IS NULL OR a.status = ?)
|
||||||
|
ORDER BY a.asset_id`,
|
||||||
|
[
|
||||||
|
options.entity_id || null, options.entity_id || null,
|
||||||
|
options.category || null, options.category || null,
|
||||||
|
options.status || null, options.status || null
|
||||||
|
]
|
||||||
|
).map((row) => {
|
||||||
|
const projected = {};
|
||||||
|
for (const column of columns) projected[column.key] = row[column.key];
|
||||||
|
return projected;
|
||||||
|
});
|
||||||
|
const currencyKeys = columns.filter((c) => c.format === 'currency').map((c) => c.key);
|
||||||
|
return { columns, rows, totals: sumBy(rows, currencyKeys) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Maintenance & alerts --------------------------------------------------
|
||||||
|
|
||||||
|
function dateStr(date) {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function freqLabel(value, unit) {
|
||||||
|
return `every ${value} ${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pmDue(options) {
|
||||||
|
const before = options.before_date || dateStr(new Date(Date.now() + 30 * 86400000));
|
||||||
|
const rows = all(
|
||||||
|
`SELECT ap.next_due_date, ap.last_completed_date, ap.frequency_value, ap.frequency_unit,
|
||||||
|
p.name AS plan_name, a.asset_id AS asset_code, a.description AS asset_description, u.name AS assigned_to_name
|
||||||
|
FROM asset_pm_plans ap
|
||||||
|
JOIN assets a ON a.id = ap.asset_id
|
||||||
|
LEFT JOIN pm_plans p ON p.id = ap.plan_id
|
||||||
|
LEFT JOIN users u ON u.id = ap.assigned_to
|
||||||
|
WHERE ap.active = 1 AND ap.next_due_date IS NOT NULL AND ap.next_due_date <= ?
|
||||||
|
AND (? IS NULL OR a.entity_id = ?)
|
||||||
|
ORDER BY ap.next_due_date`,
|
||||||
|
[before, options.entity_id || null, options.entity_id || null]
|
||||||
|
).map((row) => ({ ...row, plan_name: row.plan_name || 'Maintenance', frequency: freqLabel(row.frequency_value, row.frequency_unit) }));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('asset_code', 'Asset'), col('asset_description', 'Description'), col('plan_name', 'Plan'),
|
||||||
|
col('frequency', 'Frequency'), col('next_due_date', 'Next due', 'date'), col('last_completed_date', 'Last done', 'date'),
|
||||||
|
col('assigned_to_name', 'Assigned to')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: {},
|
||||||
|
meta: { note: `${rows.length} PM schedule(s) due on or before ${before}.` }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pmUncompleted(options) {
|
||||||
|
const today = dateStr(new Date());
|
||||||
|
const rows = all(
|
||||||
|
`SELECT ap.next_due_date, ap.last_completed_date, ap.frequency_value, ap.frequency_unit,
|
||||||
|
p.name AS plan_name, a.asset_id AS asset_code, a.description AS asset_description, u.name AS assigned_to_name
|
||||||
|
FROM asset_pm_plans ap
|
||||||
|
JOIN assets a ON a.id = ap.asset_id
|
||||||
|
LEFT JOIN pm_plans p ON p.id = ap.plan_id
|
||||||
|
LEFT JOIN users u ON u.id = ap.assigned_to
|
||||||
|
WHERE ap.active = 1 AND ap.next_due_date IS NOT NULL AND ap.next_due_date < ?
|
||||||
|
AND (? IS NULL OR a.entity_id = ?)
|
||||||
|
ORDER BY ap.next_due_date`,
|
||||||
|
[today, options.entity_id || null, options.entity_id || null]
|
||||||
|
).map((row) => ({
|
||||||
|
...row,
|
||||||
|
plan_name: row.plan_name || 'Maintenance',
|
||||||
|
frequency: freqLabel(row.frequency_value, row.frequency_unit),
|
||||||
|
days_overdue: Math.max(0, Math.round((new Date(`${today}T00:00:00`) - new Date(`${row.next_due_date}T00:00:00`)) / 86400000))
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('asset_code', 'Asset'), col('asset_description', 'Description'), col('plan_name', 'Plan'),
|
||||||
|
col('next_due_date', 'Was due', 'date'), col('days_overdue', 'Days overdue', 'number'),
|
||||||
|
col('last_completed_date', 'Last done', 'date'), col('assigned_to_name', 'Assigned to')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: {},
|
||||||
|
meta: { note: `${rows.length} asset PM schedule(s) overdue / uncompleted as of ${today}.` }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pmCompleted(options) {
|
||||||
|
const since = options.since_date || dateStr(new Date(Date.now() - 30 * 86400000));
|
||||||
|
const rows = all(
|
||||||
|
`SELECT c.completed_at, c.cost, c.rating_safety, c.rating_physical, c.rating_operating,
|
||||||
|
CASE WHEN c.signature IS NOT NULL THEN 'yes' ELSE 'no' END AS signed,
|
||||||
|
(SELECT COUNT(*) FROM pm_completion_photos ph WHERE ph.completion_id = c.id) AS photos,
|
||||||
|
u.name AS completed_by_name, a.asset_id AS asset_code, p.name AS plan_name
|
||||||
|
FROM pm_completions c
|
||||||
|
JOIN asset_pm_plans ap ON ap.id = c.asset_pm_id
|
||||||
|
JOIN assets a ON a.id = ap.asset_id
|
||||||
|
LEFT JOIN pm_plans p ON p.id = ap.plan_id
|
||||||
|
LEFT JOIN users u ON u.id = c.completed_by
|
||||||
|
WHERE c.completed_at >= ?
|
||||||
|
AND (? IS NULL OR a.entity_id = ?)
|
||||||
|
ORDER BY c.completed_at DESC`,
|
||||||
|
[since, options.entity_id || null, options.entity_id || null]
|
||||||
|
).map((row) => ({ ...row, plan_name: row.plan_name || 'Maintenance', completed_at: String(row.completed_at).slice(0, 10), cost: money(row.cost) }));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('asset_code', 'Asset'), col('plan_name', 'Plan'), col('completed_at', 'Completed', 'date'),
|
||||||
|
col('completed_by_name', 'By'), col('rating_safety', 'Safety', 'number'), col('rating_operating', 'Operating', 'number'),
|
||||||
|
col('photos', 'Photos', 'number'), col('signed', 'Signed'), col('cost', 'Cost', 'currency')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: sumBy(rows, ['cost']),
|
||||||
|
meta: { note: `${rows.length} PM service(s) completed since ${since}.` }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pmPlanPrint(options) {
|
||||||
|
const plan = options.pm_plan_id ? one('SELECT * FROM pm_plans WHERE id = ?', [options.pm_plan_id]) : null;
|
||||||
|
if (!plan) return { columns: [], rows: [], totals: {}, meta: { note: 'Select a PM plan to print.' } };
|
||||||
|
const steps = all('SELECT step_order, title, details, est_minutes FROM pm_plan_steps WHERE plan_id = ? ORDER BY step_order, id', [plan.id]);
|
||||||
|
const components = all('SELECT part_number, description, supplier, cost FROM pm_plan_components WHERE plan_id = ? ORDER BY sort_order, id', [plan.id]);
|
||||||
|
const rows = steps.map((step, index) => ({ step: index + 1, title: step.title, details: step.details, est_minutes: step.est_minutes }));
|
||||||
|
const estTotal = steps.reduce((sum, step) => sum + (Number(step.est_minutes) || 0), 0);
|
||||||
|
const partsTotal = components.reduce((sum, comp) => sum + (Number(comp.cost) || 0), 0);
|
||||||
|
const partsLines = components.map((comp) => `${comp.part_number ? comp.part_number + ' ' : ''}${comp.description || ''}${comp.supplier ? ` (${comp.supplier})` : ''} — ${money(comp.cost)}`).join('; ');
|
||||||
|
const noteParts = [
|
||||||
|
`Frequency: ${freqLabel(plan.frequency_value, plan.frequency_unit)}`,
|
||||||
|
`Estimated time: ${estTotal} min`
|
||||||
|
];
|
||||||
|
if (plan.description) noteParts.push(`Description: ${plan.description}`);
|
||||||
|
if (plan.instructions) noteParts.push(`Instructions: ${plan.instructions}`);
|
||||||
|
if (components.length) noteParts.push(`Parts (${money(partsTotal)}): ${partsLines}`);
|
||||||
|
return {
|
||||||
|
title: `PM plan — ${plan.name}`,
|
||||||
|
columns: [col('step', '#', 'number'), col('title', 'Step'), col('details', 'Details'), col('est_minutes', 'Est. min', 'number')],
|
||||||
|
rows,
|
||||||
|
totals: { est_minutes: estTotal },
|
||||||
|
meta: { note: noteParts.join(' · ') }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertsSince(options) {
|
||||||
|
const since = options.since_date || dateStr(new Date(Date.now() - 30 * 86400000));
|
||||||
|
const rows = all(
|
||||||
|
`SELECT al.severity, al.type, al.title, al.due_date, al.status, al.created_at, a.asset_id AS asset_code
|
||||||
|
FROM alerts al LEFT JOIN assets a ON a.id = al.asset_id
|
||||||
|
WHERE al.created_at >= ?
|
||||||
|
ORDER BY al.created_at DESC`,
|
||||||
|
[since]
|
||||||
|
).map((row) => ({ ...row, type: String(row.type).replace(/_/g, ' '), created_at: String(row.created_at).slice(0, 10) }));
|
||||||
|
return {
|
||||||
|
columns: [
|
||||||
|
col('severity', 'Severity'), col('type', 'Type'), col('title', 'Alert'), col('asset_code', 'Asset'),
|
||||||
|
col('due_date', 'Due', 'date'), col('status', 'Status'), col('created_at', 'Raised', 'date')
|
||||||
|
],
|
||||||
|
rows,
|
||||||
|
totals: {},
|
||||||
|
meta: { note: `${rows.length} alert(s) raised since ${since}.` }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Registry --------------------------------------------------------------
|
||||||
|
|
||||||
|
const REPORTS = {
|
||||||
|
depreciation_expense: { title: 'Annual depreciation expense', group: 'Depreciation', params: ['book', 'year', 'entity_id'], build: (o) => depreciationExpense(o) },
|
||||||
|
monthly_depreciation: { title: '12-month depreciation expense', group: 'Depreciation', params: ['book', 'year', 'entity_id'], build: (o) => monthlyDepreciation(o) },
|
||||||
|
quarterly_depreciation: { title: 'Quarterly depreciation expense', group: 'Depreciation', params: ['book', 'year', 'entity_id'], build: (o) => quarterlyDepreciation(o) },
|
||||||
|
ytd_depreciation: { title: 'Annual & YTD depreciation', group: 'Depreciation', params: ['book', 'year', 'month', 'entity_id'], build: (o) => ytdDepreciation(o) },
|
||||||
|
net_book_value: { title: 'Net book value', group: 'Depreciation', params: ['book', 'year', 'entity_id'], build: (o) => netBookValue(o) },
|
||||||
|
lifetime_depreciation: { title: 'Lifetime depreciation', group: 'Depreciation', params: ['book', 'entity_id'], build: (o) => lifetimeDepreciation(o) },
|
||||||
|
projection: { title: 'Projection', group: 'Depreciation', params: ['book', 'startYear', 'endYear', 'entity_id'], build: (o) => projection(o) },
|
||||||
|
rollforward: { title: 'Annual rollforward', group: 'Activity', params: ['book', 'year', 'entity_id'], build: (o) => rollforward(o) },
|
||||||
|
acquisitions: { title: 'Annual acquisitions', group: 'Activity', params: ['year', 'entity_id'], build: (o) => acquisitions(o) },
|
||||||
|
disposals: { title: 'Annual disposals & gain/loss', group: 'Activity', params: ['year', 'entity_id'], build: (o) => disposals(o) },
|
||||||
|
adjustments: { title: 'Annual adjustments', group: 'Activity', params: ['year', 'entity_id'], build: (o) => adjustments(o) },
|
||||||
|
cip: { title: 'Construction-in-progress', group: 'Activity', params: ['entity_id'], build: (o) => constructionInProgress(o) },
|
||||||
|
journal_depreciation: { title: 'Journal entry: depreciation', group: 'Journal entries', params: ['book', 'year', 'entity_id'], build: (o) => journalDepreciation(o) },
|
||||||
|
journal_disposals: { title: 'Journal entry: disposals', group: 'Journal entries', params: ['year', 'entity_id'], build: (o) => journalDisposals(o) },
|
||||||
|
form_4562: { title: 'Federal Form 4562 (depreciation)', group: 'Tax', params: ['year', 'entity_id'], build: (o) => form4562(o) },
|
||||||
|
form_4797: { title: 'Federal Form 4797 (sales)', group: 'Tax', params: ['year', 'entity_id'], build: (o) => form4797(o) },
|
||||||
|
amt: { title: 'AMT depreciation', group: 'Tax', params: ['year', 'entity_id'], build: (o) => depreciationExpense(o, 'AMT') },
|
||||||
|
ace: { title: 'ACE depreciation', group: 'Tax', params: ['year', 'entity_id'], build: (o) => depreciationExpense(o, 'ACE') },
|
||||||
|
personal_property_tax: { title: 'Personal property tax', group: 'Tax', params: ['book', 'year', 'entity_id'], build: (o) => personalPropertyTax(o) },
|
||||||
|
ubia: { title: 'UBIA (§199A)', group: 'Tax', params: ['entity_id'], build: (o) => ubia(o) },
|
||||||
|
pm_due: { title: 'PM due before date', group: 'Maintenance', params: ['before_date', 'entity_id'], build: (o) => pmDue(o) },
|
||||||
|
pm_uncompleted: { title: 'Assets with uncompleted PM', group: 'Maintenance', params: ['entity_id'], build: (o) => pmUncompleted(o) },
|
||||||
|
pm_completed: { title: 'PM completed since date', group: 'Maintenance', params: ['since_date', 'entity_id'], build: (o) => pmCompleted(o) },
|
||||||
|
pm_plan: { title: 'PM plan (printable)', group: 'Maintenance', params: ['pm_plan_id'], build: (o) => pmPlanPrint(o) },
|
||||||
|
alerts_since: { title: 'Alerts since date', group: 'Maintenance', params: ['since_date'], build: (o) => alertsSince(o) },
|
||||||
|
fixed_asset_card: { title: 'Fixed asset card', group: 'Detail', params: ['asset_id', 'year'], build: (o) => fixedAssetCard(o) },
|
||||||
|
custom: { title: 'Report builder (custom)', group: 'Detail', params: ['columns', 'entity_id', 'category', 'status'], build: (o) => customReport(o) }
|
||||||
|
};
|
||||||
|
|
||||||
|
function paramSpecs() {
|
||||||
|
const entities = all('SELECT id, name FROM entities ORDER BY name').map((row) => ({ title: row.name, value: row.id }));
|
||||||
|
const assets = all('SELECT id, asset_id, description FROM assets ORDER BY asset_id LIMIT 1000')
|
||||||
|
.map((row) => ({ title: `${row.asset_id} · ${row.description}`, value: row.id }));
|
||||||
|
const categories = all('SELECT DISTINCT category FROM assets ORDER BY category').map((row) => row.category).filter(Boolean);
|
||||||
|
const statuses = all('SELECT DISTINCT status FROM assets ORDER BY status').map((row) => row.status).filter(Boolean);
|
||||||
|
const year = currentYear();
|
||||||
|
const pmPlanOptions = all('SELECT id, name FROM pm_plans ORDER BY name').map((row) => ({ title: row.name, value: row.id }));
|
||||||
|
const today = new Date();
|
||||||
|
const bookCodes = (() => {
|
||||||
|
try {
|
||||||
|
const rows = all("SELECT code FROM books WHERE book_type != 'maintenance' ORDER BY sort_order, id");
|
||||||
|
if (rows.length) return rows.map((row) => row.code);
|
||||||
|
} catch {
|
||||||
|
// books table may be unavailable
|
||||||
|
}
|
||||||
|
return BOOKS;
|
||||||
|
})();
|
||||||
|
return {
|
||||||
|
book: { label: 'Book', type: 'select', options: bookCodes, default: bookCodes[0] || 'GAAP' },
|
||||||
|
year: { label: 'Year', type: 'number', default: year },
|
||||||
|
month: { label: 'Through month', type: 'select', options: Array.from({ length: 12 }, (_, i) => i + 1), default: 12 },
|
||||||
|
startYear: { label: 'Start year', type: 'number', default: year },
|
||||||
|
endYear: { label: 'End year', type: 'number', default: year + 5 },
|
||||||
|
entity_id: { label: 'Entity', type: 'select', options: entities, clearable: true, default: null },
|
||||||
|
category: { label: 'Category', type: 'select', options: categories, clearable: true, default: null },
|
||||||
|
status: { label: 'Status', type: 'select', options: statuses, clearable: true, default: null },
|
||||||
|
asset_id: { label: 'Asset', type: 'select', options: assets, default: null },
|
||||||
|
columns: { label: 'Columns', type: 'multiselect', options: BUILDER_FIELDS.map((f) => ({ title: f.label, value: f.key })), default: ['asset_id', 'description', 'category', 'status', 'acquisition_cost'] },
|
||||||
|
before_date: { label: 'Due before', type: 'date', default: dateStr(new Date(today.getTime() + 30 * 86400000)) },
|
||||||
|
since_date: { label: 'Since', type: 'date', default: dateStr(new Date(today.getTime() - 30 * 86400000)) },
|
||||||
|
pm_plan_id: { label: 'PM plan', type: 'select', options: pmPlanOptions, default: pmPlanOptions[0]?.value || null }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalog() {
|
||||||
|
const reports = Object.entries(REPORTS).map(([key, def]) => ({ key, title: def.title, group: def.group, params: def.params }));
|
||||||
|
return { reports, params: paramSpecs() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function runReport(type, options = {}) {
|
||||||
|
const def = REPORTS[type];
|
||||||
|
if (!def) {
|
||||||
|
const error = new Error(`Unknown report type: ${type}`);
|
||||||
|
error.status = 400;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const result = def.build(options);
|
||||||
|
return { type, title: def.title, group: def.group, generatedAt: now(), options, ...result };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { BOOKS, catalog, computeYear, runReport };
|
||||||
123
server/services/reportExport.js
Normal file
123
server/services/reportExport.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
const PDFDocument = require('pdfkit');
|
||||||
|
const Papa = require('papaparse');
|
||||||
|
const writeXlsxFile = require('write-excel-file/node');
|
||||||
|
|
||||||
|
function formatValue(value, format) {
|
||||||
|
if (value === null || value === undefined || value === '') return '';
|
||||||
|
if (format === 'currency') return Number(value).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||||
|
if (format === 'number') return Number(value).toLocaleString('en-US');
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tableMatrix(report) {
|
||||||
|
const header = report.columns.map((column) => column.label);
|
||||||
|
const body = report.rows.map((row) => report.columns.map((column) => {
|
||||||
|
const value = row[column.key];
|
||||||
|
return column.format === 'currency' || column.format === 'number' ? Number(value || 0) : (value ?? '');
|
||||||
|
}));
|
||||||
|
return { header, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalsRow(report) {
|
||||||
|
if (!report.totals || !Object.keys(report.totals).length) return null;
|
||||||
|
return report.columns.map((column, index) => {
|
||||||
|
if (index === 0) return 'Totals';
|
||||||
|
if (Object.prototype.hasOwnProperty.call(report.totals, column.key)) return report.totals[column.key];
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCsv(report) {
|
||||||
|
const { header, body } = tableMatrix(report);
|
||||||
|
const totals = totalsRow(report);
|
||||||
|
const rows = totals ? [...body, totals] : body;
|
||||||
|
return {
|
||||||
|
contentType: 'text/csv',
|
||||||
|
body: Papa.unparse([header, ...rows])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportXlsx(report) {
|
||||||
|
const { header } = tableMatrix(report);
|
||||||
|
const totals = totalsRow(report);
|
||||||
|
const data = [
|
||||||
|
header.map((label) => ({ value: label, fontWeight: 'bold' })),
|
||||||
|
...report.rows.map((row) => report.columns.map((column) => {
|
||||||
|
const value = row[column.key];
|
||||||
|
if (column.format === 'currency' || column.format === 'number') {
|
||||||
|
return { type: Number, value: Number(value || 0), format: column.format === 'currency' ? '#,##0.00' : '#,##0' };
|
||||||
|
}
|
||||||
|
return { value: value === null || value === undefined ? '' : String(value) };
|
||||||
|
}))
|
||||||
|
];
|
||||||
|
if (totals) {
|
||||||
|
data.push(totals.map((value, index) => {
|
||||||
|
if (index === 0) return { value: 'Totals', fontWeight: 'bold' };
|
||||||
|
return typeof value === 'number'
|
||||||
|
? { type: Number, value, format: '#,##0.00', fontWeight: 'bold' }
|
||||||
|
: { value: String(value || ''), fontWeight: 'bold' };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
body: await writeXlsxFile(data).toBuffer()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPdf(report) {
|
||||||
|
const doc = new PDFDocument({ margin: 36, size: 'LETTER', layout: 'landscape' });
|
||||||
|
const chunks = [];
|
||||||
|
doc.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
|
||||||
|
doc.fontSize(16).text(report.title || 'MixedAssets report');
|
||||||
|
doc.fontSize(9).fillColor('#555').text(
|
||||||
|
`Generated ${new Date(report.generatedAt || Date.now()).toLocaleString()}` +
|
||||||
|
(report.options ? ` · ${Object.entries(report.options).filter(([, v]) => v !== null && v !== undefined && v !== '').map(([k, v]) => `${k}: ${v}`).join(' · ')}` : '')
|
||||||
|
);
|
||||||
|
if (report.meta && report.meta.note) doc.moveDown(0.3).fontSize(8).fillColor('#777').text(report.meta.note);
|
||||||
|
doc.fillColor('#000').moveDown(0.6);
|
||||||
|
|
||||||
|
const pageWidth = doc.page.width - 72;
|
||||||
|
const columns = report.columns;
|
||||||
|
const colWidth = pageWidth / columns.length;
|
||||||
|
const drawRow = (cells, options = {}) => {
|
||||||
|
const y = doc.y;
|
||||||
|
doc.fontSize(options.bold ? 8.5 : 8);
|
||||||
|
cells.forEach((cell, index) => {
|
||||||
|
const column = columns[index];
|
||||||
|
const align = column.format === 'currency' || column.format === 'number' ? 'right' : 'left';
|
||||||
|
doc.text(String(cell ?? ''), 36 + index * colWidth, y, { width: colWidth - 6, align, ellipsis: true });
|
||||||
|
});
|
||||||
|
doc.moveDown(0.2);
|
||||||
|
if (doc.y > doc.page.height - 48) doc.addPage();
|
||||||
|
};
|
||||||
|
|
||||||
|
drawRow(columns.map((column) => column.label), { bold: true });
|
||||||
|
doc.moveTo(36, doc.y).lineTo(36 + pageWidth, doc.y).strokeColor('#ccc').stroke();
|
||||||
|
doc.moveDown(0.2);
|
||||||
|
|
||||||
|
for (const row of report.rows) {
|
||||||
|
drawRow(columns.map((column) => formatValue(row[column.key], column.format)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = totalsRow(report);
|
||||||
|
if (totals) {
|
||||||
|
doc.moveTo(36, doc.y).lineTo(36 + pageWidth, doc.y).strokeColor('#ccc').stroke();
|
||||||
|
doc.moveDown(0.2);
|
||||||
|
drawRow(totals.map((value, index) => (typeof value === 'number' ? formatValue(value, columns[index].format || 'currency') : value)), { bold: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
doc.on('end', () => resolve({ contentType: 'application/pdf', body: Buffer.concat(chunks) }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportReport(report, format) {
|
||||||
|
if (format === 'csv') return exportCsv(report);
|
||||||
|
if (format === 'xlsx') return exportXlsx(report);
|
||||||
|
if (format === 'pdf') return exportPdf(report);
|
||||||
|
return { contentType: 'application/json', body: JSON.stringify(report, null, 2) };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { exportReport };
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
const PDFDocument = require('pdfkit');
|
const PDFDocument = require('pdfkit');
|
||||||
const { all, one, parseJson } = require('../db');
|
const { all, one } = require('../db');
|
||||||
const { annualSchedule, money, monthlyFromAnnual } = require('../depreciation');
|
const { annualSchedule, money, monthlyFromAnnual } = require('../depreciation');
|
||||||
const { assetExportRows, assetFromRow } = require('./assets');
|
const { assetExportRows, assetFromRow } = require('./assets');
|
||||||
|
const { ruleSetForBook } = require('./ruleSets');
|
||||||
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) {
|
function depreciationReport(year, bookType) {
|
||||||
const rules = activeRuleSet();
|
const rules = ruleSetForBook(bookType);
|
||||||
const rows = all(`
|
const rows = all(`
|
||||||
SELECT a.*, b.book_type, b.active, b.cost, b.depreciation_method, b.convention, b.useful_life_months AS book_life,
|
SELECT a.*, b.book_type, b.active, b.cost, b.depreciation_method, b.convention, b.useful_life_months AS book_life,
|
||||||
b.section_179_amount, b.bonus_percent, b.business_use_percent AS book_business_use_percent,
|
b.section_179_amount, b.bonus_percent, b.business_use_percent AS book_business_use_percent,
|
||||||
@@ -59,7 +55,7 @@ function depreciationReport(year, bookType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function monthlyDepreciationReport(year, bookType) {
|
function monthlyDepreciationReport(year, bookType) {
|
||||||
const rules = activeRuleSet();
|
const rules = ruleSetForBook(bookType);
|
||||||
const assets = all('SELECT * FROM assets ORDER BY asset_id').map(assetFromRow);
|
const assets = all('SELECT * FROM assets ORDER BY asset_id').map(assetFromRow);
|
||||||
const rows = assets.flatMap((asset) => {
|
const rows = assets.flatMap((asset) => {
|
||||||
const book = one('SELECT * FROM asset_books WHERE asset_id = ? AND book_type = ? AND active = 1', [asset.id, bookType]);
|
const book = one('SELECT * FROM asset_books WHERE asset_id = ? AND book_type = ? AND active = 1', [asset.id, bookType]);
|
||||||
@@ -100,7 +96,6 @@ function writeDepreciationPdf(res, year, book) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
activeRuleSet,
|
|
||||||
depreciationReport,
|
depreciationReport,
|
||||||
monthlyDepreciationReport,
|
monthlyDepreciationReport,
|
||||||
writeDepreciationPdf
|
writeDepreciationPdf
|
||||||
|
|||||||
102
server/services/roles.js
Normal file
102
server/services/roles.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
const { all, audit, json, now, one, parseJson, run } = require('../db');
|
||||||
|
const { CAPABILITY_KEYS, WILDCARD, capabilitySetIncludes, sanitizeCapabilities } = require('./accessControl');
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.slice(0, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRow(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
key: row.key,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description || '',
|
||||||
|
capabilities: parseJson(row.capabilities_json, []),
|
||||||
|
is_system: Boolean(row.is_system),
|
||||||
|
locked: Boolean(row.locked),
|
||||||
|
user_count: one('SELECT COUNT(*) AS c FROM users WHERE role = ?', [row.key]).c
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listRoles() {
|
||||||
|
return all('SELECT * FROM roles ORDER BY sort_order, name').map(fromRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRole(key) {
|
||||||
|
return fromRow(one('SELECT * FROM roles WHERE key = ?', [key]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleExists(key) {
|
||||||
|
return Boolean(one('SELECT key FROM roles WHERE key = ?', [key]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capabilities granted to a role key (empty when the role is unknown).
|
||||||
|
function capabilitiesForRole(key) {
|
||||||
|
const row = one('SELECT capabilities_json FROM roles WHERE key = ?', [key]);
|
||||||
|
return row ? parseJson(row.capabilities_json, []) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleHasCapability(key, capability) {
|
||||||
|
return capabilitySetIncludes(capabilitiesForRole(key), capability);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRole(body, userId) {
|
||||||
|
const name = String(body.name || '').trim();
|
||||||
|
if (!name) throw Object.assign(new Error('A role name is required'), { status: 400 });
|
||||||
|
const key = slugify(body.key || name);
|
||||||
|
if (!key) throw Object.assign(new Error('A valid role key is required'), { status: 400 });
|
||||||
|
if (roleExists(key)) throw Object.assign(new Error('A role with that key already exists'), { status: 400 });
|
||||||
|
const capabilities = sanitizeCapabilities(body.capabilities);
|
||||||
|
const ts = now();
|
||||||
|
const sort = (one('SELECT MAX(sort_order) AS m FROM roles').m || 0) + 1;
|
||||||
|
run(
|
||||||
|
'INSERT INTO roles (key, name, description, capabilities_json, is_system, locked, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, 0, 0, ?, ?, ?)',
|
||||||
|
[key, name, body.description || null, json(capabilities), sort, ts, ts]
|
||||||
|
);
|
||||||
|
audit(userId, 'create', 'role', key, null, { name, capabilities });
|
||||||
|
return getRole(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRole(key, body, userId) {
|
||||||
|
const before = one('SELECT * FROM roles WHERE key = ?', [key]);
|
||||||
|
if (!before) return null;
|
||||||
|
const name = body.name !== undefined ? String(body.name).trim() || before.name : before.name;
|
||||||
|
// Locked roles (admin) always keep the wildcard capability set.
|
||||||
|
const capabilities = before.locked
|
||||||
|
? parseJson(before.capabilities_json, [WILDCARD])
|
||||||
|
: (body.capabilities !== undefined ? sanitizeCapabilities(body.capabilities) : parseJson(before.capabilities_json, []));
|
||||||
|
run(
|
||||||
|
'UPDATE roles SET name = ?, description = ?, capabilities_json = ?, updated_at = ? WHERE key = ?',
|
||||||
|
[name, body.description ?? before.description, json(capabilities), now(), key]
|
||||||
|
);
|
||||||
|
audit(userId, 'update', 'role', key, fromRow(before), { name, capabilities });
|
||||||
|
return getRole(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRole(key, userId) {
|
||||||
|
const before = one('SELECT * FROM roles WHERE key = ?', [key]);
|
||||||
|
if (!before) return { ok: false, status: 404, error: 'Role not found' };
|
||||||
|
if (before.is_system) return { ok: false, status: 400, error: 'Built-in roles cannot be deleted' };
|
||||||
|
const inUse = one('SELECT COUNT(*) AS c FROM users WHERE role = ?', [key]).c;
|
||||||
|
if (inUse) return { ok: false, status: 400, error: `Reassign the ${inUse} user(s) with this role before deleting it` };
|
||||||
|
run('DELETE FROM roles WHERE key = ?', [key]);
|
||||||
|
audit(userId, 'delete', 'role', key, fromRow(before), null);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CAPABILITY_KEYS,
|
||||||
|
capabilitiesForRole,
|
||||||
|
createRole,
|
||||||
|
deleteRole,
|
||||||
|
getRole,
|
||||||
|
listRoles,
|
||||||
|
roleExists,
|
||||||
|
roleHasCapability,
|
||||||
|
updateRole
|
||||||
|
};
|
||||||
25
server/services/ruleSets.js
Normal file
25
server/services/ruleSets.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const { one, parseJson } = require('../db');
|
||||||
|
|
||||||
|
function activeRuleSet() {
|
||||||
|
const row = one('SELECT rules_json FROM tax_rule_sets WHERE active = 1 ORDER BY effective_date DESC, id DESC LIMIT 1');
|
||||||
|
return row ? parseJson(row.rules_json, {}) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the depreciation rule set a book should use: its explicitly assigned
|
||||||
|
// rule set when present, otherwise the globally active set.
|
||||||
|
function ruleSetForBook(bookCode) {
|
||||||
|
if (bookCode) {
|
||||||
|
try {
|
||||||
|
const book = one('SELECT tax_rule_set_id FROM books WHERE code = ?', [bookCode]);
|
||||||
|
if (book && book.tax_rule_set_id) {
|
||||||
|
const row = one('SELECT rules_json FROM tax_rule_sets WHERE id = ?', [book.tax_rule_set_id]);
|
||||||
|
if (row) return parseJson(row.rules_json, {});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// books table may not exist on a very old database; fall through to active set
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return activeRuleSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { activeRuleSet, ruleSetForBook };
|
||||||
337
server/services/servicenow.js
Normal file
337
server/services/servicenow.js
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
const { all, audit, now, one, run } = require('../db');
|
||||||
|
const { createAsset, updateAsset } = require('./assets');
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 20000;
|
||||||
|
|
||||||
|
// Default CMDB CI field -> MixedAssets asset field mapping. Values are ServiceNow
|
||||||
|
// column names on the configured CI class; requested with display values so that
|
||||||
|
// reference fields (manufacturer, location, model) arrive as readable strings.
|
||||||
|
const DEFAULT_CMDB_MAP = {
|
||||||
|
asset_id: 'asset_tag',
|
||||||
|
description: 'name',
|
||||||
|
serial_number: 'serial_number',
|
||||||
|
category: 'sys_class_name',
|
||||||
|
vendor: 'manufacturer',
|
||||||
|
location: 'location',
|
||||||
|
department: 'department',
|
||||||
|
custodian: 'assigned_to',
|
||||||
|
acquisition_cost: 'cost',
|
||||||
|
acquired_date: 'purchase_date',
|
||||||
|
notes: 'short_description'
|
||||||
|
};
|
||||||
|
|
||||||
|
function readSettings() {
|
||||||
|
const rows = all("SELECT key, value FROM app_settings WHERE key LIKE 'servicenow_%'");
|
||||||
|
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMap(value) {
|
||||||
|
if (!value) return { ...DEFAULT_CMDB_MAP };
|
||||||
|
try {
|
||||||
|
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||||
|
return parsed && typeof parsed === 'object' && Object.keys(parsed).length ? parsed : { ...DEFAULT_CMDB_MAP };
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_CMDB_MAP };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw config (includes password) — for making API calls only.
|
||||||
|
function getConfig() {
|
||||||
|
const s = readSettings();
|
||||||
|
return {
|
||||||
|
instanceUrl: (s.servicenow_instance_url || '').replace(/\/+$/, ''),
|
||||||
|
username: s.servicenow_username || '',
|
||||||
|
password: s.servicenow_password || '',
|
||||||
|
incidentTable: s.servicenow_incident_table || 'incident',
|
||||||
|
assignmentGroup: s.servicenow_assignment_group || '',
|
||||||
|
callerId: s.servicenow_caller_id || '',
|
||||||
|
ticketEnabled: s.servicenow_ticket_enabled === 'true',
|
||||||
|
autoTicket: s.servicenow_auto_ticket === 'true',
|
||||||
|
cmdbTable: s.servicenow_cmdb_table || 'cmdb_ci_hardware',
|
||||||
|
cmdbQuery: s.servicenow_cmdb_query || '',
|
||||||
|
cmdbLimit: Number(s.servicenow_cmdb_limit || 200),
|
||||||
|
cmdbMap: parseMap(s.servicenow_cmdb_map),
|
||||||
|
lastSyncAt: s.servicenow_last_sync_at || '',
|
||||||
|
lastSyncStatus: s.servicenow_last_sync_status || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe config for the API/UI — password is never returned, only whether it is set.
|
||||||
|
function publicConfig() {
|
||||||
|
const c = getConfig();
|
||||||
|
return {
|
||||||
|
servicenow_instance_url: c.instanceUrl,
|
||||||
|
servicenow_username: c.username,
|
||||||
|
servicenow_password_set: Boolean(c.password),
|
||||||
|
servicenow_incident_table: c.incidentTable,
|
||||||
|
servicenow_assignment_group: c.assignmentGroup,
|
||||||
|
servicenow_caller_id: c.callerId,
|
||||||
|
servicenow_ticket_enabled: c.ticketEnabled,
|
||||||
|
servicenow_auto_ticket: c.autoTicket,
|
||||||
|
servicenow_cmdb_table: c.cmdbTable,
|
||||||
|
servicenow_cmdb_query: c.cmdbQuery,
|
||||||
|
servicenow_cmdb_limit: c.cmdbLimit,
|
||||||
|
servicenow_cmdb_map: c.cmdbMap,
|
||||||
|
servicenow_last_sync_at: c.lastSyncAt,
|
||||||
|
servicenow_last_sync_status: c.lastSyncStatus,
|
||||||
|
configured: Boolean(c.instanceUrl && c.username)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setValue(key, value) {
|
||||||
|
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 == null ? '' : value), now()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfig(body, userId) {
|
||||||
|
if (body.servicenow_instance_url !== undefined) setValue('servicenow_instance_url', (body.servicenow_instance_url || '').replace(/\/+$/, ''));
|
||||||
|
if (body.servicenow_username !== undefined) setValue('servicenow_username', body.servicenow_username || '');
|
||||||
|
if (body.servicenow_password_clear) setValue('servicenow_password', '');
|
||||||
|
else if (body.servicenow_password) setValue('servicenow_password', body.servicenow_password);
|
||||||
|
if (body.servicenow_incident_table !== undefined) setValue('servicenow_incident_table', body.servicenow_incident_table || 'incident');
|
||||||
|
if (body.servicenow_assignment_group !== undefined) setValue('servicenow_assignment_group', body.servicenow_assignment_group || '');
|
||||||
|
if (body.servicenow_caller_id !== undefined) setValue('servicenow_caller_id', body.servicenow_caller_id || '');
|
||||||
|
if (body.servicenow_ticket_enabled !== undefined) setValue('servicenow_ticket_enabled', body.servicenow_ticket_enabled ? 'true' : 'false');
|
||||||
|
if (body.servicenow_auto_ticket !== undefined) setValue('servicenow_auto_ticket', body.servicenow_auto_ticket ? 'true' : 'false');
|
||||||
|
if (body.servicenow_cmdb_table !== undefined) setValue('servicenow_cmdb_table', body.servicenow_cmdb_table || 'cmdb_ci_hardware');
|
||||||
|
if (body.servicenow_cmdb_query !== undefined) setValue('servicenow_cmdb_query', body.servicenow_cmdb_query || '');
|
||||||
|
if (body.servicenow_cmdb_limit !== undefined) setValue('servicenow_cmdb_limit', Number(body.servicenow_cmdb_limit) || 200);
|
||||||
|
if (body.servicenow_cmdb_map !== undefined) {
|
||||||
|
const value = typeof body.servicenow_cmdb_map === 'string' ? body.servicenow_cmdb_map : JSON.stringify(body.servicenow_cmdb_map || {});
|
||||||
|
setValue('servicenow_cmdb_map', value.trim() ? value : '');
|
||||||
|
}
|
||||||
|
audit(userId, 'update', 'servicenow_settings', null, null, { ...body, servicenow_password: body.servicenow_password ? '[set]' : undefined });
|
||||||
|
return publicConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureConfigured(c) {
|
||||||
|
if (!c.instanceUrl || !c.username) {
|
||||||
|
throw Object.assign(new Error('ServiceNow instance URL and username are required'), { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeader(c) {
|
||||||
|
return `Basic ${Buffer.from(`${c.username}:${c.password}`).toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snFetch(c, pathAndQuery, options = {}) {
|
||||||
|
const url = `${c.instanceUrl}${pathAndQuery}`;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: authHeader(c), ...(options.headers || {}) },
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw Object.assign(new Error(`ServiceNow request failed: ${error.message}`), { status: 502 });
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
let body = null;
|
||||||
|
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
|
||||||
|
if (!response.ok) {
|
||||||
|
const detail = body?.error?.message || body?.error?.detail || `HTTP ${response.status}`;
|
||||||
|
throw Object.assign(new Error(`ServiceNow: ${detail}`), { status: response.status === 401 ? 400 : 502 });
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection(userId) {
|
||||||
|
const c = getConfig();
|
||||||
|
ensureConfigured(c);
|
||||||
|
await snFetch(c, `/api/now/table/${encodeURIComponent(c.incidentTable)}?sysparm_limit=1&sysparm_fields=sys_id`, { method: 'GET' });
|
||||||
|
audit(userId, 'test', 'servicenow', null, null, { instance: c.instanceUrl });
|
||||||
|
return { ok: true, instance: c.instanceUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
// MixedAssets severity -> ServiceNow impact/urgency (1 high … 3 low).
|
||||||
|
function severityToImpactUrgency(severity) {
|
||||||
|
if (severity === 'critical') return { impact: '1', urgency: '1' };
|
||||||
|
if (severity === 'warning') return { impact: '2', urgency: '2' };
|
||||||
|
return { impact: '3', urgency: '3' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function incidentUrl(c, sysId) {
|
||||||
|
return `${c.instanceUrl}/nav_to.do?uri=${encodeURIComponent(`/${c.incidentTable}.do?sys_id=${sysId}`)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createIncidentForAlert(alert, userId, c = getConfig()) {
|
||||||
|
ensureConfigured(c);
|
||||||
|
const { impact, urgency } = severityToImpactUrgency(alert.severity);
|
||||||
|
const descriptionLines = [
|
||||||
|
alert.message || '',
|
||||||
|
'',
|
||||||
|
`MixedAssets alert #${alert.id} (${alert.type})`,
|
||||||
|
alert.asset_code ? `Asset: ${alert.asset_code}` : null,
|
||||||
|
alert.due_date ? `Due: ${alert.due_date}` : null
|
||||||
|
].filter((line) => line !== null);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
short_description: alert.title,
|
||||||
|
description: descriptionLines.join('\n'),
|
||||||
|
impact,
|
||||||
|
urgency
|
||||||
|
};
|
||||||
|
if (c.assignmentGroup) payload.assignment_group = c.assignmentGroup;
|
||||||
|
if (c.callerId) payload.caller_id = c.callerId;
|
||||||
|
|
||||||
|
const data = await snFetch(c, `/api/now/table/${encodeURIComponent(c.incidentTable)}`, { method: 'POST', body: JSON.stringify(payload) });
|
||||||
|
const result = data?.result || {};
|
||||||
|
const url = result.sys_id ? incidentUrl(c, result.sys_id) : null;
|
||||||
|
run(
|
||||||
|
'UPDATE alerts SET external_ticket_id = ?, external_ticket_number = ?, external_ticket_url = ?, external_ticket_system = ?, updated_at = ? WHERE id = ?',
|
||||||
|
[result.sys_id || null, result.number || null, url, 'servicenow', now(), alert.id]
|
||||||
|
);
|
||||||
|
audit(userId, 'ticket', 'alert', alert.id, null, { system: 'servicenow', number: result.number });
|
||||||
|
return { sys_id: result.sys_id, number: result.number, url };
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertWithCode(alertId) {
|
||||||
|
return one(
|
||||||
|
`SELECT al.*, a.asset_id AS asset_code FROM alerts al LEFT JOIN assets a ON a.id = al.asset_id WHERE al.id = ?`,
|
||||||
|
[alertId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manually submit a ServiceNow incident for one alert (returns existing ticket if already raised).
|
||||||
|
async function submitTicket(alertId, userId) {
|
||||||
|
const c = getConfig();
|
||||||
|
ensureConfigured(c);
|
||||||
|
const alert = alertWithCode(alertId);
|
||||||
|
if (!alert) return null;
|
||||||
|
if (alert.external_ticket_id) {
|
||||||
|
return { sys_id: alert.external_ticket_id, number: alert.external_ticket_number, url: alert.external_ticket_url, duplicate: true };
|
||||||
|
}
|
||||||
|
return createIncidentForAlert(alert, userId, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-create incidents for new critical alerts during the alert cycle (best-effort).
|
||||||
|
async function autoTicketAlerts(alerts, userId) {
|
||||||
|
const c = getConfig();
|
||||||
|
if (!c.ticketEnabled || !c.autoTicket || !c.instanceUrl) return { created: 0 };
|
||||||
|
let created = 0;
|
||||||
|
for (const alert of alerts) {
|
||||||
|
if (alert.severity !== 'critical' || alert.external_ticket_id) continue;
|
||||||
|
try {
|
||||||
|
await createIncidentForAlert(alert, userId, c);
|
||||||
|
created += 1;
|
||||||
|
} catch {
|
||||||
|
// best-effort: a failed ticket never breaks the alert cycle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { created };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- CMDB pull -------------------------------------------------------------
|
||||||
|
|
||||||
|
function parseCost(value) {
|
||||||
|
if (value == null || value === '') return undefined;
|
||||||
|
const num = Number(String(value).replace(/[^0-9.\-]/g, ''));
|
||||||
|
return Number.isFinite(num) ? num : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDate(value) {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const match = String(value).match(/\d{4}-\d{2}-\d{2}/);
|
||||||
|
return match ? match[0] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapCi(ci, map) {
|
||||||
|
const asset = {};
|
||||||
|
for (const [assetField, ciField] of Object.entries(map)) {
|
||||||
|
if (!ciField) continue;
|
||||||
|
let value = ci[ciField];
|
||||||
|
if (value == null || value === '') continue;
|
||||||
|
value = String(value).trim();
|
||||||
|
if (!value) continue;
|
||||||
|
if (assetField === 'acquisition_cost') {
|
||||||
|
const cost = parseCost(value);
|
||||||
|
if (cost !== undefined) asset.acquisition_cost = cost;
|
||||||
|
} else if (assetField === 'acquired_date' || assetField === 'in_service_date' || assetField === 'disposal_date') {
|
||||||
|
const date = parseDate(value);
|
||||||
|
if (date) asset[assetField] = date;
|
||||||
|
} else {
|
||||||
|
asset[assetField] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findExistingAsset(sysId, mapped) {
|
||||||
|
if (sysId) {
|
||||||
|
const bySysId = one('SELECT id FROM assets WHERE servicenow_sys_id = ?', [sysId]);
|
||||||
|
if (bySysId) return bySysId.id;
|
||||||
|
}
|
||||||
|
if (mapped.serial_number) {
|
||||||
|
const bySerial = one('SELECT id FROM assets WHERE serial_number = ? COLLATE NOCASE', [mapped.serial_number]);
|
||||||
|
if (bySerial) return bySerial.id;
|
||||||
|
}
|
||||||
|
if (mapped.asset_id) {
|
||||||
|
const byCode = one('SELECT id FROM assets WHERE asset_id = ? COLLATE NOCASE', [mapped.asset_id]);
|
||||||
|
if (byCode) return byCode.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncCmdb(userId) {
|
||||||
|
const c = getConfig();
|
||||||
|
ensureConfigured(c);
|
||||||
|
const fields = [...new Set(['sys_id', ...Object.values(c.cmdbMap).filter(Boolean)])];
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
sysparm_limit: String(c.cmdbLimit || 200),
|
||||||
|
sysparm_display_value: 'true',
|
||||||
|
sysparm_exclude_reference_link: 'true',
|
||||||
|
sysparm_fields: fields.join(',')
|
||||||
|
});
|
||||||
|
if (c.cmdbQuery) params.set('sysparm_query', c.cmdbQuery);
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await snFetch(c, `/api/now/table/${encodeURIComponent(c.cmdbTable)}?${params.toString()}`, { method: 'GET' });
|
||||||
|
} catch (error) {
|
||||||
|
setValue('servicenow_last_sync_at', now());
|
||||||
|
setValue('servicenow_last_sync_status', `Failed: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const cis = data?.result || [];
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
for (const ci of cis) {
|
||||||
|
const sysId = ci.sys_id || null;
|
||||||
|
const mapped = mapCi(ci, c.cmdbMap);
|
||||||
|
if (!mapped.description && !mapped.asset_id && !mapped.serial_number) { skipped += 1; continue; }
|
||||||
|
const existingId = findExistingAsset(sysId, mapped);
|
||||||
|
if (existingId) {
|
||||||
|
updateAsset(existingId, mapped, userId);
|
||||||
|
if (sysId) run('UPDATE assets SET servicenow_sys_id = ? WHERE id = ?', [sysId, existingId]);
|
||||||
|
updated += 1;
|
||||||
|
} else {
|
||||||
|
const asset = createAsset({ ...mapped, source: 'servicenow' }, userId);
|
||||||
|
if (sysId) run('UPDATE assets SET servicenow_sys_id = ? WHERE id = ?', [sysId, asset.id]);
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const status = `Synced ${cis.length} CI(s): ${created} created, ${updated} updated${skipped ? `, ${skipped} skipped` : ''}.`;
|
||||||
|
setValue('servicenow_last_sync_at', now());
|
||||||
|
setValue('servicenow_last_sync_status', status);
|
||||||
|
audit(userId, 'cmdb_sync', 'servicenow', null, null, { table: c.cmdbTable, created, updated, skipped });
|
||||||
|
return { created, updated, skipped, total: cis.length, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_CMDB_MAP,
|
||||||
|
autoTicketAlerts,
|
||||||
|
getConfig,
|
||||||
|
publicConfig,
|
||||||
|
saveConfig,
|
||||||
|
submitTicket,
|
||||||
|
syncCmdb,
|
||||||
|
testConnection
|
||||||
|
};
|
||||||
88
server/services/webhooks.js
Normal file
88
server/services/webhooks.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const { audit } = require('../db');
|
||||||
|
const { getConfig } = require('./notifications');
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
// The JSON object delivered for each alert. Stable, flat shape for easy consumption.
|
||||||
|
function buildPayload(alert) {
|
||||||
|
return {
|
||||||
|
event: 'alert',
|
||||||
|
id: alert.id,
|
||||||
|
type: alert.type,
|
||||||
|
severity: alert.severity,
|
||||||
|
title: alert.title,
|
||||||
|
message: alert.message || null,
|
||||||
|
due_date: alert.due_date || null,
|
||||||
|
status: alert.status,
|
||||||
|
reference_type: alert.reference_type,
|
||||||
|
reference_id: alert.reference_id,
|
||||||
|
asset_id: alert.asset_id || null,
|
||||||
|
asset_code: alert.asset_code || null,
|
||||||
|
created_at: alert.created_at,
|
||||||
|
sent_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST a single JSON object. When a secret is set, sign the raw body with HMAC-SHA256
|
||||||
|
// so the receiver can verify authenticity via the X-MixedAssets-Signature header.
|
||||||
|
async function postJson(url, secret, payload) {
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
const headers = { 'Content-Type': 'application/json', 'User-Agent': 'MixedAssets-Webhook/1' };
|
||||||
|
if (secret) {
|
||||||
|
const signature = crypto.createHmac('sha256', secret).update(body).digest('hex');
|
||||||
|
headers['X-MixedAssets-Signature'] = `sha256=${signature}`;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { method: 'POST', headers, body, signal: controller.signal });
|
||||||
|
return { ok: response.ok, status: response.status };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver each alert as its own JSON POST. Best-effort: a failed delivery is counted
|
||||||
|
// but never throws, so one bad endpoint can't break the alert cycle.
|
||||||
|
async function deliverAlerts(alerts) {
|
||||||
|
const c = getConfig();
|
||||||
|
if (!c.webhookEnabled || !c.webhookUrl || !alerts.length) {
|
||||||
|
return { attempted: false, delivered: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
let delivered = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const alert of alerts) {
|
||||||
|
try {
|
||||||
|
const result = await postJson(c.webhookUrl, c.webhookSecret, buildPayload(alert));
|
||||||
|
if (result.ok) delivered += 1;
|
||||||
|
else failed += 1;
|
||||||
|
} catch {
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { attempted: true, delivered, failed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTestWebhook(userId) {
|
||||||
|
const c = getConfig();
|
||||||
|
if (!c.webhookUrl) throw Object.assign(new Error('A webhook URL is not configured'), { status: 400 });
|
||||||
|
const payload = {
|
||||||
|
event: 'test',
|
||||||
|
severity: 'info',
|
||||||
|
title: 'MixedAssets webhook test',
|
||||||
|
message: 'If you received this, your webhook destination is configured correctly.',
|
||||||
|
sent_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await postJson(c.webhookUrl, c.webhookSecret, payload);
|
||||||
|
} catch (error) {
|
||||||
|
throw Object.assign(new Error(`Webhook request failed: ${error.message}`), { status: 400 });
|
||||||
|
}
|
||||||
|
audit(userId, 'send', 'test_webhook', null, null, { url: c.webhookUrl, status: result.status });
|
||||||
|
if (!result.ok) throw Object.assign(new Error(`Webhook endpoint returned HTTP ${result.status}`), { status: 400 });
|
||||||
|
return { ok: true, status: result.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildPayload, deliverAlerts, sendTestWebhook };
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
const { audit, json, now, one, run } = require('../db');
|
const { audit, json, now, one, run } = require('../db');
|
||||||
const { upsertEmployee } = require('./assignments');
|
const { upsertEmployee } = require('./assignments');
|
||||||
|
const { upsertWorkdayContact } = require('./contacts');
|
||||||
|
|
||||||
function publicConnection(row) {
|
function publicConnection(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
@@ -12,12 +13,21 @@ function publicConnection(row) {
|
|||||||
client_id: row.client_id,
|
client_id: row.client_id,
|
||||||
workers_path: row.workers_path,
|
workers_path: row.workers_path,
|
||||||
enabled: Boolean(row.enabled),
|
enabled: Boolean(row.enabled),
|
||||||
|
sync_enabled: Boolean(row.sync_enabled),
|
||||||
|
sync_interval_hours: row.sync_interval_hours || 24,
|
||||||
last_sync_at: row.last_sync_at,
|
last_sync_at: row.last_sync_at,
|
||||||
last_sync_status: row.last_sync_status,
|
last_sync_status: row.last_sync_status,
|
||||||
configured: Boolean(row.base_url && row.token_url && row.client_id && row.client_secret)
|
configured: Boolean(row.base_url && row.token_url && row.client_id && row.client_secret)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitName(full) {
|
||||||
|
const parts = String(full || '').trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (!parts.length) return { first: null, last: null };
|
||||||
|
if (parts.length === 1) return { first: parts[0], last: null };
|
||||||
|
return { first: parts[0], last: parts.slice(1).join(' ') };
|
||||||
|
}
|
||||||
|
|
||||||
function getConnection() {
|
function getConnection() {
|
||||||
return publicConnection(one('SELECT * FROM workday_connections ORDER BY id LIMIT 1'));
|
return publicConnection(one('SELECT * FROM workday_connections ORDER BY id LIMIT 1'));
|
||||||
}
|
}
|
||||||
@@ -37,23 +47,25 @@ function saveConnection(body, userId) {
|
|||||||
client_id: body.client_id ?? existing?.client_id ?? '',
|
client_id: body.client_id ?? existing?.client_id ?? '',
|
||||||
client_secret: body.client_secret ? body.client_secret : existing?.client_secret ?? '',
|
client_secret: body.client_secret ? body.client_secret : existing?.client_secret ?? '',
|
||||||
workers_path: body.workers_path ?? existing?.workers_path ?? '/workers',
|
workers_path: body.workers_path ?? existing?.workers_path ?? '/workers',
|
||||||
enabled: body.enabled === undefined ? Number(existing?.enabled || 0) : (body.enabled ? 1 : 0)
|
enabled: body.enabled === undefined ? Number(existing?.enabled || 0) : (body.enabled ? 1 : 0),
|
||||||
|
sync_enabled: body.sync_enabled === undefined ? Number(existing?.sync_enabled || 0) : (body.sync_enabled ? 1 : 0),
|
||||||
|
sync_interval_hours: body.sync_interval_hours === undefined ? (existing?.sync_interval_hours || 24) : Math.max(1, Number(body.sync_interval_hours) || 24)
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
run(
|
run(
|
||||||
`UPDATE workday_connections SET
|
`UPDATE workday_connections SET
|
||||||
name = ?, tenant = ?, base_url = ?, token_url = ?, client_id = ?, client_secret = ?,
|
name = ?, tenant = ?, base_url = ?, token_url = ?, client_id = ?, client_secret = ?,
|
||||||
workers_path = ?, enabled = ?, updated_at = ?
|
workers_path = ?, enabled = ?, sync_enabled = ?, sync_interval_hours = ?, updated_at = ?
|
||||||
WHERE id = ?`,
|
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]
|
[payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, payload.sync_enabled, payload.sync_interval_hours, timestamp, existing.id]
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
run(
|
run(
|
||||||
`INSERT INTO workday_connections (
|
`INSERT INTO workday_connections (
|
||||||
name, tenant, base_url, token_url, client_id, client_secret, workers_path, enabled, created_at, updated_at
|
name, tenant, base_url, token_url, client_id, client_secret, workers_path, enabled, sync_enabled, sync_interval_hours, created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, timestamp, timestamp]
|
[payload.name, payload.tenant, payload.base_url, payload.token_url, payload.client_id, payload.client_secret, payload.workers_path, payload.enabled, payload.sync_enabled, payload.sync_interval_hours, timestamp, timestamp]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,19 +97,31 @@ function normalizeWorkers(payload) {
|
|||||||
? payload
|
? payload
|
||||||
: payload.workers || payload.Workers || payload.data || payload.value || [];
|
: payload.workers || payload.Workers || payload.data || payload.value || [];
|
||||||
|
|
||||||
return rows.map((worker) => ({
|
return rows.map((worker) => {
|
||||||
workday_worker_id: worker.id || worker.workerId || worker.worker_id || worker.Worker_ID || worker.workerReference?.id,
|
const name = worker.name || worker.fullName || worker.Full_Name || worker.descriptor || [worker.firstName, worker.lastName].filter(Boolean).join(' ');
|
||||||
employee_number: worker.employeeNumber || worker.employee_number || worker.Worker_ID || worker.workerNumber,
|
const fallbackName = splitName(name);
|
||||||
name: worker.name || worker.fullName || worker.Full_Name || worker.descriptor || [worker.firstName, worker.lastName].filter(Boolean).join(' '),
|
const managerName = worker.managerName || worker.manager?.descriptor;
|
||||||
email: worker.email || worker.workEmail || worker.primaryWorkEmail || worker.Email,
|
const managerSplit = splitName(managerName);
|
||||||
department: worker.department || worker.organization || worker.Department || worker.supervisoryOrganization?.descriptor,
|
return {
|
||||||
location: worker.location || worker.workLocation || worker.Location || worker.location?.descriptor,
|
workday_worker_id: worker.id || worker.workerId || worker.worker_id || worker.Worker_ID || worker.workerReference?.id,
|
||||||
manager_name: worker.managerName || worker.manager?.descriptor,
|
employee_number: worker.employeeNumber || worker.employee_number || worker.Worker_ID || worker.workerNumber,
|
||||||
status: worker.status || (worker.active === false ? 'inactive' : 'active'),
|
name,
|
||||||
source: 'workday',
|
first_name: worker.firstName || worker.legalFirstName || worker.First_Name || fallbackName.first,
|
||||||
source_payload: worker,
|
last_name: worker.lastName || worker.legalLastName || worker.Last_Name || fallbackName.last,
|
||||||
last_synced_at: now()
|
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: managerName,
|
||||||
|
manager_first_name: worker.manager?.firstName || managerSplit.first,
|
||||||
|
manager_last_name: worker.manager?.lastName || managerSplit.last,
|
||||||
|
manager_email: worker.managerEmail || worker.manager?.email,
|
||||||
|
start_date: worker.hireDate || worker.startDate || worker.Hire_Date || worker.continuousServiceDate,
|
||||||
|
status: worker.status || (worker.active === false ? 'inactive' : 'active'),
|
||||||
|
source: 'workday',
|
||||||
|
source_payload: worker,
|
||||||
|
last_synced_at: now()
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncWorkers(userId) {
|
async function syncWorkers(userId) {
|
||||||
@@ -120,7 +144,10 @@ async function syncWorkers(userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workers = normalizeWorkers(await response.json());
|
const workers = normalizeWorkers(await response.json());
|
||||||
for (const worker of workers) upsertEmployee(worker);
|
for (const worker of workers) {
|
||||||
|
upsertEmployee(worker);
|
||||||
|
upsertWorkdayContact(worker);
|
||||||
|
}
|
||||||
|
|
||||||
const timestamp = now();
|
const timestamp = now();
|
||||||
run('UPDATE workday_connections SET last_sync_at = ?, last_sync_status = ?, updated_at = ? WHERE id = ?', [
|
run('UPDATE workday_connections SET last_sync_at = ?, last_sync_status = ?, updated_at = ? WHERE id = ?', [
|
||||||
@@ -135,14 +162,30 @@ async function syncWorkers(userId) {
|
|||||||
|
|
||||||
function importWorkersFromPayload(workers, userId) {
|
function importWorkersFromPayload(workers, userId) {
|
||||||
const normalized = normalizeWorkers(workers);
|
const normalized = normalizeWorkers(workers);
|
||||||
for (const worker of normalized) upsertEmployee(worker);
|
for (const worker of normalized) {
|
||||||
|
upsertEmployee(worker);
|
||||||
|
upsertWorkdayContact(worker);
|
||||||
|
}
|
||||||
audit(userId, 'import', 'workday_workers', null, null, { imported: normalized.length, source: 'payload' });
|
audit(userId, 'import', 'workday_workers', null, null, { imported: normalized.length, source: 'payload' });
|
||||||
return { imported: normalized.length };
|
return { imported: normalized.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run an automatic sync if it's enabled, configured, and the interval has elapsed.
|
||||||
|
async function runScheduledSync() {
|
||||||
|
const connection = getPrivateConnection();
|
||||||
|
if (!connection || !connection.sync_enabled || !connection.enabled) return { skipped: true };
|
||||||
|
if (!connection.base_url || !connection.token_url || !connection.client_id || !connection.client_secret) return { skipped: true };
|
||||||
|
const intervalMs = (Number(connection.sync_interval_hours) || 24) * 3600000;
|
||||||
|
if (connection.last_sync_at && Date.now() - new Date(connection.last_sync_at).getTime() < intervalMs) {
|
||||||
|
return { skipped: true };
|
||||||
|
}
|
||||||
|
return syncWorkers(null);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getConnection,
|
getConnection,
|
||||||
importWorkersFromPayload,
|
importWorkersFromPayload,
|
||||||
|
runScheduledSync,
|
||||||
saveConnection,
|
saveConnection,
|
||||||
syncWorkers
|
syncWorkers
|
||||||
};
|
};
|
||||||
|
|||||||
114
server/services/zones.js
Normal file
114
server/services/zones.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
const { all, audit, now, one, run } = require('../db');
|
||||||
|
|
||||||
|
let cache = null;
|
||||||
|
|
||||||
|
function clearCache() {
|
||||||
|
cache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookup() {
|
||||||
|
if (!cache) {
|
||||||
|
cache = {};
|
||||||
|
for (const row of all('SELECT * FROM depreciation_zones')) cache[row.code] = row;
|
||||||
|
}
|
||||||
|
return cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Raw row (incl. dates) for the engine; null when unknown or disabled.
|
||||||
|
function getZone(code) {
|
||||||
|
if (!code) return null;
|
||||||
|
const zone = lookup()[code];
|
||||||
|
return zone && zone.enabled ? zone : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRow(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
allowance_percent: row.allowance_percent,
|
||||||
|
pis_start: row.pis_start,
|
||||||
|
pis_end: row.pis_end,
|
||||||
|
real_property_end: row.real_property_end,
|
||||||
|
max_recovery_years: row.max_recovery_years,
|
||||||
|
leasehold_life_years: row.leasehold_life_years,
|
||||||
|
notes: row.notes,
|
||||||
|
enabled: Boolean(row.enabled),
|
||||||
|
asset_count: one('SELECT COUNT(*) AS c FROM assets WHERE special_zone = ?', [row.code]).c
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function list() {
|
||||||
|
return all('SELECT * FROM depreciation_zones ORDER BY name').map(fromRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalize(body) {
|
||||||
|
const num = (v) => (v === '' || v === null || v === undefined ? null : Number(v));
|
||||||
|
return {
|
||||||
|
name: String(body.name || '').trim(),
|
||||||
|
allowance_percent: Number(body.allowance_percent) || 0,
|
||||||
|
pis_start: body.pis_start || null,
|
||||||
|
pis_end: body.pis_end || null,
|
||||||
|
real_property_end: body.real_property_end || null,
|
||||||
|
max_recovery_years: num(body.max_recovery_years),
|
||||||
|
leasehold_life_years: num(body.leasehold_life_years),
|
||||||
|
notes: body.notes || null,
|
||||||
|
enabled: body.enabled === false ? 0 : 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createZone(body, userId) {
|
||||||
|
const input = normalize(body);
|
||||||
|
if (!input.name) throw Object.assign(new Error('A zone name is required'), { status: 400 });
|
||||||
|
const code = slugify(body.code || input.name);
|
||||||
|
if (!code) throw Object.assign(new Error('A valid zone code is required'), { status: 400 });
|
||||||
|
if (one('SELECT id FROM depreciation_zones WHERE code = ?', [code])) {
|
||||||
|
throw Object.assign(new Error('A zone with that code already exists'), { status: 400 });
|
||||||
|
}
|
||||||
|
const ts = now();
|
||||||
|
run(
|
||||||
|
`INSERT INTO depreciation_zones (code, name, allowance_percent, pis_start, pis_end, real_property_end, max_recovery_years, leasehold_life_years, notes, enabled, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[code, input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.notes, input.enabled, ts, ts]
|
||||||
|
);
|
||||||
|
clearCache();
|
||||||
|
audit(userId, 'create', 'depreciation_zone', code, null, input);
|
||||||
|
return fromRow(one('SELECT * FROM depreciation_zones WHERE code = ?', [code]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateZone(code, body, userId) {
|
||||||
|
const before = one('SELECT * FROM depreciation_zones WHERE code = ?', [code]);
|
||||||
|
if (!before) return null;
|
||||||
|
const input = normalize({ ...before, ...body, enabled: body.enabled === undefined ? Boolean(before.enabled) : body.enabled });
|
||||||
|
run(
|
||||||
|
`UPDATE depreciation_zones SET name = ?, allowance_percent = ?, pis_start = ?, pis_end = ?, real_property_end = ?,
|
||||||
|
max_recovery_years = ?, leasehold_life_years = ?, notes = ?, enabled = ?, updated_at = ? WHERE code = ?`,
|
||||||
|
[input.name, input.allowance_percent, input.pis_start, input.pis_end, input.real_property_end, input.max_recovery_years, input.leasehold_life_years, input.notes, input.enabled, now(), code]
|
||||||
|
);
|
||||||
|
clearCache();
|
||||||
|
audit(userId, 'update', 'depreciation_zone', code, before, input);
|
||||||
|
return fromRow(one('SELECT * FROM depreciation_zones WHERE code = ?', [code]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteZone(code, userId) {
|
||||||
|
const before = one('SELECT * FROM depreciation_zones WHERE code = ?', [code]);
|
||||||
|
if (!before) return false;
|
||||||
|
run('DELETE FROM depreciation_zones WHERE code = ?', [code]);
|
||||||
|
clearCache();
|
||||||
|
audit(userId, 'delete', 'depreciation_zone', code, before, null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
clearCache,
|
||||||
|
createZone,
|
||||||
|
deleteZone,
|
||||||
|
getZone,
|
||||||
|
list,
|
||||||
|
updateZone
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
747
src/App.vue
747
src/App.vue
File diff suppressed because it is too large
Load Diff
211
src/components/AssetClassSettings.vue
Normal file
211
src/components/AssetClassSettings.vue
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Asset classes</h2>
|
||||||
|
<p class="quiet text-body-2">
|
||||||
|
IRS asset classes (recovery periods) used to pre-populate a category’s class number. Seeded from the IRS tables and fully editable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-select
|
||||||
|
v-model="sourceFilter"
|
||||||
|
:items="sourceFilterItems"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
label="Source"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
style="max-width:190px"
|
||||||
|
/>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New asset class</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="filteredRows"
|
||||||
|
row-key="id"
|
||||||
|
:page-size="15"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter by description or class number"
|
||||||
|
empty-text="No asset classes."
|
||||||
|
>
|
||||||
|
<template #cell-class_number="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.class_number || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-gds="{ row }">{{ row.gds ?? '—' }}</template>
|
||||||
|
<template #cell-ads="{ row }">{{ row.ads ?? '—' }}</template>
|
||||||
|
<template #cell-source="{ row }">
|
||||||
|
<v-chip size="x-small" variant="tonal">{{ sourceLabel(row.source) }}</v-chip>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
|
||||||
|
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
|
||||||
|
<!-- Create / edit -->
|
||||||
|
<v-dialog v-model="dialog" max-width="560">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ form.id ? 'Edit asset class' : 'New asset class' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field v-model="form.description" label="Description *" autofocus />
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field v-model="form.class_number" label="Class number" placeholder="00.12" />
|
||||||
|
<v-select v-model="form.source" :items="sourceItems" item-title="title" item-value="value" label="Source" />
|
||||||
|
<v-text-field v-model.number="form.gds" type="number" label="GDS recovery (years)" />
|
||||||
|
<v-text-field v-model.number="form.ads" type="number" label="ADS recovery (years)" />
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!form.description.trim()" @click="save">Save</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Delete confirmation -->
|
||||||
|
<v-dialog v-model="deleteDialog" max-width="480">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-error">Delete asset class</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="mb-2">Delete <strong>“{{ deleteTarget?.description }}”</strong>{{ deleteTarget?.class_number ? ` (${deleteTarget.class_number})` : '' }}?</p>
|
||||||
|
<v-alert
|
||||||
|
:type="deleteTarget && deleteTarget.category_count ? 'warning' : 'info'"
|
||||||
|
variant="tonal"
|
||||||
|
density="comfortable"
|
||||||
|
>
|
||||||
|
<template v-if="deleteTarget && deleteTarget.category_count">
|
||||||
|
{{ deleteTarget.category_count }} categor{{ deleteTarget.category_count === 1 ? 'y' : 'ies' }} use this class number — they keep
|
||||||
|
their value; it just won’t appear in the lookup anymore.
|
||||||
|
</template>
|
||||||
|
<template v-else>This class isn’t referenced by any category.</template>
|
||||||
|
</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DataTable from './DataTable.vue';
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
|
||||||
|
const SOURCE_LABELS = { common: 'Common', industry: 'Manufacturing', business: 'Business', custom: 'Custom' };
|
||||||
|
|
||||||
|
function blankForm() {
|
||||||
|
return { id: null, description: '', class_number: '', gds: null, ads: null, source: 'custom' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { DataTable },
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
emits: ['updated'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
rows: [],
|
||||||
|
sourceFilter: 'all',
|
||||||
|
dialog: false,
|
||||||
|
deleteDialog: false,
|
||||||
|
deleteTarget: null,
|
||||||
|
form: blankForm(),
|
||||||
|
saving: false,
|
||||||
|
error: '',
|
||||||
|
dialogError: '',
|
||||||
|
sourceItems: Object.entries(SOURCE_LABELS).map(([value, title]) => ({ title, value })),
|
||||||
|
columns: [
|
||||||
|
{ key: 'class_number', label: 'Class #' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{ key: 'gds', label: 'GDS', format: 'number' },
|
||||||
|
{ key: 'ads', label: 'ADS', format: 'number' },
|
||||||
|
{ key: 'source', label: 'Source', sortable: false },
|
||||||
|
{ key: 'category_count', label: 'Categories', format: 'number' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
sourceFilterItems() {
|
||||||
|
return [{ title: 'All sources', value: 'all' }, ...this.sourceItems];
|
||||||
|
},
|
||||||
|
filteredRows() {
|
||||||
|
if (this.sourceFilter === 'all') return this.rows;
|
||||||
|
return this.rows.filter((r) => r.source === this.sourceFilter);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
sourceLabel(source) {
|
||||||
|
return SOURCE_LABELS[source] || source;
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/asset-classes')).json();
|
||||||
|
this.rows = data.assetClasses;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openNew() {
|
||||||
|
this.form = blankForm();
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
openEdit(row) {
|
||||||
|
this.form = { id: row.id, description: row.description, class_number: row.class_number || '', gds: row.gds, ads: row.ads, source: row.source };
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
if (!this.form.description.trim()) return;
|
||||||
|
this.saving = true;
|
||||||
|
this.dialogError = '';
|
||||||
|
try {
|
||||||
|
const method = this.form.id ? 'PUT' : 'POST';
|
||||||
|
const url = this.form.id ? `/api/asset-classes/${this.form.id}` : '/api/asset-classes';
|
||||||
|
await this.api(url, { method, body: JSON.stringify(this.form) });
|
||||||
|
this.dialog = false;
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.dialogError = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmDelete(row) {
|
||||||
|
this.deleteTarget = row;
|
||||||
|
this.deleteDialog = true;
|
||||||
|
},
|
||||||
|
async performDelete() {
|
||||||
|
const target = this.deleteTarget;
|
||||||
|
this.deleteDialog = false;
|
||||||
|
this.deleteTarget = null;
|
||||||
|
if (!target) return;
|
||||||
|
try {
|
||||||
|
await this.api(`/api/asset-classes/${target.id}`, { method: 'DELETE' });
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -10,9 +10,13 @@
|
|||||||
<v-tab value="details">Details</v-tab>
|
<v-tab value="details">Details</v-tab>
|
||||||
<v-tab value="books">Books</v-tab>
|
<v-tab value="books">Books</v-tab>
|
||||||
<v-tab value="files">Files <v-badge v-if="assetFiles.length" :content="assetFiles.length" inline /></v-tab>
|
<v-tab value="files">Files <v-badge v-if="assetFiles.length" :content="assetFiles.length" inline /></v-tab>
|
||||||
|
<v-tab value="photos">Photos <v-badge v-if="assetPhotos.length" :content="assetPhotos.length" inline /></v-tab>
|
||||||
<v-tab value="warranties">Warranties <v-badge v-if="assetWarranties.length" :content="assetWarranties.length" inline /></v-tab>
|
<v-tab value="warranties">Warranties <v-badge v-if="assetWarranties.length" :content="assetWarranties.length" inline /></v-tab>
|
||||||
<v-tab value="tasks">Tasks <v-badge v-if="assetTasks.length" :content="assetTasks.length" inline /></v-tab>
|
<v-tab value="tasks">Tasks <v-badge v-if="assetTasks.length" :content="assetTasks.length" inline /></v-tab>
|
||||||
<v-tab value="notes">Notes <v-badge v-if="assetNotes.length" :content="assetNotes.length" inline /></v-tab>
|
<v-tab value="notes">Notes <v-badge v-if="assetNotes.length" :content="assetNotes.length" inline /></v-tab>
|
||||||
|
<v-tab value="disposal">Disposal <v-badge v-if="assetDisposals.length" :content="assetDisposals.length" inline /></v-tab>
|
||||||
|
<v-tab value="lease">Lease <v-badge v-if="assetLeases.length" :content="assetLeases.length" inline /></v-tab>
|
||||||
|
<v-tab value="pm">PM <v-badge v-if="assetPm.length" :content="assetPm.length" inline /></v-tab>
|
||||||
</v-tabs>
|
</v-tabs>
|
||||||
|
|
||||||
<div class="pa-4">
|
<div class="pa-4">
|
||||||
@@ -21,10 +25,22 @@
|
|||||||
<v-window-item value="details">
|
<v-window-item value="details">
|
||||||
<v-select :model-value="selectedTemplateId" :items="templateOptions" label="Template" clearable @update:model-value="$emit('select-template', $event)" />
|
<v-select :model-value="selectedTemplateId" :items="templateOptions" label="Template" clearable @update:model-value="$emit('select-template', $event)" />
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<v-text-field v-model="localAsset.asset_id" label="Asset ID" />
|
<v-text-field
|
||||||
|
v-model="localAsset.asset_id"
|
||||||
|
label="Asset ID"
|
||||||
|
:hint="localAsset.id ? '' : 'Leave blank to auto-assign from the category’s ID template'"
|
||||||
|
:persistent-hint="!localAsset.id"
|
||||||
|
/>
|
||||||
<v-select v-model="localAsset.entity_id" :items="entityOptions" label="Entity" />
|
<v-select v-model="localAsset.entity_id" :items="entityOptions" label="Entity" />
|
||||||
<v-text-field v-model="localAsset.description" class="full" label="Description" />
|
<v-text-field v-model="localAsset.description" class="full" label="Description" />
|
||||||
<v-select v-model="localAsset.category" :items="categoryItems" label="Category" />
|
<v-select v-model="localAsset.category" :items="categoryItems" label="Category" />
|
||||||
|
<v-text-field
|
||||||
|
:model-value="assetClassNumber"
|
||||||
|
label="Asset class number"
|
||||||
|
readonly
|
||||||
|
:hint="assetClassNumber ? 'Shared by all assets in this category' : 'Set on the category in Admin → Application Configuration'"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
<v-select v-model="localAsset.status" :items="statusItems" label="Status" />
|
<v-select v-model="localAsset.status" :items="statusItems" label="Status" />
|
||||||
<v-text-field v-model.number="localAsset.acquisition_cost" type="number" label="Acquisition cost" />
|
<v-text-field v-model.number="localAsset.acquisition_cost" type="number" label="Acquisition cost" />
|
||||||
<v-text-field v-model.number="localAsset.salvage_value" type="number" label="Salvage value" />
|
<v-text-field v-model.number="localAsset.salvage_value" type="number" label="Salvage value" />
|
||||||
@@ -39,13 +55,23 @@
|
|||||||
<v-text-field v-model.number="localAsset.investment_use_percent" type="number" label="Investment use %" />
|
<v-text-field v-model.number="localAsset.investment_use_percent" type="number" label="Investment use %" />
|
||||||
<v-switch v-model="localAsset.listed_property" label="Listed property (luxury auto limits)" color="primary" density="compact" hide-details class="full" />
|
<v-switch v-model="localAsset.listed_property" label="Listed property (luxury auto limits)" color="primary" density="compact" hide-details class="full" />
|
||||||
|
|
||||||
<v-text-field v-model="localAsset.location" label="Location" />
|
<v-select
|
||||||
<v-text-field v-model="localAsset.department" label="Department" />
|
v-model="localAsset.special_zone"
|
||||||
|
:items="zoneItems"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
label="Special depreciation zone"
|
||||||
|
class="full"
|
||||||
|
/>
|
||||||
|
<v-alert v-if="zoneGuidance" type="info" variant="tonal" density="compact" class="full">{{ zoneGuidance }}</v-alert>
|
||||||
|
|
||||||
|
<v-combobox v-model="localAsset.location" :items="locationItems" label="Location" clearable />
|
||||||
|
<v-combobox v-model="localAsset.department" :items="departmentItems" label="Department" clearable />
|
||||||
<v-text-field v-model="localAsset.group_name" label="Group" />
|
<v-text-field v-model="localAsset.group_name" label="Group" />
|
||||||
<v-text-field v-model="localAsset.custodian" label="Custodian" />
|
<v-text-field v-model="localAsset.custodian" label="Custodian" />
|
||||||
<v-text-field v-model="localAsset.barcode_value" label="Barcode / tag value" />
|
<v-text-field v-model="localAsset.barcode_value" label="Barcode / tag value" />
|
||||||
<v-select v-model="localAsset.condition" :items="conditionItems" clearable label="Condition" />
|
<v-select v-model="localAsset.condition" :items="conditionItems" clearable label="Condition" />
|
||||||
<v-text-field v-model="localAsset.vendor" label="Vendor" />
|
<v-combobox v-model="localAsset.vendor" :items="vendorItems" label="Vendor" clearable />
|
||||||
<v-text-field v-model="localAsset.serial_number" label="Serial number" />
|
<v-text-field v-model="localAsset.serial_number" label="Serial number" />
|
||||||
<v-text-field v-model="localAsset.invoice_number" label="Invoice number" />
|
<v-text-field v-model="localAsset.invoice_number" label="Invoice number" />
|
||||||
<v-text-field v-model="localAsset.gl_asset_account" label="Asset GL" />
|
<v-text-field v-model="localAsset.gl_asset_account" label="Asset GL" />
|
||||||
@@ -54,6 +80,20 @@
|
|||||||
<v-textarea v-model="localAsset.notes" class="full" label="Summary notes" rows="2" />
|
<v-textarea v-model="localAsset.notes" class="full" label="Summary notes" rows="2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<div class="toolbar-row align-center">
|
||||||
|
<div>
|
||||||
|
<h3 class="section-title">Barcode label</h3>
|
||||||
|
<div class="quiet text-body-2">Uses the barcode value, or the Asset ID when blank.</div>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="small" variant="text" @click="useAssetIdAsBarcode">Use Asset ID</v-btn>
|
||||||
|
<v-btn size="small" variant="tonal" prepend-icon="mdi-printer" :disabled="!barcodeValue" @click="$emit('print-label', localAsset)">Print</v-btn>
|
||||||
|
</div>
|
||||||
|
<div v-if="barcodeValue" class="label-cell mt-2" style="max-width:240px">
|
||||||
|
<BarcodeLabel :value="barcodeValue" :caption="localAsset.description" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-if="selectedTemplateFields.length">
|
<template v-if="selectedTemplateFields.length">
|
||||||
<v-divider class="my-4" />
|
<v-divider class="my-4" />
|
||||||
<h3 class="section-title mb-3">Template fields</h3>
|
<h3 class="section-title mb-3">Template fields</h3>
|
||||||
@@ -130,6 +170,38 @@
|
|||||||
<p v-else class="quiet text-body-2">Save the asset first, then attach files.</p>
|
<p v-else class="quiet text-body-2">Save the asset first, then attach files.</p>
|
||||||
</v-window-item>
|
</v-window-item>
|
||||||
|
|
||||||
|
<!-- PHOTOS (identification) -->
|
||||||
|
<v-window-item value="photos">
|
||||||
|
<template v-if="localAsset.id">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div class="quiet text-body-2">Identification photos — what the asset looks like, where it is, nameplates, serial labels.</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="tonal" prepend-icon="mdi-camera-plus-outline" @click="$refs.photoInput.click()">Add photos</v-btn>
|
||||||
|
<input ref="photoInput" hidden type="file" accept="image/*" capture="environment" multiple @change="onPhotoPick" />
|
||||||
|
</div>
|
||||||
|
<div v-if="assetPhotos.length" class="asset-photo-grid">
|
||||||
|
<div v-for="photo in assetPhotos" :key="photo.id" class="asset-photo-card">
|
||||||
|
<div class="asset-photo-frame">
|
||||||
|
<img :src="photo.data" :alt="photo.caption || photo.name || 'asset photo'" />
|
||||||
|
<v-btn icon="mdi-close" size="x-small" color="error" variant="flat" class="asset-photo-remove" @click="$emit('delete-photo', photo.id)" />
|
||||||
|
</div>
|
||||||
|
<v-text-field
|
||||||
|
:model-value="photo.caption"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
placeholder="Caption"
|
||||||
|
class="mt-1"
|
||||||
|
@update:model-value="captionDrafts[photo.id] = $event"
|
||||||
|
@blur="saveCaption(photo)"
|
||||||
|
@keyup.enter="saveCaption(photo)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="quiet text-body-2">No photos yet. On a phone, "Add photos" opens the camera.</p>
|
||||||
|
</template>
|
||||||
|
<p v-else class="quiet text-body-2">Save the asset first, then add photos.</p>
|
||||||
|
</v-window-item>
|
||||||
|
|
||||||
<!-- WARRANTIES -->
|
<!-- WARRANTIES -->
|
||||||
<v-window-item value="warranties">
|
<v-window-item value="warranties">
|
||||||
<template v-if="localAsset.id">
|
<template v-if="localAsset.id">
|
||||||
@@ -211,22 +283,335 @@
|
|||||||
</template>
|
</template>
|
||||||
<p v-else class="quiet text-body-2">Save the asset first, then keep running notes.</p>
|
<p v-else class="quiet text-body-2">Save the asset first, then keep running notes.</p>
|
||||||
</v-window-item>
|
</v-window-item>
|
||||||
|
|
||||||
|
<!-- DISPOSAL -->
|
||||||
|
<v-window-item value="disposal">
|
||||||
|
<template v-if="localAsset.id">
|
||||||
|
<v-list v-if="assetDisposals.length" lines="two" density="compact" class="mb-3">
|
||||||
|
<v-list-item v-for="d in assetDisposals" :key="d.id" prepend-icon="mdi-cash-remove">
|
||||||
|
<v-list-item-title>
|
||||||
|
{{ d.partial ? 'Partial' : 'Full' }} {{ d.method }} · {{ d.disposal_date }}
|
||||||
|
<span :class="d.gain_loss >= 0 ? 'text-success' : 'text-error'">
|
||||||
|
{{ d.gain_loss >= 0 ? 'Gain' : 'Loss' }} {{ currency(Math.abs(d.gain_loss)) }}
|
||||||
|
</span>
|
||||||
|
</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>
|
||||||
|
{{ d.book_type }} · proceeds {{ currency(d.disposal_price - d.disposal_expense) }} · NBV {{ currency(d.net_book_value) }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
<template #append>
|
||||||
|
<v-btn icon="mdi-undo" size="small" variant="text" @click="$emit('reverse-disposal', d.id)" />
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<h3 class="section-title mb-2">Record disposal</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-select v-model="disposalForm.book_type" :items="bookItems" label="Book" />
|
||||||
|
<v-select v-model="disposalForm.method" :items="disposalMethodItems" item-title="title" item-value="value" label="Disposal type" />
|
||||||
|
<v-text-field v-model="disposalForm.disposal_date" type="date" label="Disposal date" />
|
||||||
|
<v-select v-model="disposalForm.property_type" :items="propertyTypeItems" item-title="title" item-value="value" label="Property type (Form 4797)" />
|
||||||
|
<v-text-field v-model.number="disposalForm.disposal_price" type="number" label="Sale price" />
|
||||||
|
<v-text-field v-model.number="disposalForm.disposal_expense" type="number" label="Sale expense" />
|
||||||
|
<v-switch v-model="disposalForm.partial" label="Partial disposal" color="primary" density="compact" hide-details class="full" />
|
||||||
|
<v-text-field v-if="disposalForm.partial" v-model.number="disposalForm.disposed_cost" type="number" label="Cost basis disposed" class="full" />
|
||||||
|
<v-textarea v-model="disposalForm.notes" class="full" label="Notes" rows="2" />
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-row mt-2">
|
||||||
|
<v-btn variant="tonal" prepend-icon="mdi-calculator" @click="$emit('preview-disposal', { ...disposalForm })">Calculate gain/loss</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="error" prepend-icon="mdi-cash-remove" @click="$emit('record-disposal', { ...disposalForm })">Record disposal</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-alert v-if="disposalPreview" class="mt-3" density="compact" :type="disposalPreview.gain_loss >= 0 ? 'success' : 'warning'">
|
||||||
|
Accumulated depreciation {{ currency(disposalPreview.accumulated_depreciation) }} ·
|
||||||
|
Net book value {{ currency(disposalPreview.net_book_value) }} ·
|
||||||
|
Proceeds {{ currency(disposalPreview.realized) }} →
|
||||||
|
<strong>{{ disposalPreview.gain_loss >= 0 ? 'Gain' : 'Loss' }} of {{ currency(Math.abs(disposalPreview.gain_loss)) }}</strong>
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<h3 class="section-title mb-2">Impairments & adjustments</h3>
|
||||||
|
<v-list v-if="assetAdjustments.length" density="compact" class="mb-2">
|
||||||
|
<v-list-item v-for="adj in assetAdjustments" :key="adj.id" prepend-icon="mdi-trending-down">
|
||||||
|
<v-list-item-title>{{ adj.type }} · {{ currency(adj.amount) }} ({{ adj.book_type }})</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ adj.adjustment_date }} · {{ adj.reason || '—' }}</v-list-item-subtitle>
|
||||||
|
<template #append>
|
||||||
|
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="$emit('delete-adjustment', adj.id)" />
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-select v-model="adjustmentForm.type" :items="adjustmentTypeItems" item-title="title" item-value="value" label="Type" />
|
||||||
|
<v-select v-model="adjustmentForm.book_type" :items="bookItems" label="Book" />
|
||||||
|
<v-text-field v-model.number="adjustmentForm.amount" type="number" label="Amount" />
|
||||||
|
<v-text-field v-model="adjustmentForm.adjustment_date" type="date" label="Date" />
|
||||||
|
<v-text-field v-model="adjustmentForm.reason" class="full" label="Reason" />
|
||||||
|
</div>
|
||||||
|
<v-btn variant="tonal" prepend-icon="mdi-plus" class="mt-2" :disabled="!adjustmentForm.amount" @click="addAdjustment">Add adjustment</v-btn>
|
||||||
|
</template>
|
||||||
|
<p v-else class="quiet text-body-2">Save the asset first, then record a disposal.</p>
|
||||||
|
</v-window-item>
|
||||||
|
|
||||||
|
<!-- LEASE -->
|
||||||
|
<v-window-item value="lease">
|
||||||
|
<template v-if="localAsset.id">
|
||||||
|
<v-list v-if="assetLeases.length" lines="two" density="compact" class="mb-3">
|
||||||
|
<v-list-item
|
||||||
|
v-for="lease in assetLeases"
|
||||||
|
:key="lease.id"
|
||||||
|
:active="selectedLeaseId === lease.id"
|
||||||
|
prepend-icon="mdi-file-document-outline"
|
||||||
|
@click="$emit('load-lease-schedule', lease.id)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>{{ lease.lessor || 'Lease' }} · {{ currency(lease.contract_value) }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ lease.start_date }} → {{ lease.end_date }} · {{ lease.payment_frequency }} · {{ lease.discount_rate }}%</v-list-item-subtitle>
|
||||||
|
<template #append>
|
||||||
|
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click.stop="$emit('delete-lease', lease.id)" />
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<h3 class="section-title mb-2">Add lease</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field v-model="leaseForm.lessor" label="Lessor" />
|
||||||
|
<v-text-field v-model.number="leaseForm.contract_value" type="number" label="Contract value (total payments)" />
|
||||||
|
<v-text-field v-model="leaseForm.start_date" type="date" label="Start date" />
|
||||||
|
<v-text-field v-model="leaseForm.end_date" type="date" label="End date" />
|
||||||
|
<v-text-field v-model.number="leaseForm.discount_rate" type="number" label="Discount rate %" />
|
||||||
|
<v-select v-model="leaseForm.payment_frequency" :items="leaseFrequencyItems" item-title="title" item-value="value" label="Payment frequency" />
|
||||||
|
<v-textarea v-model="leaseForm.notes" class="full" label="Notes" rows="2" />
|
||||||
|
</div>
|
||||||
|
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" class="mt-2" @click="addLease">Add lease</v-btn>
|
||||||
|
|
||||||
|
<template v-if="leaseSchedule">
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<h3 class="section-title mb-1">Amortization schedule</h3>
|
||||||
|
<p class="quiet text-body-2 mb-2">
|
||||||
|
ROU asset / present value {{ currency(leaseSchedule.summary.present_value) }} ·
|
||||||
|
{{ leaseSchedule.summary.periods }} payments of {{ currency(leaseSchedule.summary.periodic_payment) }} ·
|
||||||
|
interest {{ currency(leaseSchedule.summary.total_interest) }}
|
||||||
|
</p>
|
||||||
|
<div style="overflow-x:auto;max-height:280px">
|
||||||
|
<table class="asset-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th><th>Date</th><th>Payment</th><th>Interest</th><th>Principal</th><th>Liability</th><th>ROU bal.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in leaseSchedule.rows" :key="row.period">
|
||||||
|
<td>{{ row.period }}</td>
|
||||||
|
<td>{{ row.date }}</td>
|
||||||
|
<td>{{ currency(row.payment) }}</td>
|
||||||
|
<td>{{ currency(row.interest) }}</td>
|
||||||
|
<td>{{ currency(row.principal) }}</td>
|
||||||
|
<td>{{ currency(row.liability_balance) }}</td>
|
||||||
|
<td>{{ currency(row.rou_balance) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
<p v-else class="quiet text-body-2">Save the asset first, then capture a lease.</p>
|
||||||
|
</v-window-item>
|
||||||
|
|
||||||
|
<!-- PREVENTATIVE MAINTENANCE -->
|
||||||
|
<v-window-item value="pm">
|
||||||
|
<template v-if="localAsset.id">
|
||||||
|
<v-list v-if="assetPm.length" lines="two" density="compact" class="mb-3">
|
||||||
|
<v-list-item v-for="pm in assetPm" :key="pm.id" :prepend-icon="pm.active ? 'mdi-wrench-clock' : 'mdi-check-circle-outline'">
|
||||||
|
<v-list-item-title>{{ pm.plan_name || 'Maintenance' }} · every {{ pm.frequency_value }} {{ unitLabel(pm.frequency_unit) }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>
|
||||||
|
<template v-if="pm.active">
|
||||||
|
Next due {{ pm.next_due_date || '—' }}<template v-if="pm.last_completed_date"> · last done {{ pm.last_completed_date }}</template>
|
||||||
|
</template>
|
||||||
|
<template v-else>Schedule complete (past {{ pm.end_date }})</template>
|
||||||
|
<template v-if="pm.total_cost"> · spent {{ currency(pm.total_cost) }}</template>
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
<template #append>
|
||||||
|
<v-btn v-if="pm.active" size="small" variant="text" color="primary" @click="openComplete(pm)">Complete</v-btn>
|
||||||
|
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="$emit('detach-pm', pm.id)" />
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<template v-if="pmCompletions.length">
|
||||||
|
<h3 class="section-title mb-2">Completed forms</h3>
|
||||||
|
<v-list density="compact" class="mb-3">
|
||||||
|
<v-list-item v-for="c in pmCompletions" :key="c.id" prepend-icon="mdi-clipboard-check-outline">
|
||||||
|
<v-list-item-title>{{ c.plan_name || 'Maintenance' }} · {{ (c.completed_at || '').slice(0, 10) }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>
|
||||||
|
by {{ c.completed_by_name || 'Unknown' }} · safety {{ c.ratings.safety || '–' }}★
|
||||||
|
<template v-if="c.photo_count"> · {{ c.photo_count }} photo(s)</template>
|
||||||
|
<template v-if="c.has_signature"> · signed</template>
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
<template #append>
|
||||||
|
<v-btn size="small" variant="text" @click="$emit('view-completion', c.id)">View</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
<v-divider class="mb-3" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-divider class="mb-3" />
|
||||||
|
<h3 class="section-title mb-2">Attach a PM plan</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-select v-model="pmForm.plan_id" :items="planOptions" item-title="title" item-value="value" label="Plan" class="full" @update:model-value="applyPlanDefaults" />
|
||||||
|
<v-text-field v-model.number="pmForm.frequency_value" type="number" label="Every" />
|
||||||
|
<v-select v-model="pmForm.frequency_unit" :items="pmFrequencyUnits" item-title="title" item-value="value" label="Frequency unit" />
|
||||||
|
<v-text-field v-model="pmForm.start_date" type="date" label="Start / first due" />
|
||||||
|
<v-text-field v-model="pmForm.end_date" type="date" label="End date (depreciation end)" />
|
||||||
|
</div>
|
||||||
|
<v-btn color="primary" variant="tonal" prepend-icon="mdi-plus" class="mt-2" :disabled="!pmForm.plan_id" @click="attachPm">Attach plan</v-btn>
|
||||||
|
</template>
|
||||||
|
<p v-else class="quiet text-body-2">Save the asset first, then attach PM plans.</p>
|
||||||
|
</v-window-item>
|
||||||
</v-window>
|
</v-window>
|
||||||
|
|
||||||
<v-alert v-if="drawerError" class="mt-4" density="compact" type="error">{{ drawerError }}</v-alert>
|
<v-alert v-if="drawerError" class="mt-4" density="compact" type="error">{{ drawerError }}</v-alert>
|
||||||
<div class="toolbar-row mt-4">
|
<div class="toolbar-row mt-4">
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn variant="text" @click="$emit('update:modelValue', false)">Cancel</v-btn>
|
<v-btn variant="text" @click="$emit('update:modelValue', false)">Cancel</v-btn>
|
||||||
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="$emit('save-asset', localAsset)">Save asset</v-btn>
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="attemptSave">Save asset</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<v-dialog v-model="completeDialog" max-width="620" scrollable>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Complete: {{ completeTarget?.plan_name || 'Maintenance' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="quiet text-body-2 mb-3">Closing as <strong>{{ currentUserName || 'current user' }}</strong></div>
|
||||||
|
|
||||||
|
<template v-if="completeGuidancePhotos.length">
|
||||||
|
<h3 class="section-title mb-1">Guidance photos</h3>
|
||||||
|
<div class="pm-guidance-grid mb-3">
|
||||||
|
<figure v-for="photo in completeGuidancePhotos" :key="photo.id" class="pm-guidance-fig">
|
||||||
|
<img :src="photo.data" :alt="photo.caption || 'guidance'" />
|
||||||
|
<figcaption v-if="photo.caption" class="quiet text-caption">{{ photo.caption }}</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<h3 class="section-title mb-1">Steps</h3>
|
||||||
|
<p v-if="!completeSteps.length" class="quiet text-body-2">This plan has no steps.</p>
|
||||||
|
<div v-for="(step, i) in completeSteps" :key="i" class="d-flex align-start mb-1">
|
||||||
|
<v-checkbox-btn v-model="step.done" />
|
||||||
|
<div class="mt-1">
|
||||||
|
<div>{{ step.title }}</div>
|
||||||
|
<div v-if="step.details" class="quiet text-caption">{{ step.details }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="my-3" />
|
||||||
|
<h3 class="section-title mb-2">Condition ratings</h3>
|
||||||
|
<div v-for="q in ratingQuestions" :key="q.key" class="d-flex align-center mb-1">
|
||||||
|
<div class="text-body-2" style="min-width:230px">{{ q.label }}</div>
|
||||||
|
<v-rating v-model="completeRatings[q.key]" length="5" color="amber" active-color="amber" density="compact" hover />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="completeComponents.length">
|
||||||
|
<v-divider class="my-3" />
|
||||||
|
<div class="text-body-2 font-weight-medium mb-1">Parts used</div>
|
||||||
|
<div v-for="(c, i) in completeComponents" :key="`cc${i}`" class="d-flex text-body-2 mb-1">
|
||||||
|
<span>{{ c.part_number ? c.part_number + ' · ' : '' }}{{ c.description }}<span v-if="c.supplier" class="quiet"> ({{ c.supplier }})</span></span>
|
||||||
|
<v-spacer />
|
||||||
|
<span>{{ currency(c.cost) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-text-field v-model.number="completeCost" type="number" prefix="$" label="Service cost" density="compact" hide-details class="mt-3" />
|
||||||
|
|
||||||
|
<v-divider class="my-3" />
|
||||||
|
<div class="toolbar-row mb-1">
|
||||||
|
<h3 class="section-title">Completion notes</h3>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="x-small" variant="tonal" prepend-icon="mdi-plus" @click="completeNotesList.push('')">Add note</v-btn>
|
||||||
|
</div>
|
||||||
|
<div v-for="(note, i) in completeNotesList" :key="`note${i}`" class="d-flex align-center mb-1" style="gap:6px">
|
||||||
|
<v-text-field v-model="completeNotesList[i]" density="compact" hide-details placeholder="Note" />
|
||||||
|
<v-btn icon="mdi-close" size="x-small" variant="text" @click="completeNotesList.splice(i, 1)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="my-3" />
|
||||||
|
<div class="toolbar-row mb-1">
|
||||||
|
<h3 class="section-title">Photos</h3>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="x-small" variant="tonal" prepend-icon="mdi-camera-plus-outline" @click="$refs.pmPhotoInput.click()">Add photos</v-btn>
|
||||||
|
<input ref="pmPhotoInput" hidden type="file" accept="image/*" multiple @change="onCompletePhotos" />
|
||||||
|
</div>
|
||||||
|
<div v-if="completePhotos.length" class="pm-photo-grid">
|
||||||
|
<div v-for="(p, i) in completePhotos" :key="`p${i}`" class="pm-photo-wrap">
|
||||||
|
<img :src="p.data" class="pm-photo-thumb" :alt="p.name || 'maintenance photo'" />
|
||||||
|
<v-btn icon="mdi-close" size="x-small" class="pm-photo-remove" @click="completePhotos.splice(i, 1)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="my-3" />
|
||||||
|
<h3 class="section-title mb-1">Signature <span class="text-error">*</span></h3>
|
||||||
|
<SignaturePad v-model="completeSignature" />
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="completeDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-check" :disabled="!completeSignature" @click="submitComplete">Mark complete</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Prompt shown when depreciation-critical fields change on an already-depreciated asset -->
|
||||||
|
<v-dialog v-model="applyDialog" max-width="560">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Apply depreciation change</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="mb-2">You changed depreciation-critical fields ({{ changedCriticalLabels }}).</p>
|
||||||
|
<p class="mb-3 quiet text-body-2">Choose when the change takes effect:</p>
|
||||||
|
<v-radio-group v-model="applyChoice" hide-details>
|
||||||
|
<v-radio value="placed_in_service">
|
||||||
|
<template #label>
|
||||||
|
<div>
|
||||||
|
<div>Placed-in-service date</div>
|
||||||
|
<div class="quiet text-caption">Rebuild the entire depreciation history from the start (clears prior depreciation).</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-radio>
|
||||||
|
<v-radio value="going_forward" class="mt-2">
|
||||||
|
<template #label>
|
||||||
|
<div>
|
||||||
|
<div>Current period — going forward</div>
|
||||||
|
<div class="quiet text-caption">Keep depreciation already recorded; apply the change to remaining periods.</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-radio>
|
||||||
|
</v-radio-group>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="applyDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="confirmApply">Save asset</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
</v-navigation-drawer>
|
</v-navigation-drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import BarcodeLabel from './BarcodeLabel.vue';
|
||||||
|
import SignaturePad from './SignaturePad.vue';
|
||||||
import { inputTypeForCustomField, makeDefaultBooks } from '../utils/assets';
|
import { inputTypeForCustomField, makeDefaultBooks } from '../utils/assets';
|
||||||
import { clonePlain } from '../utils/clone';
|
import { clonePlain } from '../utils/clone';
|
||||||
import { conditionItems, conventionItems, newUsedItems, taskTypeItems } from '../constants';
|
import { currency } from '../utils/format';
|
||||||
|
import {
|
||||||
|
adjustmentTypeItems,
|
||||||
|
conditionItems,
|
||||||
|
conventionItems,
|
||||||
|
disposalMethodItems,
|
||||||
|
leaseFrequencyItems,
|
||||||
|
newUsedItems,
|
||||||
|
pmFrequencyUnits,
|
||||||
|
propertyTypeItems,
|
||||||
|
taskTypeItems
|
||||||
|
} from '../constants';
|
||||||
|
|
||||||
function blankWarranty() {
|
function blankWarranty() {
|
||||||
return { provider: '', phone: '', start_date: '', expiration_date: '', warranty_limit: '', actual_usage: '', coverage_details: '' };
|
return { provider: '', phone: '', start_date: '', expiration_date: '', warranty_limit: '', actual_usage: '', coverage_details: '' };
|
||||||
@@ -236,19 +621,60 @@ function blankTask() {
|
|||||||
return { title: '', type: 'maintenance', due_date: '', notes: '' };
|
return { title: '', type: 'maintenance', due_date: '', notes: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function blankDisposal() {
|
||||||
|
return {
|
||||||
|
book_type: 'GAAP',
|
||||||
|
method: 'sale',
|
||||||
|
disposal_date: new Date().toISOString().slice(0, 10),
|
||||||
|
property_type: '1245',
|
||||||
|
disposal_price: 0,
|
||||||
|
disposal_expense: 0,
|
||||||
|
partial: false,
|
||||||
|
disposed_cost: 0,
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankAdjustment() {
|
||||||
|
return { type: 'impairment', book_type: 'GAAP', amount: 0, adjustment_date: new Date().toISOString().slice(0, 10), reason: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankLease() {
|
||||||
|
return { lessor: '', contract_value: 0, start_date: '', end_date: '', discount_rate: 0, payment_frequency: 'monthly', notes: '' };
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
components: { BarcodeLabel, SignaturePad },
|
||||||
props: {
|
props: {
|
||||||
assetForm: { type: Object, required: true },
|
assetForm: { type: Object, required: true },
|
||||||
|
assetAdjustments: { type: Array, default: () => [] },
|
||||||
|
assetDisposals: { type: Array, default: () => [] },
|
||||||
assetFiles: { type: Array, default: () => [] },
|
assetFiles: { type: Array, default: () => [] },
|
||||||
|
assetLeases: { type: Array, default: () => [] },
|
||||||
assetNotes: { type: Array, default: () => [] },
|
assetNotes: { type: Array, default: () => [] },
|
||||||
|
assetPhotos: { type: Array, default: () => [] },
|
||||||
|
assetPm: { type: Array, default: () => [] },
|
||||||
assetTasks: { type: Array, default: () => [] },
|
assetTasks: { type: Array, default: () => [] },
|
||||||
assetWarranties: { type: Array, default: () => [] },
|
assetWarranties: { type: Array, default: () => [] },
|
||||||
|
bookItems: { type: Array, default: () => [] },
|
||||||
|
pmPlans: { type: Array, default: () => [] },
|
||||||
|
pmDefaults: { type: Object, default: () => ({ value: 3, unit: 'months' }) },
|
||||||
|
currentUserName: { type: String, default: '' },
|
||||||
categoryItems: { type: Array, required: true },
|
categoryItems: { type: Array, required: true },
|
||||||
|
disposalPreview: { type: Object, default: null },
|
||||||
drawerError: { type: String, default: '' },
|
drawerError: { type: String, default: '' },
|
||||||
entityOptions: { type: Array, required: true },
|
entityOptions: { type: Array, required: true },
|
||||||
|
locationItems: { type: Array, default: () => [] },
|
||||||
|
departmentItems: { type: Array, default: () => [] },
|
||||||
|
vendorItems: { type: Array, default: () => [] },
|
||||||
|
categoryClassNumbers: { type: Object, default: () => ({}) },
|
||||||
|
zoneItems: { type: Array, default: () => [] },
|
||||||
|
zoneDetails: { type: Object, default: () => ({}) },
|
||||||
|
leaseSchedule: { type: Object, default: null },
|
||||||
methodItems: { type: Array, required: true },
|
methodItems: { type: Array, required: true },
|
||||||
modelValue: { type: Boolean, default: false },
|
modelValue: { type: Boolean, default: false },
|
||||||
saving: { type: Boolean, default: false },
|
saving: { type: Boolean, default: false },
|
||||||
|
selectedLeaseId: { type: [Number, String], default: null },
|
||||||
selectedTemplateFields: { type: Array, required: true },
|
selectedTemplateFields: { type: Array, required: true },
|
||||||
selectedTemplateId: { type: [Number, String], default: null },
|
selectedTemplateId: { type: [Number, String], default: null },
|
||||||
statusItems: { type: Array, required: true },
|
statusItems: { type: Array, required: true },
|
||||||
@@ -259,26 +685,118 @@ export default {
|
|||||||
'upload-file', 'download-file', 'delete-file',
|
'upload-file', 'download-file', 'delete-file',
|
||||||
'add-warranty', 'delete-warranty',
|
'add-warranty', 'delete-warranty',
|
||||||
'add-task', 'toggle-task', 'delete-task',
|
'add-task', 'toggle-task', 'delete-task',
|
||||||
'add-note', 'delete-note'
|
'add-note', 'delete-note',
|
||||||
|
'upload-photo', 'update-photo', 'delete-photo',
|
||||||
|
'preview-disposal', 'record-disposal', 'reverse-disposal',
|
||||||
|
'add-adjustment', 'delete-adjustment',
|
||||||
|
'save-lease', 'delete-lease', 'load-lease-schedule',
|
||||||
|
'print-label',
|
||||||
|
'attach-pm', 'detach-pm', 'complete-pm', 'view-completion'
|
||||||
],
|
],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
tab: 'details',
|
tab: 'details',
|
||||||
|
adjustmentTypeItems,
|
||||||
conditionItems,
|
conditionItems,
|
||||||
conventionItems,
|
conventionItems,
|
||||||
|
disposalMethodItems,
|
||||||
|
leaseFrequencyItems,
|
||||||
newUsedItems,
|
newUsedItems,
|
||||||
|
pmFrequencyUnits,
|
||||||
|
propertyTypeItems,
|
||||||
taskTypeItems,
|
taskTypeItems,
|
||||||
localAsset: this.normalize(this.assetForm),
|
localAsset: this.normalize(this.assetForm),
|
||||||
warrantyForm: blankWarranty(),
|
warrantyForm: blankWarranty(),
|
||||||
taskForm: blankTask(),
|
taskForm: blankTask(),
|
||||||
|
disposalForm: blankDisposal(),
|
||||||
|
adjustmentForm: blankAdjustment(),
|
||||||
|
leaseForm: blankLease(),
|
||||||
|
pmForm: { plan_id: null, frequency_value: 3, frequency_unit: 'months', start_date: '', end_date: '' },
|
||||||
|
completeDialog: false,
|
||||||
|
completeTarget: null,
|
||||||
|
completeSteps: [],
|
||||||
|
completeGuidancePhotos: [],
|
||||||
|
completeComponents: [],
|
||||||
|
completeCost: 0,
|
||||||
|
completeNotesList: [],
|
||||||
|
completeRatings: { safety: 0, physical: 0, operating: 0 },
|
||||||
|
completeSignature: '',
|
||||||
|
completePhotos: [],
|
||||||
|
ratingQuestions: [
|
||||||
|
{ key: 'safety', label: 'Safety condition / Is it safe?' },
|
||||||
|
{ key: 'physical', label: 'Physical condition / appearance' },
|
||||||
|
{ key: 'operating', label: 'Operating condition / functionality' }
|
||||||
|
],
|
||||||
noteDraft: '',
|
noteDraft: '',
|
||||||
|
captionDrafts: {},
|
||||||
|
criticalSnapshot: null,
|
||||||
|
applyDialog: false,
|
||||||
|
applyChoice: 'placed_in_service',
|
||||||
requiredRule: (value) => value !== null && value !== undefined && value !== '' || 'Required'
|
requiredRule: (value) => value !== null && value !== undefined && value !== '' || 'Required'
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
barcodeValue() {
|
||||||
|
return (this.localAsset.barcode_value || this.localAsset.asset_id || '').trim();
|
||||||
|
},
|
||||||
|
assetClassNumber() {
|
||||||
|
// Live from the selected category; fall back to the value stored on the asset.
|
||||||
|
return this.categoryClassNumbers[this.localAsset.category] || this.localAsset.asset_class_number || '';
|
||||||
|
},
|
||||||
|
zoneGuidance() {
|
||||||
|
const zone = this.zoneDetails[this.localAsset.special_zone];
|
||||||
|
if (!zone) return '';
|
||||||
|
const pis = this.localAsset.in_service_date || this.localAsset.acquired_date || '';
|
||||||
|
const inWindow = (!zone.pis_start || !pis || pis >= zone.pis_start) && (!zone.pis_end || !pis || pis <= zone.pis_end);
|
||||||
|
const base = `${zone.name}: ${zone.allowance_percent}% §168 allowance applied to the FEDERAL book`
|
||||||
|
+ `${zone.pis_start || zone.pis_end ? ` for assets placed in service ${zone.pis_start || '…'} to ${zone.pis_end || '…'}` : ''}.`;
|
||||||
|
const lease = zone.leasehold_life_years ? ` Qualified leasehold improvements use a ${zone.leasehold_life_years}-year life.` : '';
|
||||||
|
if (pis && !inWindow) return `${base} This asset's placed-in-service date is OUTSIDE the window, so the allowance will not apply.${lease}`;
|
||||||
|
return `${base}${lease}`;
|
||||||
|
},
|
||||||
|
changedCriticalLabels() {
|
||||||
|
if (!this.criticalSnapshot) return 'depreciation settings';
|
||||||
|
const current = this.criticalObject(this.localAsset);
|
||||||
|
const snap = this.criticalSnapshot;
|
||||||
|
const labels = {
|
||||||
|
property_type: 'property type',
|
||||||
|
in_service_date: 'placed-in-service date',
|
||||||
|
acquisition_cost: 'acquisition value',
|
||||||
|
useful_life_months: 'estimated life',
|
||||||
|
salvage_value: 'salvage value',
|
||||||
|
business_use_percent: 'business-use %',
|
||||||
|
depreciation_method: 'depreciation method',
|
||||||
|
special_zone: 'special depreciation zone'
|
||||||
|
};
|
||||||
|
const changed = Object.keys(labels).filter((key) => JSON.stringify(current[key]) !== JSON.stringify(snap[key])).map((key) => labels[key]);
|
||||||
|
if (JSON.stringify(current.books) !== JSON.stringify(snap.books)) changed.push('book settings (method, life, §179, §168, cost, or business-use)');
|
||||||
|
return changed.join(', ') || 'depreciation settings';
|
||||||
|
},
|
||||||
|
planOptions() {
|
||||||
|
return this.pmPlans.map((plan) => ({
|
||||||
|
title: `${plan.name} (every ${plan.frequency_value} ${this.unitLabel(plan.frequency_unit)})`,
|
||||||
|
value: plan.id
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
depreciationEnd() {
|
||||||
|
const start = this.localAsset.in_service_date;
|
||||||
|
const months = Number(this.localAsset.useful_life_months) || 0;
|
||||||
|
if (!start || !months) return '';
|
||||||
|
const date = new Date(`${start}T00:00:00`);
|
||||||
|
date.setMonth(date.getMonth() + months);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
},
|
||||||
|
pmCompletions() {
|
||||||
|
return (this.assetPm || [])
|
||||||
|
.flatMap((pm) => (pm.completions || []).map((c) => ({ ...c, plan_name: pm.plan_name })))
|
||||||
|
.sort((a, b) => String(b.completed_at).localeCompare(String(a.completed_at)));
|
||||||
|
}
|
||||||
|
},
|
||||||
watch: {
|
watch: {
|
||||||
assetForm: {
|
assetForm: {
|
||||||
handler(value) {
|
handler(value) {
|
||||||
this.localAsset = this.normalize(value);
|
this.localAsset = this.normalize(value);
|
||||||
|
this.criticalSnapshot = this.criticalObject(this.localAsset);
|
||||||
},
|
},
|
||||||
deep: true
|
deep: true
|
||||||
},
|
},
|
||||||
@@ -287,12 +805,18 @@ export default {
|
|||||||
this.tab = 'details';
|
this.tab = 'details';
|
||||||
this.warrantyForm = blankWarranty();
|
this.warrantyForm = blankWarranty();
|
||||||
this.taskForm = blankTask();
|
this.taskForm = blankTask();
|
||||||
|
this.disposalForm = blankDisposal();
|
||||||
|
this.adjustmentForm = blankAdjustment();
|
||||||
|
this.leaseForm = blankLease();
|
||||||
|
this.resetPmForm();
|
||||||
this.noteDraft = '';
|
this.noteDraft = '';
|
||||||
|
this.criticalSnapshot = this.criticalObject(this.localAsset);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
inputTypeForCustomField,
|
inputTypeForCustomField,
|
||||||
|
currency,
|
||||||
normalize(value) {
|
normalize(value) {
|
||||||
const asset = clonePlain(value);
|
const asset = clonePlain(value);
|
||||||
if (!Array.isArray(asset.books) || !asset.books.length) {
|
if (!Array.isArray(asset.books) || !asset.books.length) {
|
||||||
@@ -301,6 +825,46 @@ export default {
|
|||||||
if (!asset.custom_fields || typeof asset.custom_fields !== 'object') asset.custom_fields = {};
|
if (!asset.custom_fields || typeof asset.custom_fields !== 'object') asset.custom_fields = {};
|
||||||
return asset;
|
return asset;
|
||||||
},
|
},
|
||||||
|
// Snapshot of the depreciation-critical fields, used to detect changes on save.
|
||||||
|
criticalObject(asset) {
|
||||||
|
return {
|
||||||
|
property_type: asset.property_type ?? null,
|
||||||
|
in_service_date: asset.in_service_date ?? null,
|
||||||
|
acquisition_cost: Number(asset.acquisition_cost || 0),
|
||||||
|
useful_life_months: Number(asset.useful_life_months || 0),
|
||||||
|
salvage_value: Number(asset.salvage_value || 0),
|
||||||
|
business_use_percent: Number(asset.business_use_percent || 0),
|
||||||
|
depreciation_method: asset.depreciation_method ?? null,
|
||||||
|
special_zone: asset.special_zone ?? null,
|
||||||
|
books: (asset.books || []).map((b) => ({
|
||||||
|
book_type: b.book_type,
|
||||||
|
cost: b.cost ?? null,
|
||||||
|
depreciation_method: b.depreciation_method ?? null,
|
||||||
|
useful_life_months: b.useful_life_months ?? null,
|
||||||
|
section_179_amount: Number(b.section_179_amount || 0),
|
||||||
|
bonus_percent: Number(b.bonus_percent || 0),
|
||||||
|
business_use_percent: b.business_use_percent ?? null,
|
||||||
|
convention: b.convention ?? null,
|
||||||
|
active: b.active !== false
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
},
|
||||||
|
hasCriticalChange() {
|
||||||
|
return this.criticalSnapshot && JSON.stringify(this.criticalObject(this.localAsset)) !== JSON.stringify(this.criticalSnapshot);
|
||||||
|
},
|
||||||
|
attemptSave() {
|
||||||
|
// Existing, already-saved asset whose depreciation inputs changed → ask when to apply.
|
||||||
|
if (this.localAsset.id && this.hasCriticalChange()) {
|
||||||
|
this.applyChoice = 'placed_in_service';
|
||||||
|
this.applyDialog = true;
|
||||||
|
} else {
|
||||||
|
this.$emit('save-asset', this.localAsset);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmApply() {
|
||||||
|
this.$emit('save-asset', { ...this.localAsset, depreciation_apply: this.applyChoice });
|
||||||
|
this.applyDialog = false;
|
||||||
|
},
|
||||||
methodLabel(code) {
|
methodLabel(code) {
|
||||||
return this.methodItems.find((item) => item.value === code)?.title || code;
|
return this.methodItems.find((item) => item.value === code)?.title || code;
|
||||||
},
|
},
|
||||||
@@ -315,6 +879,19 @@ export default {
|
|||||||
if (file) this.$emit('upload-file', file);
|
if (file) this.$emit('upload-file', file);
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
},
|
},
|
||||||
|
onPhotoPick(event) {
|
||||||
|
for (const file of Array.from(event.target.files || [])) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => this.$emit('upload-photo', { name: file.name, mime: file.type, data: String(reader.result) });
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
event.target.value = '';
|
||||||
|
},
|
||||||
|
saveCaption(photo) {
|
||||||
|
const caption = this.captionDrafts[photo.id];
|
||||||
|
if (caption === undefined || caption === photo.caption) return;
|
||||||
|
this.$emit('update-photo', { id: photo.id, caption });
|
||||||
|
},
|
||||||
addWarranty() {
|
addWarranty() {
|
||||||
this.$emit('add-warranty', { ...this.warrantyForm });
|
this.$emit('add-warranty', { ...this.warrantyForm });
|
||||||
this.warrantyForm = blankWarranty();
|
this.warrantyForm = blankWarranty();
|
||||||
@@ -326,6 +903,73 @@ export default {
|
|||||||
addNote() {
|
addNote() {
|
||||||
this.$emit('add-note', this.noteDraft.trim());
|
this.$emit('add-note', this.noteDraft.trim());
|
||||||
this.noteDraft = '';
|
this.noteDraft = '';
|
||||||
|
},
|
||||||
|
addAdjustment() {
|
||||||
|
this.$emit('add-adjustment', { ...this.adjustmentForm });
|
||||||
|
this.adjustmentForm = blankAdjustment();
|
||||||
|
},
|
||||||
|
addLease() {
|
||||||
|
this.$emit('save-lease', { ...this.leaseForm });
|
||||||
|
this.leaseForm = blankLease();
|
||||||
|
},
|
||||||
|
useAssetIdAsBarcode() {
|
||||||
|
this.localAsset.barcode_value = this.localAsset.asset_id || '';
|
||||||
|
},
|
||||||
|
unitLabel(unit) {
|
||||||
|
return (pmFrequencyUnits.find((u) => u.value === unit) || {}).title || unit;
|
||||||
|
},
|
||||||
|
resetPmForm() {
|
||||||
|
this.pmForm = {
|
||||||
|
plan_id: null,
|
||||||
|
frequency_value: this.pmDefaults?.value || 3,
|
||||||
|
frequency_unit: this.pmDefaults?.unit || 'months',
|
||||||
|
start_date: new Date().toISOString().slice(0, 10),
|
||||||
|
end_date: this.depreciationEnd
|
||||||
|
};
|
||||||
|
},
|
||||||
|
applyPlanDefaults(planId) {
|
||||||
|
const plan = this.pmPlans.find((p) => p.id === planId);
|
||||||
|
if (plan) {
|
||||||
|
this.pmForm.frequency_value = plan.frequency_value;
|
||||||
|
this.pmForm.frequency_unit = plan.frequency_unit;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
attachPm() {
|
||||||
|
this.$emit('attach-pm', { ...this.pmForm, next_due_date: this.pmForm.start_date });
|
||||||
|
this.resetPmForm();
|
||||||
|
},
|
||||||
|
openComplete(pm) {
|
||||||
|
this.completeTarget = pm;
|
||||||
|
this.completeSteps = (pm.steps || []).map((s) => ({ title: s.title, details: s.details, done: false }));
|
||||||
|
this.completeGuidancePhotos = pm.plan_photos || [];
|
||||||
|
this.completeComponents = (pm.components || []).map((c) => ({ ...c }));
|
||||||
|
this.completeCost = this.completeComponents.reduce((sum, c) => sum + (Number(c.cost) || 0), 0);
|
||||||
|
this.completeNotesList = [''];
|
||||||
|
this.completeRatings = { safety: 0, physical: 0, operating: 0 };
|
||||||
|
this.completeSignature = '';
|
||||||
|
this.completePhotos = [];
|
||||||
|
this.completeDialog = true;
|
||||||
|
},
|
||||||
|
onCompletePhotos(event) {
|
||||||
|
for (const file of Array.from(event.target.files || [])) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => this.completePhotos.push({ name: file.name, mime: file.type, data: String(reader.result) });
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
event.target.value = '';
|
||||||
|
},
|
||||||
|
submitComplete() {
|
||||||
|
this.$emit('complete-pm', {
|
||||||
|
apId: this.completeTarget.id,
|
||||||
|
steps: this.completeSteps.map((s) => ({ title: s.title, done: s.done })),
|
||||||
|
components: this.completeComponents,
|
||||||
|
cost: this.completeCost,
|
||||||
|
notes_list: this.completeNotesList.map((n) => String(n || '').trim()).filter(Boolean),
|
||||||
|
ratings: { ...this.completeRatings },
|
||||||
|
signature: this.completeSignature,
|
||||||
|
photos: this.completePhotos
|
||||||
|
});
|
||||||
|
this.completeDialog = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
224
src/components/AssetIdTemplateSettings.vue
Normal file
224
src/components/AssetIdTemplateSettings.vue
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Asset ID templates</h2>
|
||||||
|
<p class="quiet text-body-2">
|
||||||
|
Naming patterns for auto-assigning asset tags. Use <strong>#</strong> where the number goes — it is zero-padded to that
|
||||||
|
width and increments automatically (e.g. <code>DT-######</code> → DT-000042). Assign a template to a category in
|
||||||
|
<strong>Asset categories</strong> below; new assets in that category get the next tag automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New template</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="templates"
|
||||||
|
row-key="id"
|
||||||
|
:page-size="10"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter templates"
|
||||||
|
empty-text="No ID templates yet."
|
||||||
|
>
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.name }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-pattern="{ row }"><code>{{ row.pattern }}</code></template>
|
||||||
|
<template #cell-preview="{ row }"><code>{{ row.preview }}</code></template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
|
||||||
|
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
|
||||||
|
<!-- Create / edit -->
|
||||||
|
<v-dialog v-model="dialog" max-width="480">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ form.id ? 'Edit ID template' : 'New ID template' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field v-model="form.name" label="Template name" autofocus />
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.pattern"
|
||||||
|
label="Pattern"
|
||||||
|
placeholder="DT-######"
|
||||||
|
hint="Use # for the auto-incrementing number"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
<v-text-field v-model.number="form.next_number" type="number" min="1" label="Next number" class="mt-2" />
|
||||||
|
<v-alert type="info" variant="tonal" density="comfortable" class="mt-3">
|
||||||
|
Next tag preview: <strong><code>{{ preview }}</code></strong>
|
||||||
|
</v-alert>
|
||||||
|
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!canSave" @click="save">Save</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Delete confirmation -->
|
||||||
|
<v-dialog v-model="deleteDialog" max-width="500">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-error">Delete ID template</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="mb-3">Delete the template <strong>“{{ deleteTarget?.name }}”</strong>?</p>
|
||||||
|
<v-alert
|
||||||
|
:type="deleteTarget && deleteTarget.category_count ? 'warning' : 'info'"
|
||||||
|
variant="tonal"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
|
<template v-if="deleteTarget && deleteTarget.category_count">
|
||||||
|
This template is assigned to <strong>{{ deleteTarget.category_count }}</strong>
|
||||||
|
categor{{ deleteTarget.category_count === 1 ? 'y' : 'ies' }}.
|
||||||
|
</template>
|
||||||
|
<template v-else>This template is not assigned to any category.</template>
|
||||||
|
</v-alert>
|
||||||
|
<ul class="delete-impact">
|
||||||
|
<li>Existing assets <strong>keep their current IDs</strong> — nothing is renamed.</li>
|
||||||
|
<li>Affected categories are <strong>unassigned</strong>; new assets in them fall back to the default <code>MA-#####</code> sequence.</li>
|
||||||
|
</ul>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete template</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DataTable from './DataTable.vue';
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
|
||||||
|
function blankForm() {
|
||||||
|
return { id: null, name: '', pattern: '', next_number: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { DataTable },
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
emits: ['updated'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
templates: [],
|
||||||
|
dialog: false,
|
||||||
|
deleteDialog: false,
|
||||||
|
deleteTarget: null,
|
||||||
|
form: blankForm(),
|
||||||
|
saving: false,
|
||||||
|
error: '',
|
||||||
|
dialogError: '',
|
||||||
|
columns: [
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'pattern', label: 'Pattern' },
|
||||||
|
{ key: 'preview', label: 'Next tag' },
|
||||||
|
{ key: 'next_number', label: 'Next #', format: 'number' },
|
||||||
|
{ key: 'category_count', label: 'Categories', format: 'number' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
preview() {
|
||||||
|
const pattern = String(this.form.pattern || '');
|
||||||
|
const n = Math.max(1, Number(this.form.next_number) || 1);
|
||||||
|
if (!pattern.includes('#')) return '—';
|
||||||
|
return pattern.replace(/#+/, (hashes) => String(n).padStart(hashes.length, '0'));
|
||||||
|
},
|
||||||
|
canSave() {
|
||||||
|
return Boolean(this.form.name.trim()) && String(this.form.pattern).includes('#');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/asset-id-templates')).json();
|
||||||
|
this.templates = data.templates;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openNew() {
|
||||||
|
this.form = blankForm();
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
openEdit(row) {
|
||||||
|
this.form = { id: row.id, name: row.name, pattern: row.pattern, next_number: row.next_number };
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
if (!this.canSave) return;
|
||||||
|
this.saving = true;
|
||||||
|
this.dialogError = '';
|
||||||
|
try {
|
||||||
|
const method = this.form.id ? 'PUT' : 'POST';
|
||||||
|
const url = this.form.id ? `/api/asset-id-templates/${this.form.id}` : '/api/asset-id-templates';
|
||||||
|
await this.api(url, {
|
||||||
|
method,
|
||||||
|
body: JSON.stringify({ name: this.form.name.trim(), pattern: String(this.form.pattern).trim(), next_number: this.form.next_number })
|
||||||
|
});
|
||||||
|
this.dialog = false;
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.dialogError = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmDelete(row) {
|
||||||
|
this.deleteTarget = row;
|
||||||
|
this.deleteDialog = true;
|
||||||
|
},
|
||||||
|
async performDelete() {
|
||||||
|
const target = this.deleteTarget;
|
||||||
|
this.deleteDialog = false;
|
||||||
|
this.deleteTarget = null;
|
||||||
|
if (!target) return;
|
||||||
|
try {
|
||||||
|
await this.api(`/api/asset-id-templates/${target.id}`, { method: 'DELETE' });
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.delete-impact {
|
||||||
|
padding-left: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.delete-impact li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
50
src/components/BarcodeLabel.vue
Normal file
50
src/components/BarcodeLabel.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div class="barcode-label">
|
||||||
|
<svg ref="svg" class="barcode-svg" />
|
||||||
|
<div class="barcode-meta">
|
||||||
|
<div class="barcode-id">{{ value }}</div>
|
||||||
|
<div v-if="caption" class="barcode-caption">{{ caption }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import JsBarcode from 'jsbarcode';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
value: { type: String, required: true },
|
||||||
|
caption: { type: String, default: '' },
|
||||||
|
format: { type: String, default: 'CODE128' }
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.render();
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value() {
|
||||||
|
this.render();
|
||||||
|
},
|
||||||
|
format() {
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
render() {
|
||||||
|
const svg = this.$refs.svg;
|
||||||
|
if (!svg || !this.value) return;
|
||||||
|
try {
|
||||||
|
JsBarcode(svg, this.value, {
|
||||||
|
format: this.format,
|
||||||
|
displayValue: false,
|
||||||
|
margin: 0,
|
||||||
|
height: 46,
|
||||||
|
width: 1.6
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Value is not encodable in the chosen symbology; leave the SVG blank.
|
||||||
|
svg.innerHTML = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
267
src/components/CategorySettings.vue
Normal file
267
src/components/CategorySettings.vue
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Asset categories</h2>
|
||||||
|
<p class="quiet text-body-2">
|
||||||
|
Manage the categories offered in the asset form’s Category dropdown. Renaming a category updates every asset that uses it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New category</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="categories"
|
||||||
|
row-key="id"
|
||||||
|
:page-size="10"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter categories"
|
||||||
|
empty-text="No categories yet."
|
||||||
|
>
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.name }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-code="{ row }">{{ row.code || '—' }}</template>
|
||||||
|
<template #cell-id_template_name="{ row }">
|
||||||
|
<span v-if="row.id_template_name">{{ row.id_template_name }} <span class="quiet">({{ row.id_template_pattern }})</span></span>
|
||||||
|
<span v-else class="quiet">Default (MA-#####)</span>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
|
||||||
|
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
|
||||||
|
<!-- Create / edit -->
|
||||||
|
<v-dialog v-model="dialog" max-width="460">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ form.id ? 'Edit category' : 'New category' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field v-model="form.name" label="Category name" autofocus @keyup.enter="save" />
|
||||||
|
<v-text-field v-model="form.code" label="Short code (optional)" />
|
||||||
|
<v-autocomplete
|
||||||
|
v-model="classLookup"
|
||||||
|
:items="assetClassItems"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
label="Look up IRS asset class (optional)"
|
||||||
|
placeholder="Search by name or class number…"
|
||||||
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
clearable
|
||||||
|
@update:model-value="applyAssetClass"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.asset_class_number"
|
||||||
|
label="Asset class number (optional)"
|
||||||
|
:hint="classHint || 'Shared by every asset in this category'"
|
||||||
|
persistent-hint
|
||||||
|
/>
|
||||||
|
<v-select
|
||||||
|
v-model="form.id_template_id"
|
||||||
|
:items="templateItems"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
label="Asset ID template"
|
||||||
|
hint="New assets in this category get tags from this template"
|
||||||
|
persistent-hint
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!form.name.trim()" @click="save">Save</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Delete confirmation -->
|
||||||
|
<v-dialog v-model="deleteDialog" max-width="500">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-error">Delete category</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="mb-3">Delete the category <strong>“{{ deleteTarget?.name }}”</strong>?</p>
|
||||||
|
<v-alert
|
||||||
|
:type="deleteTarget && deleteTarget.asset_count ? 'warning' : 'info'"
|
||||||
|
variant="tonal"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
|
<template v-if="deleteTarget && deleteTarget.asset_count">
|
||||||
|
<strong>{{ deleteTarget.asset_count }}</strong> asset{{ deleteTarget.asset_count === 1 ? '' : 's' }} currently
|
||||||
|
use this category.
|
||||||
|
</template>
|
||||||
|
<template v-else>No assets currently use this category.</template>
|
||||||
|
</v-alert>
|
||||||
|
<ul class="delete-impact">
|
||||||
|
<li>Those assets <strong>keep their current category value</strong> — their data is not changed.</li>
|
||||||
|
<li>The category will <strong>no longer appear</strong> in the dropdown for new or edited assets.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="quiet text-body-2 mt-3">Tip: to merge categories instead, <strong>rename</strong> this one to match another.</p>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete category</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DataTable from './DataTable.vue';
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { DataTable },
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
emits: ['updated'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
categories: [],
|
||||||
|
idTemplates: [],
|
||||||
|
assetClasses: [],
|
||||||
|
classLookup: null,
|
||||||
|
dialog: false,
|
||||||
|
deleteDialog: false,
|
||||||
|
deleteTarget: null,
|
||||||
|
form: { id: null, name: '', code: '', asset_class_number: '', id_template_id: null },
|
||||||
|
saving: false,
|
||||||
|
error: '',
|
||||||
|
dialogError: '',
|
||||||
|
columns: [
|
||||||
|
{ key: 'name', label: 'Category' },
|
||||||
|
{ key: 'code', label: 'Code' },
|
||||||
|
{ key: 'asset_class_number', label: 'Class #' },
|
||||||
|
{ key: 'id_template_name', label: 'ID template', sortable: false },
|
||||||
|
{ key: 'asset_count', label: 'Assets', format: 'number' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
templateItems() {
|
||||||
|
return this.idTemplates.map((t) => ({ title: `${t.name} (${t.pattern})`, value: t.id }));
|
||||||
|
},
|
||||||
|
pickableClasses() {
|
||||||
|
return this.assetClasses.filter((c) => c.class_number);
|
||||||
|
},
|
||||||
|
assetClassItems() {
|
||||||
|
const tableLabels = { common: 'Common', industry: 'Manufacturing', business: 'Business', custom: 'Custom' };
|
||||||
|
return this.pickableClasses.map((c, index) => ({
|
||||||
|
title: `${c.class_number} — ${c.description} (GDS ${c.gds ?? '—'} / ADS ${c.ads ?? '—'}) · ${tableLabels[c.table] || 'Common'}`,
|
||||||
|
value: index
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
classHint() {
|
||||||
|
const match = this.assetClasses.find((c) => c.class_number && c.class_number === String(this.form.asset_class_number || '').trim());
|
||||||
|
if (!match) return '';
|
||||||
|
return `IRS ${match.class_number} · ${match.description} · GDS ${match.gds ?? '—'} / ADS ${match.ads ?? '—'} yrs`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
this.loadTemplates();
|
||||||
|
this.loadAssetClasses();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/asset-categories')).json();
|
||||||
|
this.categories = data.categories;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadTemplates() {
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/asset-id-templates')).json();
|
||||||
|
this.idTemplates = data.templates;
|
||||||
|
} catch {
|
||||||
|
this.idTemplates = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadAssetClasses() {
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/asset-classes')).json();
|
||||||
|
this.assetClasses = data.assetClasses || [];
|
||||||
|
} catch {
|
||||||
|
this.assetClasses = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
applyAssetClass(index) {
|
||||||
|
const entry = index != null ? this.pickableClasses[index] : null;
|
||||||
|
if (entry) this.form.asset_class_number = entry.class_number;
|
||||||
|
this.$nextTick(() => { this.classLookup = null; });
|
||||||
|
},
|
||||||
|
openNew() {
|
||||||
|
this.loadTemplates(); this.loadAssetClasses(); this.classLookup = null;
|
||||||
|
this.form = { id: null, name: '', code: '', asset_class_number: '', id_template_id: null };
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
openEdit(row) {
|
||||||
|
this.loadTemplates(); this.loadAssetClasses(); this.classLookup = null;
|
||||||
|
this.form = { id: row.id, name: row.name, code: row.code || '', asset_class_number: row.asset_class_number || '', id_template_id: row.id_template_id || null };
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
const name = this.form.name.trim();
|
||||||
|
if (!name) return;
|
||||||
|
this.saving = true;
|
||||||
|
this.dialogError = '';
|
||||||
|
try {
|
||||||
|
const method = this.form.id ? 'PUT' : 'POST';
|
||||||
|
const url = this.form.id ? `/api/asset-categories/${this.form.id}` : '/api/asset-categories';
|
||||||
|
await this.api(url, { method, body: JSON.stringify({ name, code: this.form.code || null, asset_class_number: this.form.asset_class_number || null, id_template_id: this.form.id_template_id || null }) });
|
||||||
|
this.dialog = false;
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.dialogError = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmDelete(row) {
|
||||||
|
this.deleteTarget = row;
|
||||||
|
this.deleteDialog = true;
|
||||||
|
},
|
||||||
|
async performDelete() {
|
||||||
|
const target = this.deleteTarget;
|
||||||
|
this.deleteDialog = false;
|
||||||
|
this.deleteTarget = null;
|
||||||
|
if (!target) return;
|
||||||
|
try {
|
||||||
|
await this.api(`/api/asset-categories/${target.id}`, { method: 'DELETE' });
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.delete-impact {
|
||||||
|
padding-left: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.delete-impact li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
205
src/components/DataTable.vue
Normal file
205
src/components/DataTable.vue
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<template>
|
||||||
|
<div class="data-table">
|
||||||
|
<div v-if="searchable || $slots.toolbar" class="toolbar-row mb-2">
|
||||||
|
<v-text-field
|
||||||
|
v-if="searchable"
|
||||||
|
v-model="query"
|
||||||
|
:placeholder="searchPlaceholder"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
prepend-inner-icon="mdi-filter-variant"
|
||||||
|
style="max-width: 280px"
|
||||||
|
/>
|
||||||
|
<slot name="toolbar" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-scroll" :style="stickyHeader ? { maxHeight } : {}">
|
||||||
|
<table class="asset-table" :class="{ 'sticky-head': stickyHeader }">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-if="hasLead" class="lead-col"><slot name="lead-header" /></th>
|
||||||
|
<th
|
||||||
|
v-for="col in columns"
|
||||||
|
:key="col.key"
|
||||||
|
:class="[alignClass(col), { sortable: col.sortable !== false }]"
|
||||||
|
@click="toggleSort(col)"
|
||||||
|
>
|
||||||
|
<span class="th-label">
|
||||||
|
{{ col.label }}
|
||||||
|
<v-icon v-if="sortKey === col.key" :icon="sortDir === 1 ? 'mdi-menu-up' : 'mdi-menu-down'" size="16" />
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
<th v-if="hasActions" class="actions-col" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, index) in pagedRows" :key="keyFor(row, index)">
|
||||||
|
<td v-if="hasLead" class="lead-col"><slot name="lead" :row="row" /></td>
|
||||||
|
<td v-for="col in columns" :key="col.key" :class="alignClass(col)">
|
||||||
|
<slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]">
|
||||||
|
{{ formatCell(row[col.key], col.format) }}
|
||||||
|
</slot>
|
||||||
|
</td>
|
||||||
|
<td v-if="hasActions" class="text-right actions-col"><slot name="actions" :row="row" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!pagedRows.length">
|
||||||
|
<td :colspan="colspan" class="quiet">{{ emptyText }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot v-if="totals">
|
||||||
|
<tr>
|
||||||
|
<td v-if="hasLead" />
|
||||||
|
<td v-for="(col, i) in columns" :key="col.key" :class="['font-weight-bold', alignClass(col)]">
|
||||||
|
{{ i === 0 ? 'Totals' : (col.key in totals ? formatCell(totals[col.key], col.format || 'currency') : '') }}
|
||||||
|
</td>
|
||||||
|
<td v-if="hasActions" />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="data-table-footer">
|
||||||
|
<span class="quiet text-caption">{{ rangeLabel }}</span>
|
||||||
|
<v-spacer />
|
||||||
|
<span class="quiet text-caption mr-1">Rows per page</span>
|
||||||
|
<v-select
|
||||||
|
v-model="perPage"
|
||||||
|
:items="pageSizeItems"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
density="compact"
|
||||||
|
variant="plain"
|
||||||
|
hide-details
|
||||||
|
style="max-width: 78px"
|
||||||
|
/>
|
||||||
|
<v-pagination
|
||||||
|
v-if="pageCount > 1"
|
||||||
|
v-model="page"
|
||||||
|
:length="pageCount"
|
||||||
|
:total-visible="5"
|
||||||
|
density="comfortable"
|
||||||
|
class="ml-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { currency, shortDate } from '../utils/format';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
columns: { type: Array, required: true },
|
||||||
|
rows: { type: Array, required: true },
|
||||||
|
rowKey: { type: [String, Function], default: 'id' },
|
||||||
|
totals: { type: Object, default: null },
|
||||||
|
searchable: { type: Boolean, default: false },
|
||||||
|
searchPlaceholder: { type: String, default: 'Filter rows' },
|
||||||
|
pageSize: { type: Number, default: 25 },
|
||||||
|
pageSizeOptions: { type: Array, default: () => [10, 25, 50, 100] },
|
||||||
|
stickyHeader: { type: Boolean, default: true },
|
||||||
|
maxHeight: { type: String, default: '62vh' },
|
||||||
|
hasActions: { type: Boolean, default: false },
|
||||||
|
hasLead: { type: Boolean, default: false },
|
||||||
|
emptyText: { type: String, default: 'No data.' }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
sortKey: null,
|
||||||
|
sortDir: 1,
|
||||||
|
page: 1,
|
||||||
|
perPage: this.pageSize,
|
||||||
|
query: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
pageSizeItems() {
|
||||||
|
return [...this.pageSizeOptions.map((n) => ({ title: String(n), value: n })), { title: 'All', value: -1 }];
|
||||||
|
},
|
||||||
|
colspan() {
|
||||||
|
return this.columns.length + (this.hasLead ? 1 : 0) + (this.hasActions ? 1 : 0);
|
||||||
|
},
|
||||||
|
filteredRows() {
|
||||||
|
if (!this.searchable || !this.query) return this.rows;
|
||||||
|
const needle = String(this.query).toLowerCase();
|
||||||
|
return this.rows.filter((row) =>
|
||||||
|
this.columns.some((col) => String(row[col.key] ?? '').toLowerCase().includes(needle))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
sortedRows() {
|
||||||
|
if (!this.sortKey) return this.filteredRows;
|
||||||
|
const col = this.columns.find((c) => c.key === this.sortKey);
|
||||||
|
const numeric = col && (col.format === 'currency' || col.format === 'number');
|
||||||
|
const dated = col && col.format === 'date';
|
||||||
|
const dir = this.sortDir;
|
||||||
|
return [...this.filteredRows].sort((a, b) => {
|
||||||
|
let av = a[this.sortKey];
|
||||||
|
let bv = b[this.sortKey];
|
||||||
|
if (numeric) return (Number(av || 0) - Number(bv || 0)) * dir;
|
||||||
|
if (dated) return ((new Date(av || 0)) - (new Date(bv || 0))) * dir;
|
||||||
|
av = String(av ?? '');
|
||||||
|
bv = String(bv ?? '');
|
||||||
|
return av.localeCompare(bv, undefined, { numeric: true }) * dir;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
pageCount() {
|
||||||
|
if (this.perPage === -1) return 1;
|
||||||
|
return Math.max(1, Math.ceil(this.sortedRows.length / this.perPage));
|
||||||
|
},
|
||||||
|
pagedRows() {
|
||||||
|
if (this.perPage === -1) return this.sortedRows;
|
||||||
|
const start = (this.page - 1) * this.perPage;
|
||||||
|
return this.sortedRows.slice(start, start + this.perPage);
|
||||||
|
},
|
||||||
|
rangeLabel() {
|
||||||
|
const total = this.sortedRows.length;
|
||||||
|
if (!total) return '0 of 0';
|
||||||
|
if (this.perPage === -1) return `1–${total} of ${total}`;
|
||||||
|
const start = (this.page - 1) * this.perPage + 1;
|
||||||
|
const end = Math.min(start + this.perPage - 1, total);
|
||||||
|
return `${start}–${end} of ${total}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
rows() {
|
||||||
|
if (this.page > this.pageCount) this.page = this.pageCount;
|
||||||
|
},
|
||||||
|
perPage() {
|
||||||
|
this.page = 1;
|
||||||
|
},
|
||||||
|
query() {
|
||||||
|
this.page = 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
toggleSort(col) {
|
||||||
|
if (col.sortable === false) return;
|
||||||
|
if (this.sortKey === col.key) {
|
||||||
|
if (this.sortDir === 1) this.sortDir = -1;
|
||||||
|
else this.sortKey = null;
|
||||||
|
} else {
|
||||||
|
this.sortKey = col.key;
|
||||||
|
this.sortDir = 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
alignClass(col) {
|
||||||
|
return col.align === 'end' || col.format === 'currency' || col.format === 'number' ? 'text-right' : '';
|
||||||
|
},
|
||||||
|
keyFor(row, index) {
|
||||||
|
if (typeof this.rowKey === 'function') {
|
||||||
|
const key = this.rowKey(row);
|
||||||
|
return key == null ? index : key;
|
||||||
|
}
|
||||||
|
return row[this.rowKey] ?? index;
|
||||||
|
},
|
||||||
|
formatCell(value, format) {
|
||||||
|
if (value === null || value === undefined || value === '') return '';
|
||||||
|
if (format === 'currency') return currency(value);
|
||||||
|
if (format === 'number') return Number(value).toLocaleString();
|
||||||
|
if (format === 'date') return shortDate(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
186
src/components/DepartmentSettings.vue
Normal file
186
src/components/DepartmentSettings.vue
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Asset departments</h2>
|
||||||
|
<p class="quiet text-body-2">
|
||||||
|
Manage the departments offered in the asset form’s Department dropdown. Renaming a department updates every asset that uses it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New department</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="departments"
|
||||||
|
row-key="id"
|
||||||
|
:page-size="10"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter departments"
|
||||||
|
empty-text="No departments yet."
|
||||||
|
>
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.name }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-code="{ row }">{{ row.code || '—' }}</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
|
||||||
|
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
|
||||||
|
<!-- Create / edit -->
|
||||||
|
<v-dialog v-model="dialog" max-width="460">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ form.id ? 'Edit department' : 'New department' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field v-model="form.name" label="Department name" autofocus @keyup.enter="save" />
|
||||||
|
<v-text-field v-model="form.code" label="Short code (optional)" />
|
||||||
|
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!form.name.trim()" @click="save">Save</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Delete confirmation -->
|
||||||
|
<v-dialog v-model="deleteDialog" max-width="500">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-error">Delete department</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="mb-3">Delete the department <strong>“{{ deleteTarget?.name }}”</strong>?</p>
|
||||||
|
<v-alert
|
||||||
|
:type="deleteTarget && deleteTarget.asset_count ? 'warning' : 'info'"
|
||||||
|
variant="tonal"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
|
<template v-if="deleteTarget && deleteTarget.asset_count">
|
||||||
|
<strong>{{ deleteTarget.asset_count }}</strong> asset{{ deleteTarget.asset_count === 1 ? '' : 's' }} currently
|
||||||
|
use this department.
|
||||||
|
</template>
|
||||||
|
<template v-else>No assets currently use this department.</template>
|
||||||
|
</v-alert>
|
||||||
|
<ul class="delete-impact">
|
||||||
|
<li>Those assets <strong>keep their current department value</strong> — their data is not changed.</li>
|
||||||
|
<li>The department will <strong>no longer appear</strong> in the dropdown for new or edited assets.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="quiet text-body-2 mt-3">Tip: to merge departments instead, <strong>rename</strong> this one to match another.</p>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete department</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DataTable from './DataTable.vue';
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { DataTable },
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
emits: ['updated'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
departments: [],
|
||||||
|
dialog: false,
|
||||||
|
deleteDialog: false,
|
||||||
|
deleteTarget: null,
|
||||||
|
form: { id: null, name: '', code: '' },
|
||||||
|
saving: false,
|
||||||
|
error: '',
|
||||||
|
dialogError: '',
|
||||||
|
columns: [
|
||||||
|
{ key: 'name', label: 'Department' },
|
||||||
|
{ key: 'code', label: 'Code' },
|
||||||
|
{ key: 'asset_count', label: 'Assets', format: 'number' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/asset-departments')).json();
|
||||||
|
this.departments = data.departments;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openNew() {
|
||||||
|
this.form = { id: null, name: '', code: '' };
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
openEdit(row) {
|
||||||
|
this.form = { id: row.id, name: row.name, code: row.code || '' };
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
const name = this.form.name.trim();
|
||||||
|
if (!name) return;
|
||||||
|
this.saving = true;
|
||||||
|
this.dialogError = '';
|
||||||
|
try {
|
||||||
|
const method = this.form.id ? 'PUT' : 'POST';
|
||||||
|
const url = this.form.id ? `/api/asset-departments/${this.form.id}` : '/api/asset-departments';
|
||||||
|
await this.api(url, { method, body: JSON.stringify({ name, code: this.form.code || null }) });
|
||||||
|
this.dialog = false;
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.dialogError = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmDelete(row) {
|
||||||
|
this.deleteTarget = row;
|
||||||
|
this.deleteDialog = true;
|
||||||
|
},
|
||||||
|
async performDelete() {
|
||||||
|
const target = this.deleteTarget;
|
||||||
|
this.deleteDialog = false;
|
||||||
|
this.deleteTarget = null;
|
||||||
|
if (!target) return;
|
||||||
|
try {
|
||||||
|
await this.api(`/api/asset-departments/${target.id}`, { method: 'DELETE' });
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.delete-impact {
|
||||||
|
padding-left: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.delete-impact li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
184
src/components/DepreciationZoneSettings.vue
Normal file
184
src/components/DepreciationZoneSettings.vue
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Depreciation zones</h2>
|
||||||
|
<p class="quiet text-body-2">
|
||||||
|
Special-allowance zones (e.g. New York Liberty Zone). Assign one to an asset and the engine applies the zone’s §168
|
||||||
|
allowance to the federal book for assets placed in service within the date window.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New zone</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="zones"
|
||||||
|
row-key="code"
|
||||||
|
:page-size="10"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter zones"
|
||||||
|
empty-text="No depreciation zones."
|
||||||
|
>
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.name }}</span>
|
||||||
|
<v-chip v-if="!row.enabled" size="x-small" variant="tonal" class="ml-2">Disabled</v-chip>
|
||||||
|
</template>
|
||||||
|
<template #cell-allowance_percent="{ row }">{{ row.allowance_percent }}%</template>
|
||||||
|
<template #cell-window="{ row }">{{ row.pis_start || '—' }} → {{ row.pis_end || '—' }}</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
|
||||||
|
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
|
||||||
|
<v-dialog v-model="dialog" max-width="640" scrollable>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ form.exists ? `Edit zone · ${form.name}` : 'New depreciation zone' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field v-model="form.name" label="Zone name *" />
|
||||||
|
<v-text-field v-if="!form.exists" v-model="form.code" label="Code (optional)" placeholder="auto from name" />
|
||||||
|
<v-text-field v-model.number="form.allowance_percent" type="number" label="§168 special allowance %" suffix="%" />
|
||||||
|
<v-switch v-model="form.enabled" label="Enabled" color="primary" density="compact" hide-details />
|
||||||
|
<v-text-field v-model="form.pis_start" type="date" label="Placed-in-service start" />
|
||||||
|
<v-text-field v-model="form.pis_end" type="date" label="Placed-in-service end" />
|
||||||
|
<v-text-field v-model="form.real_property_end" type="date" label="Real-property PIS end (optional)" />
|
||||||
|
<v-text-field v-model.number="form.max_recovery_years" type="number" label="Max recovery years (optional)" />
|
||||||
|
<v-text-field v-model.number="form.leasehold_life_years" type="number" label="Leasehold improvement life (yrs, optional)" />
|
||||||
|
</div>
|
||||||
|
<v-textarea v-model="form.notes" class="mt-2" label="Notes" rows="3" />
|
||||||
|
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!form.name.trim()" @click="save">Save zone</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-dialog v-model="deleteDialog" max-width="460">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-error">Delete zone</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p>Delete <strong>“{{ deleteTarget?.name }}”</strong>?</p>
|
||||||
|
<v-alert v-if="deleteTarget && deleteTarget.asset_count" type="warning" variant="tonal" density="comfortable" class="mt-2">
|
||||||
|
{{ deleteTarget.asset_count }} asset(s) reference this zone — they keep their value, but the special allowance stops applying.
|
||||||
|
</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DataTable from './DataTable.vue';
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
|
||||||
|
function blankForm() {
|
||||||
|
return {
|
||||||
|
exists: false, code: '', name: '', allowance_percent: 0, enabled: true,
|
||||||
|
pis_start: '', pis_end: '', real_property_end: '', max_recovery_years: null, leasehold_life_years: null, notes: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { DataTable },
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
emits: ['updated'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
zones: [],
|
||||||
|
dialog: false,
|
||||||
|
deleteDialog: false,
|
||||||
|
deleteTarget: null,
|
||||||
|
form: blankForm(),
|
||||||
|
saving: false,
|
||||||
|
error: '',
|
||||||
|
dialogError: '',
|
||||||
|
columns: [
|
||||||
|
{ key: 'name', label: 'Zone' },
|
||||||
|
{ key: 'allowance_percent', label: 'Allowance' },
|
||||||
|
{ key: 'window', label: 'Placed-in-service window', sortable: false },
|
||||||
|
{ key: 'asset_count', label: 'Assets', format: 'number' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/depreciation-zones')).json();
|
||||||
|
this.zones = data.zones;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openNew() {
|
||||||
|
this.form = blankForm();
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
openEdit(row) {
|
||||||
|
this.form = {
|
||||||
|
exists: true, code: row.code, name: row.name, allowance_percent: row.allowance_percent, enabled: row.enabled,
|
||||||
|
pis_start: row.pis_start || '', pis_end: row.pis_end || '', real_property_end: row.real_property_end || '',
|
||||||
|
max_recovery_years: row.max_recovery_years, leasehold_life_years: row.leasehold_life_years, notes: row.notes || ''
|
||||||
|
};
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
if (!this.form.name.trim()) return;
|
||||||
|
this.saving = true;
|
||||||
|
this.dialogError = '';
|
||||||
|
try {
|
||||||
|
const method = this.form.exists ? 'PUT' : 'POST';
|
||||||
|
const url = this.form.exists ? `/api/depreciation-zones/${this.form.code}` : '/api/depreciation-zones';
|
||||||
|
await this.api(url, { method, body: JSON.stringify(this.form) });
|
||||||
|
this.dialog = false;
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.dialogError = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmDelete(row) {
|
||||||
|
this.deleteTarget = row;
|
||||||
|
this.deleteDialog = true;
|
||||||
|
},
|
||||||
|
async performDelete() {
|
||||||
|
const target = this.deleteTarget;
|
||||||
|
this.deleteDialog = false;
|
||||||
|
this.deleteTarget = null;
|
||||||
|
if (!target) return;
|
||||||
|
try {
|
||||||
|
await this.api(`/api/depreciation-zones/${target.code}`, { method: 'DELETE' });
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
80
src/components/LabelSheet.vue
Normal file
80
src/components/LabelSheet.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog :model-value="modelValue" max-width="940" scrollable @update:model-value="$emit('update:modelValue', $event)">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="d-flex align-center">
|
||||||
|
<span>Print asset labels</span>
|
||||||
|
<v-spacer />
|
||||||
|
<v-select
|
||||||
|
v-model="format"
|
||||||
|
:items="formatItems"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
label="Symbology"
|
||||||
|
style="max-width: 170px"
|
||||||
|
class="mr-3"
|
||||||
|
/>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-printer" :disabled="!assets.length" @click="print">Print</v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p v-if="!assets.length" class="quiet text-body-2">No assets to label.</p>
|
||||||
|
<div ref="sheet" class="label-grid">
|
||||||
|
<div v-for="asset in assets" :key="asset.id || asset.asset_id" class="label-cell">
|
||||||
|
<BarcodeLabel :value="asset.barcode_value || asset.asset_id" :caption="asset.description" :format="format" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<span class="quiet text-caption px-2">{{ assets.length }} label(s)</span>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="$emit('update:modelValue', false)">Close</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import BarcodeLabel from './BarcodeLabel.vue';
|
||||||
|
|
||||||
|
const PRINT_CSS = `
|
||||||
|
@page { margin: 12mm; }
|
||||||
|
body { font-family: Arial, Helvetica, sans-serif; margin: 0; }
|
||||||
|
.label-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10mm 8mm; }
|
||||||
|
.label-cell { border: 1px solid #ddd; border-radius: 6px; padding: 8px; text-align: center; page-break-inside: avoid; }
|
||||||
|
.barcode-svg { width: 100%; height: 46px; }
|
||||||
|
.barcode-id { font-weight: 700; font-size: 12px; margin-top: 4px; letter-spacing: 0.04em; }
|
||||||
|
.barcode-caption { font-size: 10px; color: #555; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { BarcodeLabel },
|
||||||
|
props: {
|
||||||
|
assets: { type: Array, default: () => [] },
|
||||||
|
modelValue: { type: Boolean, default: false }
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
format: 'CODE128',
|
||||||
|
formatItems: ['CODE128', 'CODE39']
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
print() {
|
||||||
|
const sheet = this.$refs.sheet;
|
||||||
|
if (!sheet) return;
|
||||||
|
const win = window.open('', '_blank', 'width=960,height=720');
|
||||||
|
if (!win) return;
|
||||||
|
win.document.write(
|
||||||
|
`<html><head><title>Asset labels</title><style>${PRINT_CSS}</style></head>` +
|
||||||
|
`<body><div class="label-grid">${sheet.innerHTML}</div></body></html>`
|
||||||
|
);
|
||||||
|
win.document.close();
|
||||||
|
win.focus();
|
||||||
|
setTimeout(() => {
|
||||||
|
win.print();
|
||||||
|
win.close();
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
76
src/components/MonacoJsonEditor.vue
Normal file
76
src/components/MonacoJsonEditor.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="container" class="monaco-host" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import * as monaco from 'monaco-editor';
|
||||||
|
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||||
|
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
||||||
|
|
||||||
|
// Configure Monaco web workers once (Vite bundles each ?worker as its own chunk).
|
||||||
|
if (!window.__mixedassetsMonacoEnv) {
|
||||||
|
self.MonacoEnvironment = {
|
||||||
|
getWorker(_workerId, label) {
|
||||||
|
return label === 'json' ? new jsonWorker() : new editorWorker();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.__mixedassetsMonacoEnv = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
modelValue: { type: String, default: '' },
|
||||||
|
readOnly: { type: Boolean, default: false },
|
||||||
|
theme: { type: String, default: 'vs-dark' }
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
mounted() {
|
||||||
|
this.editor = monaco.editor.create(this.$refs.container, {
|
||||||
|
value: this.modelValue,
|
||||||
|
language: 'json',
|
||||||
|
theme: this.theme,
|
||||||
|
automaticLayout: true,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
fontSize: 13,
|
||||||
|
tabSize: 2,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
readOnly: this.readOnly,
|
||||||
|
formatOnPaste: true,
|
||||||
|
fixedOverflowWidgets: true
|
||||||
|
});
|
||||||
|
this.editor.onDidChangeModelContent(() => {
|
||||||
|
const value = this.editor.getValue();
|
||||||
|
if (value !== this.modelValue) this.$emit('update:modelValue', value);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
this.editor?.dispose();
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
modelValue(value) {
|
||||||
|
if (this.editor && value !== this.editor.getValue()) this.editor.setValue(value || '');
|
||||||
|
},
|
||||||
|
readOnly(value) {
|
||||||
|
this.editor?.updateOptions({ readOnly: value });
|
||||||
|
},
|
||||||
|
theme(value) {
|
||||||
|
monaco.editor.setTheme(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
format() {
|
||||||
|
this.editor?.getAction('editor.action.formatDocument')?.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.monaco-host {
|
||||||
|
width: 100%;
|
||||||
|
height: 540px;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
115
src/components/NotificationSettings.vue
Normal file
115
src/components/NotificationSettings.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<h2 class="section-title mb-1">Email & alerts (SMTP)</h2>
|
||||||
|
<p class="quiet text-body-2 mb-3">Mail server used for reminder and alert emails. Leave the password blank to keep the stored value.</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-switch v-model="form.alerts_enabled" label="Enable email alerts" color="primary" density="compact" hide-details />
|
||||||
|
<v-text-field v-model.number="form.alerts_lead_days" type="number" label="Lead days (warn ahead of due date)" />
|
||||||
|
<v-text-field v-model="form.alerts_recipients" class="full" label="Alert recipients (comma-separated)" />
|
||||||
|
<v-text-field v-model="form.smtp_host" label="SMTP host" />
|
||||||
|
<v-text-field v-model.number="form.smtp_port" type="number" label="SMTP port" />
|
||||||
|
<v-switch v-model="form.smtp_secure" label="Use TLS (secure)" color="primary" density="compact" hide-details />
|
||||||
|
<v-text-field v-model="form.smtp_user" label="SMTP username" />
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.smtp_password"
|
||||||
|
type="password"
|
||||||
|
:label="settings.smtp_password_set ? 'SMTP password (unchanged)' : 'SMTP password'"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<v-text-field v-model="form.smtp_from" class="full" label="From address" />
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-row mt-3">
|
||||||
|
<v-text-field v-model="testTo" label="Send test to (optional)" density="compact" hide-details style="max-width: 280px" />
|
||||||
|
<v-btn variant="tonal" prepend-icon="mdi-email-fast-outline" :loading="testing" @click="sendTest">Send test</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save settings</v-btn>
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="status" class="mt-3" density="compact" :type="statusType">{{ status }}</v-alert>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
settings: {},
|
||||||
|
form: {
|
||||||
|
alerts_enabled: false,
|
||||||
|
alerts_lead_days: 30,
|
||||||
|
alerts_recipients: '',
|
||||||
|
smtp_host: '',
|
||||||
|
smtp_port: 587,
|
||||||
|
smtp_secure: false,
|
||||||
|
smtp_user: '',
|
||||||
|
smtp_password: '',
|
||||||
|
smtp_from: ''
|
||||||
|
},
|
||||||
|
testTo: '',
|
||||||
|
saving: false,
|
||||||
|
testing: false,
|
||||||
|
status: '',
|
||||||
|
statusType: 'success'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
const data = await (await this.api('/api/notifications/settings')).json();
|
||||||
|
this.settings = data.settings;
|
||||||
|
this.form = {
|
||||||
|
alerts_enabled: data.settings.alerts_enabled,
|
||||||
|
alerts_lead_days: data.settings.alerts_lead_days,
|
||||||
|
alerts_recipients: data.settings.alerts_recipients,
|
||||||
|
smtp_host: data.settings.smtp_host,
|
||||||
|
smtp_port: data.settings.smtp_port,
|
||||||
|
smtp_secure: data.settings.smtp_secure,
|
||||||
|
smtp_user: data.settings.smtp_user,
|
||||||
|
smtp_password: '',
|
||||||
|
smtp_from: data.settings.smtp_from
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
this.saving = true;
|
||||||
|
this.status = '';
|
||||||
|
try {
|
||||||
|
const payload = { ...this.form };
|
||||||
|
if (!payload.smtp_password) delete payload.smtp_password;
|
||||||
|
const data = await (await this.api('/api/notifications/settings', { method: 'PUT', body: JSON.stringify(payload) })).json();
|
||||||
|
this.settings = data.settings;
|
||||||
|
this.form.smtp_password = '';
|
||||||
|
this.statusType = 'success';
|
||||||
|
this.status = 'Settings saved.';
|
||||||
|
} catch (error) {
|
||||||
|
this.statusType = 'error';
|
||||||
|
this.status = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async sendTest() {
|
||||||
|
this.testing = true;
|
||||||
|
this.status = '';
|
||||||
|
try {
|
||||||
|
await this.api('/api/notifications/test', { method: 'POST', body: JSON.stringify({ to: this.testTo || undefined }) });
|
||||||
|
this.statusType = 'success';
|
||||||
|
this.status = `Test email sent${this.testTo ? ` to ${this.testTo}` : ''}.`;
|
||||||
|
} catch (error) {
|
||||||
|
this.statusType = 'error';
|
||||||
|
this.status = `Test failed: ${error.message}`;
|
||||||
|
} finally {
|
||||||
|
this.testing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
187
src/components/PartCategorySettings.vue
Normal file
187
src/components/PartCategorySettings.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Parts categories</h2>
|
||||||
|
<p class="quiet text-body-2">
|
||||||
|
Manage the categories offered in the parts inventory form. Separate from asset and PM categories.
|
||||||
|
Renaming a category updates every part that uses it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New category</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="categories"
|
||||||
|
row-key="id"
|
||||||
|
:page-size="10"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter categories"
|
||||||
|
empty-text="No parts categories yet."
|
||||||
|
>
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.name }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-code="{ row }">{{ row.code || '—' }}</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
|
||||||
|
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
|
||||||
|
<!-- Create / edit -->
|
||||||
|
<v-dialog v-model="dialog" max-width="460">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ form.id ? 'Edit parts category' : 'New parts category' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field v-model="form.name" label="Category name" autofocus @keyup.enter="save" />
|
||||||
|
<v-text-field v-model="form.code" label="Short code (optional)" />
|
||||||
|
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!form.name.trim()" @click="save">Save</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Delete confirmation -->
|
||||||
|
<v-dialog v-model="deleteDialog" max-width="500">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-error">Delete parts category</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="mb-3">Delete the parts category <strong>“{{ deleteTarget?.name }}”</strong>?</p>
|
||||||
|
<v-alert
|
||||||
|
:type="deleteTarget && deleteTarget.part_count ? 'warning' : 'info'"
|
||||||
|
variant="tonal"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
|
<template v-if="deleteTarget && deleteTarget.part_count">
|
||||||
|
<strong>{{ deleteTarget.part_count }}</strong> part{{ deleteTarget.part_count === 1 ? '' : 's' }} currently
|
||||||
|
use this category.
|
||||||
|
</template>
|
||||||
|
<template v-else>No parts currently use this category.</template>
|
||||||
|
</v-alert>
|
||||||
|
<ul class="delete-impact">
|
||||||
|
<li>Those parts <strong>keep their current category value</strong> — their data is not changed.</li>
|
||||||
|
<li>The category will <strong>no longer appear</strong> in the dropdown for new or edited parts.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="quiet text-body-2 mt-3">Tip: to merge categories instead, <strong>rename</strong> this one to match another.</p>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete category</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DataTable from './DataTable.vue';
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { DataTable },
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
emits: ['updated'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
categories: [],
|
||||||
|
dialog: false,
|
||||||
|
deleteDialog: false,
|
||||||
|
deleteTarget: null,
|
||||||
|
form: { id: null, name: '', code: '' },
|
||||||
|
saving: false,
|
||||||
|
error: '',
|
||||||
|
dialogError: '',
|
||||||
|
columns: [
|
||||||
|
{ key: 'name', label: 'Category' },
|
||||||
|
{ key: 'code', label: 'Code' },
|
||||||
|
{ key: 'part_count', label: 'Parts', format: 'number' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/part-categories')).json();
|
||||||
|
this.categories = data.categories;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openNew() {
|
||||||
|
this.form = { id: null, name: '', code: '' };
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
openEdit(row) {
|
||||||
|
this.form = { id: row.id, name: row.name, code: row.code || '' };
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
const name = this.form.name.trim();
|
||||||
|
if (!name) return;
|
||||||
|
this.saving = true;
|
||||||
|
this.dialogError = '';
|
||||||
|
try {
|
||||||
|
const method = this.form.id ? 'PUT' : 'POST';
|
||||||
|
const url = this.form.id ? `/api/part-categories/${this.form.id}` : '/api/part-categories';
|
||||||
|
await this.api(url, { method, body: JSON.stringify({ name, code: this.form.code || null }) });
|
||||||
|
this.dialog = false;
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.dialogError = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmDelete(row) {
|
||||||
|
this.deleteTarget = row;
|
||||||
|
this.deleteDialog = true;
|
||||||
|
},
|
||||||
|
async performDelete() {
|
||||||
|
const target = this.deleteTarget;
|
||||||
|
this.deleteDialog = false;
|
||||||
|
this.deleteTarget = null;
|
||||||
|
if (!target) return;
|
||||||
|
try {
|
||||||
|
await this.api(`/api/part-categories/${target.id}`, { method: 'DELETE' });
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.delete-impact {
|
||||||
|
padding-left: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.delete-impact li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
187
src/components/PmCategorySettings.vue
Normal file
187
src/components/PmCategorySettings.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">PM categories</h2>
|
||||||
|
<p class="quiet text-body-2">
|
||||||
|
Manage the categories offered in the preventative-maintenance plan form. Separate from asset categories.
|
||||||
|
Renaming a category updates every PM plan that uses it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New category</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="categories"
|
||||||
|
row-key="id"
|
||||||
|
:page-size="10"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter categories"
|
||||||
|
empty-text="No PM categories yet."
|
||||||
|
>
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.name }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-code="{ row }">{{ row.code || '—' }}</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
|
||||||
|
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
|
||||||
|
<!-- Create / edit -->
|
||||||
|
<v-dialog v-model="dialog" max-width="460">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ form.id ? 'Edit PM category' : 'New PM category' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field v-model="form.name" label="Category name" autofocus @keyup.enter="save" />
|
||||||
|
<v-text-field v-model="form.code" label="Short code (optional)" />
|
||||||
|
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!form.name.trim()" @click="save">Save</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- Delete confirmation -->
|
||||||
|
<v-dialog v-model="deleteDialog" max-width="500">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-error">Delete PM category</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="mb-3">Delete the PM category <strong>“{{ deleteTarget?.name }}”</strong>?</p>
|
||||||
|
<v-alert
|
||||||
|
:type="deleteTarget && deleteTarget.plan_count ? 'warning' : 'info'"
|
||||||
|
variant="tonal"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
|
<template v-if="deleteTarget && deleteTarget.plan_count">
|
||||||
|
<strong>{{ deleteTarget.plan_count }}</strong> PM plan{{ deleteTarget.plan_count === 1 ? '' : 's' }} currently
|
||||||
|
use this category.
|
||||||
|
</template>
|
||||||
|
<template v-else>No PM plans currently use this category.</template>
|
||||||
|
</v-alert>
|
||||||
|
<ul class="delete-impact">
|
||||||
|
<li>Those plans <strong>keep their current category value</strong> — their data is not changed.</li>
|
||||||
|
<li>The category will <strong>no longer appear</strong> in the dropdown for new or edited PM plans.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="quiet text-body-2 mt-3">Tip: to merge categories instead, <strong>rename</strong> this one to match another.</p>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete category</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DataTable from './DataTable.vue';
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { DataTable },
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
emits: ['updated'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
categories: [],
|
||||||
|
dialog: false,
|
||||||
|
deleteDialog: false,
|
||||||
|
deleteTarget: null,
|
||||||
|
form: { id: null, name: '', code: '' },
|
||||||
|
saving: false,
|
||||||
|
error: '',
|
||||||
|
dialogError: '',
|
||||||
|
columns: [
|
||||||
|
{ key: 'name', label: 'Category' },
|
||||||
|
{ key: 'code', label: 'Code' },
|
||||||
|
{ key: 'plan_count', label: 'Plans', format: 'number' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/pm-categories')).json();
|
||||||
|
this.categories = data.categories;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openNew() {
|
||||||
|
this.form = { id: null, name: '', code: '' };
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
openEdit(row) {
|
||||||
|
this.form = { id: row.id, name: row.name, code: row.code || '' };
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
const name = this.form.name.trim();
|
||||||
|
if (!name) return;
|
||||||
|
this.saving = true;
|
||||||
|
this.dialogError = '';
|
||||||
|
try {
|
||||||
|
const method = this.form.id ? 'PUT' : 'POST';
|
||||||
|
const url = this.form.id ? `/api/pm-categories/${this.form.id}` : '/api/pm-categories';
|
||||||
|
await this.api(url, { method, body: JSON.stringify({ name, code: this.form.code || null }) });
|
||||||
|
this.dialog = false;
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.dialogError = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmDelete(row) {
|
||||||
|
this.deleteTarget = row;
|
||||||
|
this.deleteDialog = true;
|
||||||
|
},
|
||||||
|
async performDelete() {
|
||||||
|
const target = this.deleteTarget;
|
||||||
|
this.deleteDialog = false;
|
||||||
|
this.deleteTarget = null;
|
||||||
|
if (!target) return;
|
||||||
|
try {
|
||||||
|
await this.api(`/api/pm-categories/${target.id}`, { method: 'DELETE' });
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.delete-impact {
|
||||||
|
padding-left: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.delete-impact li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
122
src/components/PmCompletionDialog.vue
Normal file
122
src/components/PmCompletionDialog.vue
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog :model-value="modelValue" max-width="660" scrollable @update:model-value="$emit('update:modelValue', $event)">
|
||||||
|
<v-card v-if="completion">
|
||||||
|
<v-card-title>PM completion — {{ completion.asset_code }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="quiet text-body-2 mb-3">
|
||||||
|
{{ completion.plan_name || 'Maintenance' }} · completed {{ completion.completed_at }}
|
||||||
|
by {{ completion.completed_by_name || 'Unknown' }} · cost {{ currency(completion.cost) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rating-row mb-3">
|
||||||
|
<div><div class="quiet text-caption">Safety / is it safe?</div><v-rating :model-value="completion.ratings.safety || 0" length="5" readonly density="compact" color="amber" size="small" /></div>
|
||||||
|
<div><div class="quiet text-caption">Physical condition</div><v-rating :model-value="completion.ratings.physical || 0" length="5" readonly density="compact" color="amber" size="small" /></div>
|
||||||
|
<div><div class="quiet text-caption">Operating condition</div><v-rating :model-value="completion.ratings.operating || 0" length="5" readonly density="compact" color="amber" size="small" /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="completion.steps.length">
|
||||||
|
<h3 class="section-title mb-1">Steps</h3>
|
||||||
|
<div v-for="(s, i) in completion.steps" :key="`s${i}`" class="text-body-2">
|
||||||
|
<v-icon :icon="s.done ? 'mdi-check-circle' : 'mdi-circle-outline'" :color="s.done ? 'success' : ''" size="16" /> {{ s.title }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="completion.notes.length">
|
||||||
|
<h3 class="section-title mt-3 mb-1">Completion notes</h3>
|
||||||
|
<ul class="pl-4">
|
||||||
|
<li v-for="(n, i) in completion.notes" :key="`n${i}`" class="text-body-2">{{ n }}</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="completion.components.length">
|
||||||
|
<h3 class="section-title mt-3 mb-1">Parts</h3>
|
||||||
|
<div v-for="(c, i) in completion.components" :key="`c${i}`" class="d-flex text-body-2">
|
||||||
|
<span>{{ c.part_number ? c.part_number + ' · ' : '' }}{{ c.description }}</span>
|
||||||
|
<v-spacer /><span>{{ currency(c.cost) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="completion.photos.length">
|
||||||
|
<h3 class="section-title mt-3 mb-1">Photos</h3>
|
||||||
|
<div class="pm-photo-grid">
|
||||||
|
<a v-for="p in completion.photos" :key="p.id" :href="p.data" target="_blank" rel="noopener">
|
||||||
|
<img :src="p.data" class="pm-photo" :alt="p.name || 'photo'" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-if="completion.signature">
|
||||||
|
<h3 class="section-title mt-3 mb-1">Signature</h3>
|
||||||
|
<img :src="completion.signature" class="pm-signature" alt="signature" />
|
||||||
|
</template>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="$emit('update:modelValue', false)">Close</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
<v-card v-else>
|
||||||
|
<v-card-text class="quiet">Loading…</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
import { currency } from '../utils/format';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true },
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
completionId: { type: [Number, String], default: null }
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
data() {
|
||||||
|
return { completion: null };
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
completionId() {
|
||||||
|
if (this.modelValue) this.load();
|
||||||
|
},
|
||||||
|
modelValue(open) {
|
||||||
|
if (open) this.load();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
currency,
|
||||||
|
async load() {
|
||||||
|
this.completion = null;
|
||||||
|
if (!this.completionId) return;
|
||||||
|
const data = await (await apiRequest(`/api/pm-completions/${this.completionId}`, this.token)).json();
|
||||||
|
this.completion = data.completion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rating-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.pm-photo-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pm-photo {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.2);
|
||||||
|
}
|
||||||
|
.pm-signature {
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
63
src/components/PmSettings.vue
Normal file
63
src/components/PmSettings.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<h2 class="section-title mb-1">Preventative maintenance defaults</h2>
|
||||||
|
<p class="quiet text-body-2 mb-3">Default cadence applied when attaching a PM plan to an asset, and how many days ahead PM-due alerts are raised.</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field v-model.number="form.pm_default_frequency_value" type="number" label="Default frequency (every)" />
|
||||||
|
<v-select v-model="form.pm_default_frequency_unit" :items="pmFrequencyUnits" item-title="title" item-value="value" label="Default frequency unit" />
|
||||||
|
<v-text-field v-model.number="form.pm_lead_days" type="number" label="Alert lead days" />
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-row mt-3">
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save PM defaults</v-btn>
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="status" class="mt-3" density="compact" :type="statusType">{{ status }}</v-alert>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
import { pmFrequencyUnits } from '../constants';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
pmFrequencyUnits,
|
||||||
|
form: { pm_default_frequency_value: 3, pm_default_frequency_unit: 'months', pm_lead_days: 7 },
|
||||||
|
saving: false,
|
||||||
|
status: '',
|
||||||
|
statusType: 'success'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
const data = await (await this.api('/api/pm-settings')).json();
|
||||||
|
this.form = { ...this.form, ...data.settings };
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
this.saving = true;
|
||||||
|
this.status = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/pm-settings', { method: 'PUT', body: JSON.stringify(this.form) })).json();
|
||||||
|
this.form = { ...this.form, ...data.settings };
|
||||||
|
this.statusType = 'success';
|
||||||
|
this.status = 'PM defaults saved.';
|
||||||
|
} catch (error) {
|
||||||
|
this.statusType = 'error';
|
||||||
|
this.status = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
213
src/components/ServiceNowSettings.vue
Normal file
213
src/components/ServiceNowSettings.vue
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<h2 class="section-title mb-1">ServiceNow integration</h2>
|
||||||
|
<p class="quiet text-body-2 mb-3">
|
||||||
|
Submit alerts as ServiceNow incidents (Table API) and pull asset data from the CMDB. Uses basic auth over HTTPS.
|
||||||
|
Leave the password blank to keep the stored value.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 class="section-title text-body-1 mb-2">Connection</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field v-model="form.servicenow_instance_url" class="full" label="Instance URL" placeholder="https://dev12345.service-now.com" />
|
||||||
|
<v-text-field v-model="form.servicenow_username" label="Username (integration user)" autocomplete="off" />
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.servicenow_password"
|
||||||
|
type="password"
|
||||||
|
:label="settings.servicenow_password_set ? 'Password (unchanged)' : 'Password'"
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-btn v-if="settings.servicenow_password_set" variant="text" size="small" color="error" prepend-icon="mdi-key-remove" @click="clearPassword">Clear password</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<h3 class="section-title text-body-1 mb-2">Incident creation (push)</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-switch v-model="form.servicenow_ticket_enabled" label="Allow submitting alerts as incidents" color="primary" density="compact" hide-details />
|
||||||
|
<v-switch v-model="form.servicenow_auto_ticket" label="Auto-create incidents for new critical alerts" color="primary" density="compact" hide-details />
|
||||||
|
<v-text-field v-model="form.servicenow_incident_table" label="Incident table" placeholder="incident" />
|
||||||
|
<v-text-field v-model="form.servicenow_assignment_group" label="Assignment group (sys_id or name, optional)" />
|
||||||
|
<v-text-field v-model="form.servicenow_caller_id" class="full" label="Caller / requested-for (sys_id, optional)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<h3 class="section-title text-body-1 mb-2">CMDB asset sync (pull)</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field v-model="form.servicenow_cmdb_table" label="CMDB CI table" placeholder="cmdb_ci_hardware" />
|
||||||
|
<v-text-field v-model.number="form.servicenow_cmdb_limit" type="number" label="Max records per sync" />
|
||||||
|
<v-text-field v-model="form.servicenow_cmdb_query" class="full" label="Filter (sysparm_query, optional)" placeholder="install_status=1^operational_status=1" />
|
||||||
|
</div>
|
||||||
|
<v-textarea
|
||||||
|
v-model="cmdbMapText"
|
||||||
|
class="mt-2"
|
||||||
|
label="Field mapping (asset field → CMDB column, JSON)"
|
||||||
|
:rows="6"
|
||||||
|
hint="Maps MixedAssets asset fields to ServiceNow CI columns. Leave default to use the built-in hardware mapping."
|
||||||
|
persistent-hint
|
||||||
|
:error-messages="mapError"
|
||||||
|
/>
|
||||||
|
<div class="toolbar-row mt-3">
|
||||||
|
<v-btn variant="tonal" prepend-icon="mdi-database-sync" :loading="syncing" :disabled="!settings.configured" @click="syncCmdb">Sync CMDB now</v-btn>
|
||||||
|
<div class="quiet text-body-2 ml-2">Last sync: {{ settings.servicenow_last_sync_at ? shortDate(settings.servicenow_last_sync_at) : 'Never' }}</div>
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="settings.servicenow_last_sync_status" class="mt-2" density="compact" type="info">{{ settings.servicenow_last_sync_status }}</v-alert>
|
||||||
|
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<div class="toolbar-row">
|
||||||
|
<v-btn variant="tonal" prepend-icon="mdi-connection" :loading="testing" :disabled="!form.servicenow_instance_url" @click="test">Test connection</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save ServiceNow settings</v-btn>
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="status" class="mt-3" density="compact" :type="statusType">{{ status }}</v-alert>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
import { shortDate } from '../utils/format';
|
||||||
|
|
||||||
|
function blankForm() {
|
||||||
|
return {
|
||||||
|
servicenow_instance_url: '',
|
||||||
|
servicenow_username: '',
|
||||||
|
servicenow_password: '',
|
||||||
|
servicenow_incident_table: 'incident',
|
||||||
|
servicenow_assignment_group: '',
|
||||||
|
servicenow_caller_id: '',
|
||||||
|
servicenow_ticket_enabled: false,
|
||||||
|
servicenow_auto_ticket: false,
|
||||||
|
servicenow_cmdb_table: 'cmdb_ci_hardware',
|
||||||
|
servicenow_cmdb_query: '',
|
||||||
|
servicenow_cmdb_limit: 200
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
settings: {},
|
||||||
|
form: blankForm(),
|
||||||
|
cmdbMapText: '',
|
||||||
|
mapError: '',
|
||||||
|
saving: false,
|
||||||
|
testing: false,
|
||||||
|
syncing: false,
|
||||||
|
status: '',
|
||||||
|
statusType: 'success'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
shortDate,
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
apply(settings) {
|
||||||
|
this.settings = settings;
|
||||||
|
this.form = {
|
||||||
|
servicenow_instance_url: settings.servicenow_instance_url || '',
|
||||||
|
servicenow_username: settings.servicenow_username || '',
|
||||||
|
servicenow_password: '',
|
||||||
|
servicenow_incident_table: settings.servicenow_incident_table || 'incident',
|
||||||
|
servicenow_assignment_group: settings.servicenow_assignment_group || '',
|
||||||
|
servicenow_caller_id: settings.servicenow_caller_id || '',
|
||||||
|
servicenow_ticket_enabled: settings.servicenow_ticket_enabled || false,
|
||||||
|
servicenow_auto_ticket: settings.servicenow_auto_ticket || false,
|
||||||
|
servicenow_cmdb_table: settings.servicenow_cmdb_table || 'cmdb_ci_hardware',
|
||||||
|
servicenow_cmdb_query: settings.servicenow_cmdb_query || '',
|
||||||
|
servicenow_cmdb_limit: settings.servicenow_cmdb_limit || 200
|
||||||
|
};
|
||||||
|
this.cmdbMapText = JSON.stringify(settings.servicenow_cmdb_map || {}, null, 2);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
const data = await (await this.api('/api/servicenow/settings')).json();
|
||||||
|
this.apply(data.settings);
|
||||||
|
},
|
||||||
|
buildPayload() {
|
||||||
|
this.mapError = '';
|
||||||
|
const payload = { ...this.form };
|
||||||
|
if (!payload.servicenow_password) delete payload.servicenow_password;
|
||||||
|
const text = this.cmdbMapText.trim();
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
payload.servicenow_cmdb_map = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
this.mapError = 'Field mapping must be valid JSON.';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payload.servicenow_cmdb_map = '';
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
const payload = this.buildPayload();
|
||||||
|
if (!payload) return;
|
||||||
|
this.saving = true;
|
||||||
|
this.status = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/servicenow/settings', { method: 'PUT', body: JSON.stringify(payload) })).json();
|
||||||
|
this.apply(data.settings);
|
||||||
|
this.statusType = 'success';
|
||||||
|
this.status = 'ServiceNow settings saved.';
|
||||||
|
} catch (error) {
|
||||||
|
this.statusType = 'error';
|
||||||
|
this.status = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async clearPassword() {
|
||||||
|
this.status = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/servicenow/settings', { method: 'PUT', body: JSON.stringify({ servicenow_password_clear: true }) })).json();
|
||||||
|
this.apply(data.settings);
|
||||||
|
this.statusType = 'success';
|
||||||
|
this.status = 'Password cleared.';
|
||||||
|
} catch (error) {
|
||||||
|
this.statusType = 'error';
|
||||||
|
this.status = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async test() {
|
||||||
|
this.testing = true;
|
||||||
|
this.status = '';
|
||||||
|
try {
|
||||||
|
await this.save();
|
||||||
|
const result = await (await this.api('/api/servicenow/test', { method: 'POST', body: JSON.stringify({}) })).json();
|
||||||
|
this.statusType = 'success';
|
||||||
|
this.status = `Connected to ${result.instance}.`;
|
||||||
|
} catch (error) {
|
||||||
|
this.statusType = 'error';
|
||||||
|
this.status = `Connection failed: ${error.message}`;
|
||||||
|
} finally {
|
||||||
|
this.testing = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async syncCmdb() {
|
||||||
|
const payload = this.buildPayload();
|
||||||
|
if (!payload) return;
|
||||||
|
this.syncing = true;
|
||||||
|
this.status = '';
|
||||||
|
try {
|
||||||
|
await this.save();
|
||||||
|
const result = await (await this.api('/api/servicenow/cmdb-sync', { method: 'POST', body: JSON.stringify({}) })).json();
|
||||||
|
this.statusType = 'success';
|
||||||
|
this.status = result.status || `Synced ${result.total} CI(s).`;
|
||||||
|
await this.load();
|
||||||
|
} catch (error) {
|
||||||
|
this.statusType = 'error';
|
||||||
|
this.status = `Sync failed: ${error.message}`;
|
||||||
|
} finally {
|
||||||
|
this.syncing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
80
src/components/SignaturePad.vue
Normal file
80
src/components/SignaturePad.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<canvas
|
||||||
|
ref="canvas"
|
||||||
|
class="sig-canvas"
|
||||||
|
@pointerdown="start"
|
||||||
|
@pointermove="draw"
|
||||||
|
@pointerup="end"
|
||||||
|
@pointerleave="end"
|
||||||
|
/>
|
||||||
|
<div class="d-flex align-center mt-1">
|
||||||
|
<span class="quiet text-caption">Sign in the box above</span>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="x-small" variant="text" @click="clear">Clear</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
modelValue: { type: String, default: '' }
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
data() {
|
||||||
|
return { drawing: false, ctx: null, hasInk: false };
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
const canvas = this.$refs.canvas;
|
||||||
|
canvas.width = canvas.offsetWidth || 460;
|
||||||
|
canvas.height = 150;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
this.ctx.lineWidth = 2;
|
||||||
|
this.ctx.lineCap = 'round';
|
||||||
|
this.ctx.strokeStyle = '#111';
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
pos(event) {
|
||||||
|
const rect = this.$refs.canvas.getBoundingClientRect();
|
||||||
|
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||||
|
},
|
||||||
|
start(event) {
|
||||||
|
this.drawing = true;
|
||||||
|
const point = this.pos(event);
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(point.x, point.y);
|
||||||
|
try { this.$refs.canvas.setPointerCapture(event.pointerId); } catch { /* not supported */ }
|
||||||
|
},
|
||||||
|
draw(event) {
|
||||||
|
if (!this.drawing) return;
|
||||||
|
const point = this.pos(event);
|
||||||
|
this.ctx.lineTo(point.x, point.y);
|
||||||
|
this.ctx.stroke();
|
||||||
|
this.hasInk = true;
|
||||||
|
},
|
||||||
|
end() {
|
||||||
|
if (!this.drawing) return;
|
||||||
|
this.drawing = false;
|
||||||
|
if (this.hasInk) this.$emit('update:modelValue', this.$refs.canvas.toDataURL('image/png'));
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
this.ctx.clearRect(0, 0, this.$refs.canvas.width, this.$refs.canvas.height);
|
||||||
|
this.hasInk = false;
|
||||||
|
this.$emit('update:modelValue', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sig-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-app-bar class="top-nav" density="compact" flat height="64">
|
<v-app-bar class="top-nav" density="compact" flat height="64">
|
||||||
|
<v-btn icon="mdi-menu" variant="text" class="mr-1" title="Toggle menu" aria-label="Toggle navigation menu" @click="$emit('toggle-nav')" />
|
||||||
<div class="top-nav-title">
|
<div class="top-nav-title">
|
||||||
<div class="text-subtitle-2 quiet">{{ subtitle }}</div>
|
<div class="text-subtitle-2 quiet">{{ subtitle }}</div>
|
||||||
<div class="text-h6 font-weight-bold">{{ title }}</div>
|
<div class="text-h6 font-weight-bold">{{ title }}</div>
|
||||||
</div>
|
</div>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
|
<v-btn icon="mdi-help-circle-outline" variant="text" class="mr-1" title="User guide" aria-label="Open user guide" @click="$emit('open-help')" />
|
||||||
<v-menu v-model="profileMenu" :close-on-content-click="false" location="bottom end">
|
<v-menu v-model="profileMenu" :close-on-content-click="false" location="bottom end">
|
||||||
<template #activator="{ props }">
|
<template #activator="{ props }">
|
||||||
<v-btn v-bind="props" class="profile-trigger" variant="text">
|
<v-btn v-bind="props" class="profile-trigger" variant="text" aria-label="Account and appearance settings">
|
||||||
<v-avatar color="primary" size="32">
|
<v-avatar color="primary" size="32">
|
||||||
<span class="text-caption font-weight-bold">{{ initials }}</span>
|
<span class="text-caption font-weight-bold">{{ initials }}</span>
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
@@ -30,11 +32,68 @@
|
|||||||
<v-select
|
<v-select
|
||||||
v-model="localPreferences.theme"
|
v-model="localPreferences.theme"
|
||||||
:items="themeItems"
|
:items="themeItems"
|
||||||
|
:item-props="themeItemProps"
|
||||||
item-title="title"
|
item-title="title"
|
||||||
item-value="value"
|
item-value="value"
|
||||||
label="Theme"
|
label="Theme"
|
||||||
prepend-inner-icon="mdi-theme-light-dark"
|
prepend-inner-icon="mdi-theme-light-dark"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<v-select
|
||||||
|
v-model="localPreferences.fontScale"
|
||||||
|
:items="fontSizeItems"
|
||||||
|
:item-props="fontItemProps"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
label="Text size"
|
||||||
|
prepend-inner-icon="mdi-format-size"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="text-caption quiet mt-1 mb-1">Accent colour</div>
|
||||||
|
<div class="accent-swatches" role="group" aria-label="Accent colour presets">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="accent-swatch accent-default"
|
||||||
|
:class="{ selected: !localPreferences.accent }"
|
||||||
|
title="Theme default"
|
||||||
|
aria-label="Use theme default accent colour"
|
||||||
|
:aria-pressed="!localPreferences.accent"
|
||||||
|
@click="localPreferences.accent = ''"
|
||||||
|
>
|
||||||
|
<v-icon icon="mdi-cancel" size="16" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="color in accentPresets"
|
||||||
|
:key="color"
|
||||||
|
type="button"
|
||||||
|
class="accent-swatch"
|
||||||
|
:class="{ selected: sameColor(localPreferences.accent, color) }"
|
||||||
|
:style="{ backgroundColor: color }"
|
||||||
|
:title="color"
|
||||||
|
:aria-label="`Accent colour ${color}`"
|
||||||
|
:aria-pressed="sameColor(localPreferences.accent, color)"
|
||||||
|
@click="localPreferences.accent = color"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<v-text-field
|
||||||
|
v-model="localPreferences.accent"
|
||||||
|
label="Custom accent (hex)"
|
||||||
|
placeholder="#2457a6"
|
||||||
|
density="compact"
|
||||||
|
class="mt-2"
|
||||||
|
prepend-inner-icon="mdi-eyedropper-variant"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-switch
|
||||||
|
v-model="localPreferences.contrast"
|
||||||
|
label="High-contrast boost"
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="mt-1"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-alert v-if="status" class="mt-2" density="compact" type="success">{{ status }}</v-alert>
|
<v-alert v-if="status" class="mt-2" density="compact" type="success">{{ status }}</v-alert>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
@@ -48,6 +107,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { accentPresets, fontSizeItems } from '../constants';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
preferences: { type: Object, required: true },
|
preferences: { type: Object, required: true },
|
||||||
@@ -57,11 +118,14 @@ export default {
|
|||||||
title: { type: String, required: true },
|
title: { type: String, required: true },
|
||||||
user: { type: Object, required: true }
|
user: { type: Object, required: true }
|
||||||
},
|
},
|
||||||
emits: ['logout', 'save-preferences'],
|
emits: ['logout', 'open-help', 'save-preferences', 'toggle-nav'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
accentPresets,
|
||||||
|
fontSizeItems,
|
||||||
localPreferences: { ...this.preferences },
|
localPreferences: { ...this.preferences },
|
||||||
profileMenu: false,
|
profileMenu: false,
|
||||||
|
syncing: false,
|
||||||
status: ''
|
status: ''
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -78,15 +142,32 @@ export default {
|
|||||||
watch: {
|
watch: {
|
||||||
preferences: {
|
preferences: {
|
||||||
handler(value) {
|
handler(value) {
|
||||||
|
// Sync from parent without echoing back a live-preview emit (avoids a feedback loop).
|
||||||
|
this.syncing = true;
|
||||||
this.localPreferences = { ...value };
|
this.localPreferences = { ...value };
|
||||||
|
this.$nextTick(() => { this.syncing = false; });
|
||||||
},
|
},
|
||||||
deep: true
|
deep: true
|
||||||
},
|
},
|
||||||
'localPreferences.theme'(theme) {
|
localPreferences: {
|
||||||
this.$emit('save-preferences', { ...this.localPreferences, theme }, { persist: false });
|
handler(value) {
|
||||||
|
if (this.syncing) return;
|
||||||
|
this.$emit('save-preferences', { ...value }, { persist: false });
|
||||||
|
},
|
||||||
|
deep: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
themeItemProps(item) {
|
||||||
|
return { subtitle: item.props?.subtitle };
|
||||||
|
},
|
||||||
|
fontItemProps(item) {
|
||||||
|
return { subtitle: item.props?.subtitle };
|
||||||
|
},
|
||||||
|
sameColor(a, b) {
|
||||||
|
const norm = (value) => String(value || '').trim().toLowerCase().replace(/^#/, '');
|
||||||
|
return norm(a) !== '' && norm(a) === norm(b);
|
||||||
|
},
|
||||||
async save() {
|
async save() {
|
||||||
await this.$emit('save-preferences', { ...this.localPreferences }, { persist: true });
|
await this.$emit('save-preferences', { ...this.localPreferences }, { persist: true });
|
||||||
this.status = 'Preferences saved';
|
this.status = 'Preferences saved';
|
||||||
@@ -97,3 +178,37 @@ export default {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.accent-swatches {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.accent-swatch {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(var(--v-theme-on-surface), 0.25);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
transition: transform 0.08s ease, border-color 0.08s ease;
|
||||||
|
}
|
||||||
|
.accent-swatch:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
.accent-swatch.selected {
|
||||||
|
border-color: rgb(var(--v-theme-on-surface));
|
||||||
|
box-shadow: 0 0 0 2px rgb(var(--v-theme-surface)), 0 0 0 4px rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
.accent-swatch.accent-default {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.accent-swatch:focus-visible {
|
||||||
|
outline: 3px solid rgb(var(--v-theme-info));
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
994
src/components/UserManual.vue
Normal file
994
src/components/UserManual.vue
Normal file
@@ -0,0 +1,994 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog :model-value="modelValue" max-width="1180" scrollable @update:model-value="$emit('update:modelValue', $event)">
|
||||||
|
<v-card class="user-manual-print" height="90vh">
|
||||||
|
<v-toolbar color="primary" density="comfortable" class="px-2">
|
||||||
|
<v-icon icon="mdi-book-open-page-variant" class="ml-2 mr-2" />
|
||||||
|
<v-toolbar-title class="font-weight-bold">MixedAssets User Guide</v-toolbar-title>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" prepend-icon="mdi-printer" class="d-print-none" @click="print">Print / Save PDF</v-btn>
|
||||||
|
<v-btn icon="mdi-close" variant="text" class="d-print-none" @click="$emit('update:modelValue', false)" />
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<div class="manual-body">
|
||||||
|
<!-- Section navigation -->
|
||||||
|
<nav class="user-manual-nav">
|
||||||
|
<v-text-field
|
||||||
|
v-model="search"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
variant="solo-filled"
|
||||||
|
flat
|
||||||
|
placeholder="Search topics"
|
||||||
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
class="ma-2"
|
||||||
|
/>
|
||||||
|
<v-list density="compact" nav class="pa-1">
|
||||||
|
<v-list-item
|
||||||
|
v-for="section in filteredSections"
|
||||||
|
:key="section.id"
|
||||||
|
:active="active === section.id"
|
||||||
|
:prepend-icon="section.icon"
|
||||||
|
:title="section.title"
|
||||||
|
rounded="sm"
|
||||||
|
@click="select(section.id)"
|
||||||
|
/>
|
||||||
|
<v-list-item v-if="!filteredSections.length" class="quiet text-body-2">No topics match “{{ search }}”.</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div ref="content" class="manual-content user-manual-content">
|
||||||
|
<div class="manual-doc-title d-none d-print-block">
|
||||||
|
<h1>MixedAssets User Guide</h1>
|
||||||
|
<p class="quiet">Fixed-asset management, depreciation, maintenance & integrations</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GETTING STARTED -->
|
||||||
|
<section v-show="isShown('overview')" class="manual-section">
|
||||||
|
<h2>Getting started</h2>
|
||||||
|
<p>
|
||||||
|
MixedAssets is an integrated system for tracking physical assets through their full lifecycle —
|
||||||
|
acquisition, depreciation across multiple accounting books, preventative maintenance, disposal — alongside
|
||||||
|
a parts inventory, a contacts/CRM directory, an alerting engine, and connectors to email, webhooks, and ServiceNow.
|
||||||
|
</p>
|
||||||
|
<p>Most day-to-day work follows one of these flows:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Record an asset</strong> → set its books & depreciation → attach warranties, files, photos, and maintenance plans.</li>
|
||||||
|
<li><strong>Maintain it</strong> → complete preventative-maintenance services, log tasks and notes, draw down spare parts.</li>
|
||||||
|
<li><strong>Account for it</strong> → review the general ledger per book, run reports, and record disposals or impairments when it leaves service.</li>
|
||||||
|
</ul>
|
||||||
|
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||||
|
You can reopen this guide at any time from the <strong>?</strong> icon in the top bar. Use the topic list on the
|
||||||
|
left, or the search box, to jump to a subject. The <strong>Print / Save PDF</strong> button produces a complete printable manual.
|
||||||
|
</v-alert>
|
||||||
|
<h3>Signing in</h3>
|
||||||
|
<p>
|
||||||
|
Sign in with your email and password. Your <em>role</em> determines which screens and actions are available
|
||||||
|
(see <a href="#" @click.prevent="select('roles')">Roles & permissions</a>). If a screen is missing from your
|
||||||
|
left-hand menu, your role does not grant access to it.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- INTERFACE -->
|
||||||
|
<section v-show="isShown('navigation')" class="manual-section">
|
||||||
|
<h2>Interface & navigation</h2>
|
||||||
|
<h3>The left navigation rail</h3>
|
||||||
|
<p>
|
||||||
|
The rail on the left lists every screen your role can access. Some items expand to reveal sub-items:
|
||||||
|
<strong>Assets</strong> contains <strong>Scan</strong>, <strong>Assignments</strong>, and <strong>Templates</strong>; <strong>Maintenance</strong>
|
||||||
|
contains <strong>Parts inventory</strong>; and <strong>Financial</strong> contains <strong>Books</strong> and <strong>Tax rules</strong>.
|
||||||
|
<strong>Admin</strong> expands to <strong>Users & Teams</strong>, <strong>Application Configuration</strong>, and <strong>Integrations</strong>.
|
||||||
|
Collapse the rail to icons using the chevron at the top of the rail
|
||||||
|
(or the menu button in the top bar) to give content more room; your choice is remembered on this device.
|
||||||
|
</p>
|
||||||
|
<h3>The top bar</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Shows the current screen title and a short description.</li>
|
||||||
|
<li>Screen-specific actions (such as <strong>New asset</strong>) appear on the right.</li>
|
||||||
|
<li>The <strong>?</strong> icon opens this user guide.</li>
|
||||||
|
<li>Your avatar opens the profile menu for theme selection and sign-out.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Themes & appearance</h3>
|
||||||
|
<p>
|
||||||
|
Open the profile menu (your avatar, top-right) to personalize the interface. All choices are saved to your account and
|
||||||
|
follow you between devices:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Theme</strong> — several dark options (Dark, Ocean, Slate, High contrast) and light options (Light, Sepia).</li>
|
||||||
|
<li><strong>Text size</strong> — Small, Normal, Large, or Extra large; this scales the whole interface for easier reading.</li>
|
||||||
|
<li><strong>Accent colour</strong> — pick a preset swatch or enter any custom hex value; choose “default” to return to the theme’s colour. Button text automatically switches to black or white for legibility.</li>
|
||||||
|
<li><strong>High-contrast boost</strong> — strengthens borders, text, and link underlines on top of whichever theme you’re using.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Accessibility</h3>
|
||||||
|
<p>MixedAssets is built to work with assistive technology:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Press <strong>Tab</strong> to reveal a “Skip to main content” link, and to move through controls with a clear keyboard focus outline.</li>
|
||||||
|
<li>Images and photos carry descriptive text for screen readers, and icon-only buttons have spoken labels.</li>
|
||||||
|
<li>The text-size and high-contrast options above support low-vision use, and the app honours your system’s “reduce motion” setting.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Tables</h3>
|
||||||
|
<p>Most lists use a consistent table with the same controls everywhere:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Filter box</strong> — type to narrow the rows.</li>
|
||||||
|
<li><strong>Sortable columns</strong> — click a header to sort; click again to reverse, a third time to clear.</li>
|
||||||
|
<li><strong>Rows per page</strong> and pagination at the bottom; choose <em>All</em> to show everything.</li>
|
||||||
|
<li>Headers stay visible (sticky) while you scroll long lists.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- DASHBOARD -->
|
||||||
|
<section v-show="isShown('dashboard')" class="manual-section">
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
<p>
|
||||||
|
The Dashboard is your portfolio at a glance. It summarizes the number of assets, total cost, how many are in
|
||||||
|
service versus disposed, and a chart of value by category, with a supporting breakdown table beneath it.
|
||||||
|
It also surfaces recent activity and upcoming work so you can see what needs attention without opening each screen.
|
||||||
|
</p>
|
||||||
|
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||||
|
The Dashboard is read-only — it is a reporting surface. Make changes from the relevant screen (Assets, Maintenance, etc.)
|
||||||
|
and the Dashboard updates automatically.
|
||||||
|
</v-alert>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ASSETS -->
|
||||||
|
<section v-show="isShown('assets')" class="manual-section">
|
||||||
|
<h2>Assets — creating & managing</h2>
|
||||||
|
<p>
|
||||||
|
The <strong>Asset register</strong> lists every asset. Use the filter and status selector to find records, select
|
||||||
|
rows for bulk actions, and open any asset to edit it in the <em>asset workbench</em> (the panel that slides in from the right).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Creating an asset</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Click <strong>New asset</strong> in the top bar.</li>
|
||||||
|
<li>Optionally pick a <strong>Template</strong> to pre-fill category, life, method, GL accounts, and custom fields.</li>
|
||||||
|
<li>Fill in the <strong>Details</strong>: description, category, acquisition cost, acquired / in-service dates, useful life, location, custodian, serial number, and so on.</li>
|
||||||
|
<li>Click <strong>Save asset</strong>. The workbench stays open on the new record so you can immediately add books, files, photos, warranties, and maintenance.</li>
|
||||||
|
</ol>
|
||||||
|
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||||
|
An Asset ID is generated automatically if you leave it blank. You can also type your own identifier.
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<h3>The asset workbench tabs</h3>
|
||||||
|
<table class="manual-table">
|
||||||
|
<thead><tr><th>Tab</th><th>What it’s for</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><strong>Details</strong></td><td>Core asset data, GL accounts, condition, and the barcode label (print a single label here).</td></tr>
|
||||||
|
<tr><td><strong>Books</strong></td><td>Per-book depreciation settings — each book can carry its own cost, method, life, §179, bonus, and convention. See <a href="#" @click.prevent="select('books')">Books, depreciation & tax</a>.</td></tr>
|
||||||
|
<tr><td><strong>Files</strong></td><td>Attach invoices, manuals, and insurance records. Files are stored with the asset and can be downloaded later.</td></tr>
|
||||||
|
<tr><td><strong>Photos</strong></td><td>Identification photos — what the asset looks like, where it is, nameplates and serial labels. Each photo can have a caption. On a phone, <strong>Add photos</strong> opens the camera.</td></tr>
|
||||||
|
<tr><td><strong>Warranties</strong></td><td>Provider, coverage window, limits, and details. Expiring warranties raise alerts.</td></tr>
|
||||||
|
<tr><td><strong>Tasks</strong></td><td>Ad-hoc to-dos (inspection, calibration, renewal). Overdue tasks raise alerts. Tick a task to complete it.</td></tr>
|
||||||
|
<tr><td><strong>Notes</strong></td><td>A running, timestamped log of comments about the asset.</td></tr>
|
||||||
|
<tr><td><strong>Disposal</strong></td><td>Record a sale/retirement and impairments — see <a href="#" @click.prevent="select('lifecycle')">Asset lifecycle</a>.</td></tr>
|
||||||
|
<tr><td><strong>Lease</strong></td><td>Capture lease terms and view the amortization schedule — see <a href="#" @click.prevent="select('lifecycle')">Asset lifecycle</a>.</td></tr>
|
||||||
|
<tr><td><strong>PM</strong></td><td>Attach preventative-maintenance plans and complete services — see <a href="#" @click.prevent="select('pm')">Preventative maintenance</a>.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Editing & bulk changes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Open any row to edit it, then <strong>Save asset</strong>.</li>
|
||||||
|
<li>Select multiple rows and use <strong>mass edit</strong> to change status, location, or department across all of them at once.</li>
|
||||||
|
<li><strong>Import</strong> a spreadsheet to create/update many assets, and <strong>export</strong> the register to a file.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Status values</h3>
|
||||||
|
<p>
|
||||||
|
Assets move through statuses such as <em>in service</em>, <em>CIP</em> (construction in progress), <em>reserved</em>,
|
||||||
|
<em>checked out</em>, <em>disposed</em>, and <em>retired</em>. Status drives filtering, the Dashboard counts, and which
|
||||||
|
records appear in reports.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- LIFECYCLE -->
|
||||||
|
<section v-show="isShown('lifecycle')" class="manual-section">
|
||||||
|
<h2>Asset lifecycle — disposal, leases & impairment</h2>
|
||||||
|
|
||||||
|
<h3>Disposing of an asset</h3>
|
||||||
|
<p>Open the asset, go to the <strong>Disposal</strong> tab, and:</p>
|
||||||
|
<ol>
|
||||||
|
<li>Choose the <strong>book</strong> the disposal is calculated against, the <strong>disposal type</strong> (sale, exchange, abandonment, like-kind, involuntary conversion), and the date.</li>
|
||||||
|
<li>Enter the sale price and any selling expense. For a partial disposal, tick <strong>Partial</strong> and enter the cost basis being removed.</li>
|
||||||
|
<li>Click <strong>Calculate gain/loss</strong> to preview accumulated depreciation, net book value, proceeds, and the resulting gain or loss.</li>
|
||||||
|
<li>Click <strong>Record disposal</strong> to finalize. The asset is marked disposed and the entry appears in the disposal history (you can reverse it from there if needed).</li>
|
||||||
|
</ol>
|
||||||
|
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||||
|
Gain/loss is computed from the selected book’s net book value at the disposal date, so the figure reflects that book’s
|
||||||
|
method and conventions. Disposals also feed the disposal and gain/loss reports.
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<h3>Impairments & adjustments</h3>
|
||||||
|
<p>
|
||||||
|
Below the disposal form, record an <strong>impairment</strong>, write-down, write-up, or revaluation against a specific
|
||||||
|
book with an amount, date, and reason. These adjust the asset’s carrying value and appear in the adjustments report.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Leases</h3>
|
||||||
|
<p>
|
||||||
|
On the <strong>Lease</strong> tab, capture the lessor, total contract value, start/end dates, discount rate, and payment
|
||||||
|
frequency. Select a saved lease to view its <strong>amortization schedule</strong> — present value (right-of-use asset),
|
||||||
|
periodic payment, and the period-by-period split of interest, principal, and remaining liability. Leases nearing their
|
||||||
|
end date raise alerts.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BARCODES -->
|
||||||
|
<section v-show="isShown('barcodes')" class="manual-section">
|
||||||
|
<h2>Barcodes & scanning</h2>
|
||||||
|
<h3>Printing labels</h3>
|
||||||
|
<p>
|
||||||
|
Every asset can carry a barcode value (it falls back to the Asset ID if left blank). Print a single label from the
|
||||||
|
asset’s <strong>Details</strong> tab, or select multiple assets in the register and print a <strong>label sheet</strong> for batch tagging.
|
||||||
|
</p>
|
||||||
|
<h3>The Scan screen</h3>
|
||||||
|
<p>
|
||||||
|
Use <strong>Scan</strong> to find an asset in the field: point your device camera at a barcode (or type the code), and the
|
||||||
|
matching asset opens so you can update its status, location, department, custodian, and condition, or add a quick note on the spot.
|
||||||
|
</p>
|
||||||
|
<v-alert type="warning" variant="tonal" density="comfortable" class="manual-callout">
|
||||||
|
Camera scanning requires a secure (HTTPS) connection or running on the local machine. Where the camera isn’t available,
|
||||||
|
manual code entry works everywhere.
|
||||||
|
</v-alert>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BOOKS / DEPRECIATION -->
|
||||||
|
<section v-show="isShown('books')" class="manual-section">
|
||||||
|
<h2>Books, depreciation & tax</h2>
|
||||||
|
<p>
|
||||||
|
A <strong>book</strong> is a parallel set of depreciation figures — for example GAAP (financial statements), Federal tax,
|
||||||
|
State, AMT, ACE, and your own user-defined books. The same physical asset is depreciated independently in each book, so you
|
||||||
|
can satisfy financial reporting and several tax regimes from one record.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>How the financial modules relate to asset tracking</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Books screen</strong> — defines the <em>registry</em> of books available across the whole system: which are enabled, each book’s default method and convention, and which tax rule set it uses.</li>
|
||||||
|
<li><strong>Asset → Books tab</strong> — every enabled book appears here for each asset, where you can override cost, life, method, §179, bonus, and convention <em>per book, per asset</em>.</li>
|
||||||
|
<li><strong>Tax rule sets</strong> — supply the catalog of depreciation methods and the statutory limits (e.g. §179 caps, bonus %) that a book draws on. Assigning a rule set to a book is what makes that book follow a given jurisdiction’s rules.</li>
|
||||||
|
<li><strong>Depreciation engine</strong> — calculates each book using its assigned rule set; the results drive the general ledger, reports, and disposal gain/loss.</li>
|
||||||
|
</ul>
|
||||||
|
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||||
|
In short: <em>Tax rule set → assigned to a Book → applied per Asset → produces depreciation → rolls up to the GL and reports.</em>
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<h3>Configuring books</h3>
|
||||||
|
<p>On the <strong>Books</strong> screen you can:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Enable/disable books, set each book’s default method and convention, and assign its tax rule set.</li>
|
||||||
|
<li><strong>Create a user-defined book</strong> (e.g. IFRS or an internal management book) with the <strong>New book</strong> button. Newly enabled books automatically appear on every asset’s Books tab.</li>
|
||||||
|
<li>Delete a book that isn’t a primary or maintenance book and isn’t in use.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>The general ledger view</h3>
|
||||||
|
<p>
|
||||||
|
Select a book to view its <strong>GL ledger</strong> in debit/credit format — asset cost, accumulated depreciation, and the
|
||||||
|
period’s depreciation expense rolled to GL accounts, with per-asset detail and a summary. You can export the ledger.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Depreciation methods & critical fields</h3>
|
||||||
|
<p>
|
||||||
|
Each book’s depreciation is computed from the depreciation-critical fields: property type, placed-in-service date,
|
||||||
|
acquisition value, depreciation method, estimated life, salvage value, Section 168 (bonus) allowance %, Section 179, and
|
||||||
|
business-use %. Supported 200% declining-balance methods include <strong>MF200</strong> (MACRS formula),
|
||||||
|
<strong>MT200</strong> (MACRS tables), and <strong>MI200</strong> (MACRS, Indian-reservation) — which ignore salvage and
|
||||||
|
recover the full basis — and <strong>DB200</strong>, <strong>DH200</strong> (half-year), and <strong>DD200</strong> (full-year),
|
||||||
|
which honor salvage (depreciating only down to it). All switch to straight-line when that yields a larger deduction.
|
||||||
|
</p>
|
||||||
|
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||||
|
When you change a depreciation-critical field on an asset that already has depreciation, a prompt asks <strong>when to apply</strong>
|
||||||
|
the change: <em>Placed-in-service date</em> (rebuild the whole schedule) or <em>Current period — going forward</em> (keep depreciation
|
||||||
|
already recorded).
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<h3>Special depreciation zones</h3>
|
||||||
|
<p>
|
||||||
|
Assign a <strong>Special depreciation zone</strong> on the asset’s Details tab (e.g. <strong>New York Liberty Zone</strong>) and the
|
||||||
|
engine automatically applies the zone’s §168 special allowance (30% for NY Liberty) to the <strong>federal book</strong> when the
|
||||||
|
asset’s placed-in-service date falls within the zone’s window. The form shows a note confirming the allowance, the date window,
|
||||||
|
and any leasehold-improvement life. Zones are managed in <strong>Admin → Application Configuration → Depreciation zones</strong>
|
||||||
|
(create your own, set the allowance %, date window, and notes).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>The PM (maintenance) book</h3>
|
||||||
|
<p>
|
||||||
|
A dedicated <strong>PM book</strong> tracks actual maintenance <em>spend</em> per asset rather than depreciation. Its ledger
|
||||||
|
aggregates the cost captured when preventative-maintenance services are completed (see <a href="#" @click.prevent="select('pm')">Preventative maintenance</a>).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Reports</h3>
|
||||||
|
<p>
|
||||||
|
The <strong>Reports</strong> screen is catalog-driven: choose a report, set parameters (book, year, etc.), and view a table
|
||||||
|
with totals and an optional chart. Reports cover depreciation schedules and expense, net book value, roll-forwards,
|
||||||
|
acquisitions, disposals and gain/loss, adjustments, journal entries, several tax forms, and maintenance/PM and alert
|
||||||
|
reports. You can save report configurations to rerun later and export results to PDF, Excel, or CSV. To print an individual
|
||||||
|
PM plan, run the PM plan report and export it.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TAX RULES -->
|
||||||
|
<section v-show="isShown('taxrules')" class="manual-section">
|
||||||
|
<h2>Tax rule sets</h2>
|
||||||
|
<p>
|
||||||
|
A <strong>tax rule set</strong> defines the depreciation methods and statutory limits for a jurisdiction
|
||||||
|
(for example US federal, or a particular state). A book consults its assigned rule set to compute depreciation.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>The Tax rules editor</h3>
|
||||||
|
<p>The screen has two parts: <strong>identity fields</strong> on the left (Name, Jurisdiction, Version, Effective date, Source note, Active) and a <strong>JSON editor</strong> holding the rule body. The toolbar offers:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>New</strong> / <strong>Upload JSON</strong> — start a fresh set or load one from a file.</li>
|
||||||
|
<li><strong>Format</strong> — validate and tidy (pretty-print) the JSON. <em>Save is disabled while the JSON is invalid.</em></li>
|
||||||
|
<li><strong>Regenerate method catalog</strong> — auto-fill the full set of standard depreciation methods, so you rarely have to type the <code>methods</code> list by hand.</li>
|
||||||
|
<li><strong>Export</strong> — download the set as a JSON file.</li>
|
||||||
|
<li><strong>Activate</strong> — make this version the one in effect for its jurisdiction (this deactivates other versions for the same jurisdiction).</li>
|
||||||
|
<li><strong>Save</strong> / <strong>Delete</strong>.</li>
|
||||||
|
</ul>
|
||||||
|
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||||
|
The fastest way to build a set: click <strong>New</strong>, fill the identity fields, click <strong>Regenerate method catalog</strong> to populate the standard methods, adjust the <code>limits</code> for your jurisdiction, then <strong>Format</strong>, <strong>Save</strong>, and <strong>Activate</strong>. Finally, assign it to a book on <strong>Financial → Books</strong>.
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<h3>A quick JSON primer</h3>
|
||||||
|
<p>The rule body is written in JSON. A few rules cover almost everything you need:</p>
|
||||||
|
<ul>
|
||||||
|
<li>The whole document is an <strong>object</strong> wrapped in curly braces.</li>
|
||||||
|
<li>Inside, you list <strong>"key": value</strong> pairs, separated by commas.</li>
|
||||||
|
<li>Keys and text values are wrapped in <strong>double quotes</strong> — <code>"like this"</code>.</li>
|
||||||
|
<li>Numbers (<code>60</code>, <code>0.15</code>) and <code>true</code> / <code>false</code> / <code>null</code> are written <strong>without</strong> quotes.</li>
|
||||||
|
<li>A <strong>list</strong> uses square brackets, with items separated by commas. Objects and lists can nest inside each other.</li>
|
||||||
|
<li>Do <strong>not</strong> put a comma after the last item in an object or list — a trailing comma is the most common mistake. Use <strong>Format</strong> to catch errors.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Top-level fields</h3>
|
||||||
|
<table class="manual-table">
|
||||||
|
<thead><tr><th>Field</th><th>Type</th><th>Purpose</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>schema</td><td>text</td><td>Optional tag identifying the file format.</td></tr>
|
||||||
|
<tr><td>jurisdiction</td><td>text</td><td>The jurisdiction this set governs (e.g. <code>US-FED</code>, <code>US-CA</code>). Also set by the identity field.</td></tr>
|
||||||
|
<tr><td>name</td><td>text</td><td>Display name for the rule set.</td></tr>
|
||||||
|
<tr><td>version</td><td>text</td><td>Version label; must be unique within a jurisdiction.</td></tr>
|
||||||
|
<tr><td>effectiveDate</td><td>date text</td><td>When the rules take effect, as <code>"YYYY-MM-DD"</code>.</td></tr>
|
||||||
|
<tr><td>sourceNote</td><td>text</td><td>Free-form note about the source or authority.</td></tr>
|
||||||
|
<tr><td>books</td><td>list of text</td><td>Reference list of the books this set is intended for.</td></tr>
|
||||||
|
<tr><td>conventions</td><td>list of text</td><td>Conventions in use (reference): <code>half_year</code>, <code>mid_quarter</code>, <code>mid_month</code>, <code>full_month</code>, <code>none</code>.</td></tr>
|
||||||
|
<tr><td>limits</td><td>object</td><td>Statutory reference limits — §179, bonus, de-minimis (see below).</td></tr>
|
||||||
|
<tr><td>assetLives</td><td>list of objects</td><td>Reference list of class lives, each <code>{ code, label, months }</code>.</td></tr>
|
||||||
|
<tr><td>methods</td><td>list of objects</td><td><strong>The calculation catalog the engine actually uses</strong> (see below).</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="quiet">Name, jurisdiction, version, effective date, source note, and active status are also editable in the identity fields beside the editor; keeping them in the JSON too is fine.</p>
|
||||||
|
|
||||||
|
<h4>The <code>limits</code> object</h4>
|
||||||
|
<ul>
|
||||||
|
<li><strong>section179</strong>: <code>deductionLimit</code> (number), <code>phaseOutBegins</code> (number), <code>cannotCreateLoss</code> (true/false).</li>
|
||||||
|
<li><strong>bonusDepreciation</strong>: <code>qualifiedPropertyPercent</code> (e.g. <code>100</code>), <code>effectivePlacedInServiceAfter</code> (date text).</li>
|
||||||
|
<li><strong>deMinimisSafeHarbor</strong>: <code>defaultThreshold</code> (number), <code>auditedFinancialStatementThreshold</code> (number).</li>
|
||||||
|
</ul>
|
||||||
|
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||||
|
These limits <em>document</em> the jurisdiction’s caps for reference. The actual §179 and bonus amounts applied to a given
|
||||||
|
asset are the values you enter on that asset’s <strong>Books</strong> tab.
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<h3>Inside <code>methods</code> — the calculation catalog</h3>
|
||||||
|
<p>
|
||||||
|
Each entry in <code>methods</code> is one depreciation method. When you pick a method on an asset’s Books tab, the engine
|
||||||
|
matches your choice by its <code>code</code> and computes depreciation using that entry’s settings.
|
||||||
|
</p>
|
||||||
|
<table class="manual-table">
|
||||||
|
<thead><tr><th>Field</th><th>Type</th><th>Purpose</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>code</td><td>text</td><td>Unique identifier; this is what a book’s “method” selection points to.</td></tr>
|
||||||
|
<tr><td>family</td><td>text</td><td>Grouping shown in the picker — e.g. <code>MACRS</code>, <code>ACRS</code>, <code>GAAP</code>, <code>Manual</code>, <code>Lease</code>.</td></tr>
|
||||||
|
<tr><td>label</td><td>text</td><td>Human-readable name shown in pickers and reports.</td></tr>
|
||||||
|
<tr><td>formula</td><td>text</td><td>How depreciation is calculated (see values below).</td></tr>
|
||||||
|
<tr><td>lifeMonths</td><td>number</td><td>Recovery period in months (e.g. <code>60</code> = 5 years).</td></tr>
|
||||||
|
<tr><td>defaultConvention</td><td>text</td><td>First/last-year convention this method applies (overrides the book’s convention).</td></tr>
|
||||||
|
<tr><td>rateMultiplier</td><td>number</td><td>Declining-balance rate: <code>2</code> = 200%, <code>1.5</code> = 150%.</td></tr>
|
||||||
|
<tr><td>switchToStraightLine</td><td>true/false</td><td>For declining balance, switch to straight-line once it yields a larger deduction (typical for MACRS).</td></tr>
|
||||||
|
<tr><td>rates</td><td>list of numbers</td><td>For the <code>rate_table</code> formula: the per-year fractions of basis (should total ≈ 1.0).</td></tr>
|
||||||
|
<tr><td>fallbackFormula</td><td>text</td><td>Used when a rate table is empty (e.g. <code>straight_line</code>).</td></tr>
|
||||||
|
<tr><td>system</td><td>text</td><td>Optional label such as <code>GDS</code>, <code>ADS</code>, or <code>ACRS</code>.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h4>Accepted <code>formula</code> values</h4>
|
||||||
|
<table class="manual-table">
|
||||||
|
<thead><tr><th>Value</th><th>Behavior</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>straight_line</code></td><td>Even amount over the life. Used as the fallback for any unrecognized formula.</td></tr>
|
||||||
|
<tr><td><code>declining_balance</code></td><td>Accelerated, using <code>rateMultiplier</code>.</td></tr>
|
||||||
|
<tr><td><code>macrs_declining_balance</code></td><td>Declining balance that switches to straight-line (the standard MACRS behavior).</td></tr>
|
||||||
|
<tr><td><code>sum_of_years_digits</code></td><td>Accelerated sum-of-years-digits.</td></tr>
|
||||||
|
<tr><td><code>rate_table</code></td><td>Applies the per-year fractions in <code>rates</code>.</td></tr>
|
||||||
|
<tr><td><code>acrs_alternate_straight_line</code></td><td>ACRS alternate (modified) straight-line.</td></tr>
|
||||||
|
<tr><td><code>manual</code></td><td>You enter each year’s amount on the asset (no automatic calculation).</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Sample rule set</h3>
|
||||||
|
<p>A compact example showing the three most common method styles — straight-line, declining balance, and a rate table:</p>
|
||||||
|
<pre class="manual-code">{{ sampleRuleSet }}</pre>
|
||||||
|
|
||||||
|
<v-alert type="warning" variant="tonal" density="comfortable" class="manual-callout">
|
||||||
|
Rule sets ship as starter templates. Always confirm current tax law, rates, and elections before relying on a set for a filing.
|
||||||
|
</v-alert>
|
||||||
|
<p>To put a set to work, open <strong>Financial → Books</strong> and assign it to a book.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- TEMPLATES -->
|
||||||
|
<section v-show="isShown('templates')" class="manual-section">
|
||||||
|
<h2>Templates</h2>
|
||||||
|
<p>
|
||||||
|
Templates speed up asset entry and standardize data. A template defines default values (category, useful life,
|
||||||
|
depreciation method, GL accounts, business-use %, etc.) and a set of <strong>custom fields</strong> (text, number, date, or
|
||||||
|
checkbox) that should be captured for that kind of asset.
|
||||||
|
</p>
|
||||||
|
<h3>Creating a template</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Open <strong>Templates</strong> and give the template a name and description.</li>
|
||||||
|
<li>Set the default values new assets should start with.</li>
|
||||||
|
<li>Add custom fields, marking any that are required.</li>
|
||||||
|
<li>Save. The template is now selectable from the <strong>Template</strong> picker when creating an asset.</li>
|
||||||
|
</ol>
|
||||||
|
<p>Choosing a template on a new asset fills the defaults and adds its custom fields to the Details tab.</p>
|
||||||
|
|
||||||
|
<h3>Editing & deleting templates</h3>
|
||||||
|
<p>
|
||||||
|
The templates list (right) is searchable and paginated, and shows how many fields each template has and how many
|
||||||
|
assets currently use it. Use the <strong>pencil</strong> to load a template back into the editor and save your changes,
|
||||||
|
or the <strong>trash</strong> to delete it.
|
||||||
|
</p>
|
||||||
|
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||||
|
Deleting a template is safe for your data: assets that used it <strong>keep all their values</strong> and are simply
|
||||||
|
unlinked from the template. The only other effect is that the template is no longer offered when creating new assets.
|
||||||
|
The confirmation dialog tells you how many assets are affected before you proceed.
|
||||||
|
</v-alert>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- PM -->
|
||||||
|
<section v-show="isShown('pm')" class="manual-section">
|
||||||
|
<h2>Preventative maintenance</h2>
|
||||||
|
<p>
|
||||||
|
The <strong>Maintenance</strong> screen manages reusable <strong>PM plans</strong> — standard procedures performed on a
|
||||||
|
schedule — which you then attach to specific assets and complete over time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Creating a PM plan</h3>
|
||||||
|
<ol>
|
||||||
|
<li>On <strong>Maintenance</strong>, click <strong>New plan</strong>.</li>
|
||||||
|
<li>Name the plan and set its <strong>frequency</strong> (every N days, weeks, months, quarters, bi-yearly, or years).</li>
|
||||||
|
<li>Add ordered <strong>steps</strong> (the checklist a technician follows), each with an optional time estimate — the plan’s total estimated time totals automatically.</li>
|
||||||
|
<li>Add <strong>components / parts</strong> (part number, description, supplier, cost) to capture expected materials and cost.</li>
|
||||||
|
<li>Add <strong>guidance photos</strong> (diagrams, part locations, before/after) with captions — these are shown to the technician while completing the service.</li>
|
||||||
|
<li>Save the plan.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Attaching a plan to an asset</h3>
|
||||||
|
<p>
|
||||||
|
Open the asset, go to the <strong>PM</strong> tab, choose a plan, and set the interval, first-due date, and an end date
|
||||||
|
(it pre-fills to the asset’s depreciation end). The schedule now tracks the next due date and raises alerts as it approaches or passes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Completing (closing) a service</h3>
|
||||||
|
<ol>
|
||||||
|
<li>On the asset’s <strong>PM</strong> tab, click <strong>Complete</strong> on an active schedule.</li>
|
||||||
|
<li>The form records who is closing it (you), shows any <strong>guidance photos</strong>, and lets you tick off each step.</li>
|
||||||
|
<li>Give 1–5★ <strong>condition ratings</strong>: Safety (is it safe?), Physical condition, and Operating condition.</li>
|
||||||
|
<li>Add <strong>completion notes</strong> and <strong>photos</strong> of the work, confirm the parts cost, and capture a <strong>signature</strong> (required).</li>
|
||||||
|
<li>Click <strong>Mark complete</strong>. The next-due date rolls forward automatically (the schedule closes if it has passed its end date), and the cost is recorded to the PM book.</li>
|
||||||
|
</ol>
|
||||||
|
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||||
|
Completed forms — with signature, ratings, notes, and photos — are viewable from both the asset’s PM tab and the
|
||||||
|
<strong>Completed PM forms</strong> list on the Maintenance screen.
|
||||||
|
</v-alert>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- PARTS -->
|
||||||
|
<section v-show="isShown('parts')" class="manual-section">
|
||||||
|
<h2>Parts inventory</h2>
|
||||||
|
<p>
|
||||||
|
<strong>Parts inventory</strong> (under Maintenance) is a standalone stockroom for maintenance parts and supplies. It is
|
||||||
|
deliberately separate from the asset register — parts are consumables, not depreciating assets.
|
||||||
|
</p>
|
||||||
|
<h3>Adding a part</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Open <strong>Maintenance → Parts inventory</strong> and click <strong>New part</strong>.</li>
|
||||||
|
<li>Enter the part number, name, category, unit of measure, unit cost, and a reorder point (used for low-stock flagging).</li>
|
||||||
|
<li>Add identification details — measurements, manufacturer part number, barcode, and photos.</li>
|
||||||
|
<li>Pick a <strong>supplier</strong> from your Contacts (vendors, service providers, or contractors).</li>
|
||||||
|
<li>Save, then add stock locations and movements.</li>
|
||||||
|
</ol>
|
||||||
|
<h3>Stock, locations & reservations</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Store a part in one or more <strong>locations</strong> (chosen from your Work Location contacts), each with an <strong>aisle</strong> and <strong>bin</strong>.</li>
|
||||||
|
<li>Each location tracks quantity on hand and how much is <strong>reserved</strong>; available = on hand − reserved.</li>
|
||||||
|
<li>Record <strong>movements</strong>: receive (add stock), issue (consume), adjust (set an absolute count), reserve, and unreserve. Every movement is logged with history.</li>
|
||||||
|
<li>Parts at or below their reorder point are flagged <strong>low stock</strong>; use the low-stock filter to see what to reorder.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CONTACTS -->
|
||||||
|
<section v-show="isShown('contacts')" class="manual-section">
|
||||||
|
<h2>Contacts & CRM</h2>
|
||||||
|
<p>
|
||||||
|
<strong>Contacts</strong> is a directory of the people and organizations you work with: <em>employees, vendors, service
|
||||||
|
providers, contractors, work locations,</em> and <em>other</em>. The form adapts to the type you choose.
|
||||||
|
</p>
|
||||||
|
<h3>Managing contacts</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Use the type selector and filter box to find records; create a contact with <strong>New contact</strong>.</li>
|
||||||
|
<li>Capture name/organization, international-friendly address and phone, and email.</li>
|
||||||
|
<li><strong>Type-specific fields</strong>: employee ID/department/manager/start date; contractor tax ID or business license; vendor and service-provider star rating and credit term.</li>
|
||||||
|
<li>Keep a per-contact <strong>notes log</strong> for running history.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>How contacts connect to the rest of the app</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Work location</strong> contacts populate the <strong>Location</strong> dropdown on the asset form and are the locations where parts stock is held.</li>
|
||||||
|
<li><strong>Vendor / service-provider / contractor</strong> contacts populate the asset form’s <strong>Vendor</strong> dropdown and are selectable as the <strong>supplier</strong> on parts.</li>
|
||||||
|
<li>Employee records can be brought in automatically from Workday when that connector is configured (see <a href="#" @click.prevent="select('admin')">Administration</a>).</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ASSIGNMENTS -->
|
||||||
|
<section v-show="isShown('assignments')" class="manual-section">
|
||||||
|
<h2>Assignments (custody)</h2>
|
||||||
|
<p>
|
||||||
|
<strong>Assignments</strong> tracks who has custody of which asset. Assign an asset to an employee, department, or location,
|
||||||
|
and the full custody history is retained so you always know where an asset is and who is responsible for it.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>The employee list is filterable and paginated.</li>
|
||||||
|
<li>Assign an asset, then <strong>release</strong> it (with a reason) when custody changes — both events are kept in the traceability history.</li>
|
||||||
|
<li>Employees can be added here directly, or synced from Workday.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ALERTS -->
|
||||||
|
<section v-show="isShown('alerts')" class="manual-section">
|
||||||
|
<h2>Alerts & reminders</h2>
|
||||||
|
<p>
|
||||||
|
The <strong>Alerts</strong> screen surfaces everything that needs attention: overdue and upcoming tasks, preventative
|
||||||
|
maintenance due/overdue, and warranties or leases about to expire. Each alert has a <strong>severity</strong> — <em>critical</em>
|
||||||
|
(overdue/expired, or within seven days) or <em>warning</em>.
|
||||||
|
</p>
|
||||||
|
<h3>Working alerts</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Run scan</strong> recalculates the current alerts from your data.</li>
|
||||||
|
<li><strong>Acknowledge</strong> an alert to mark it in-progress; <strong>Dismiss</strong> one to stop it reappearing.</li>
|
||||||
|
<li>Filter by status and type, and use the summary tiles for a quick count of open and critical items.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Delivery channels</h3>
|
||||||
|
<p>Beyond the on-screen list, alerts can be pushed outward (all configured in <a href="#" @click.prevent="select('admin')">Administration</a>):</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Email digest</strong> — when email is enabled and a mail server is configured, new alerts are emailed to the recipient list. You can also trigger <strong>Email digest now</strong> from the Alerts screen.</li>
|
||||||
|
<li><strong>Webhook</strong> — each new alert can be posted to an external URL as a JSON object, optionally signed for authenticity. Useful for chat tools or custom automation.</li>
|
||||||
|
<li><strong>ServiceNow incident</strong> — raise a ticket from any alert (see below).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Forwarding an alert to ServiceNow</h3>
|
||||||
|
<p>
|
||||||
|
When the ServiceNow connection is configured, each open alert shows a <strong>ServiceNow</strong> action. Click it to create
|
||||||
|
an incident; the alert’s <strong>Ticket</strong> column then shows the incident number as a link straight into ServiceNow.
|
||||||
|
Re-clicking returns the existing ticket rather than creating a duplicate. With <em>auto-create</em> enabled, the system raises
|
||||||
|
an incident automatically for each new <em>critical</em> alert. Alert severity maps to the incident’s impact/urgency.
|
||||||
|
See <a href="#" @click.prevent="select('servicenow')">ServiceNow integration</a> for setup.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SERVICENOW -->
|
||||||
|
<section v-show="isShown('servicenow')" class="manual-section">
|
||||||
|
<h2>ServiceNow integration</h2>
|
||||||
|
<p>
|
||||||
|
MixedAssets connects to ServiceNow in two directions, both configured in <strong>Administration → ServiceNow integration</strong>:
|
||||||
|
pushing <strong>incidents</strong> out, and pulling <strong>asset data</strong> in from the CMDB.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Connecting</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Enter your ServiceNow <strong>instance URL</strong> (e.g. <em>https://yourcompany.service-now.com</em>) and the username/password of an integration user.</li>
|
||||||
|
<li>Click <strong>Test connection</strong> to confirm the credentials work.</li>
|
||||||
|
<li>Save. The stored password is never displayed back; leave it blank to keep the existing value, or use <strong>Clear password</strong> to remove it.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Pushing incidents (tickets)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Turn on <strong>Allow submitting alerts as incidents</strong> to enable ticket creation.</li>
|
||||||
|
<li>Optionally turn on <strong>Auto-create incidents for new critical alerts</strong> so critical items raise tickets automatically.</li>
|
||||||
|
<li>Set the incident table (usually <em>incident</em>) and, if you wish, a default assignment group and caller.</li>
|
||||||
|
<li>Tickets are then raised from the <strong>Alerts</strong> screen, with the incident number linked back to ServiceNow.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Pulling assets from the CMDB</h3>
|
||||||
|
<p>This populates your asset register from ServiceNow configuration items (CIs):</p>
|
||||||
|
<ol>
|
||||||
|
<li>Set the <strong>CMDB CI table</strong> (for example <em>cmdb_ci_hardware</em>) and, optionally, a filter to limit which CIs are pulled and a maximum number per sync.</li>
|
||||||
|
<li>Review the <strong>field mapping</strong> — which CI fields populate which asset fields. Sensible hardware defaults are provided; adjust as needed.</li>
|
||||||
|
<li>Click <strong>Sync CMDB now</strong>. The last sync time and a result summary are shown.</li>
|
||||||
|
</ol>
|
||||||
|
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||||
|
Re-syncing is safe: existing assets are matched (by their ServiceNow identifier, then serial number, then asset tag) and
|
||||||
|
<em>updated in place</em> rather than duplicated. CMDB-sourced assets receive default depreciation books like any other asset,
|
||||||
|
which you can then refine on the Books tab.
|
||||||
|
</v-alert>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ADMIN -->
|
||||||
|
<section v-show="isShown('admin')" class="manual-section">
|
||||||
|
<h2>Administration</h2>
|
||||||
|
<p>
|
||||||
|
<strong>Admin</strong> (admin role) is where the application is configured. It is split into three sub-screens in the
|
||||||
|
navigation rail: <strong>Users & Teams</strong>, <strong>Application Configuration</strong> (application settings, asset classes, depreciation zones, asset ID templates, asset categories,
|
||||||
|
asset departments, PM categories, parts categories, maintenance defaults, tax rule sets), and <strong>Integrations</strong> (email, webhook, ServiceNow, Workday). Each is described below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Users & roles</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Create and edit users — name, email, team, role, status, and password.</li>
|
||||||
|
<li>The list paginates and is filterable. A user’s role controls what they can see and do.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Teams</h3>
|
||||||
|
<p>Group users into teams. Select a team to edit or delete it (a team in use by members can’t be deleted until they’re reassigned).</p>
|
||||||
|
|
||||||
|
<h3>Roles</h3>
|
||||||
|
<p>Create and edit roles and the capabilities they grant — including custom roles. See <a href="#" @click.prevent="select('roles')">Roles & permissions</a>.</p>
|
||||||
|
|
||||||
|
<h3>Application settings</h3>
|
||||||
|
<p>Server name, allowed cross-origin domains, and the database driver/path (read-only) used by this deployment.</p>
|
||||||
|
|
||||||
|
<h3>Asset ID templates</h3>
|
||||||
|
<p>
|
||||||
|
Define naming patterns that auto-assign asset tags. Put <strong>#</strong> characters where the running number goes —
|
||||||
|
it’s zero-padded to that width and increments automatically. For example <code>DT-######</code> produces DT-000001,
|
||||||
|
DT-000002, … and <code>T-A#####</code> produces T-A00001. Each template has a “next number” you can set (e.g. to
|
||||||
|
continue an existing sequence), and a live preview of the next tag.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Assign a template to a category in <strong>Asset categories</strong> (below). Then, when someone creates an asset and
|
||||||
|
picks that category, the <strong>Asset ID is filled in automatically</strong> from the template — they can still type
|
||||||
|
their own ID to override it. Categories with no template use the default <code>MA-#####</code> sequence. Deleting a
|
||||||
|
template never renames existing assets; it just unassigns the affected categories.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Asset classes</h3>
|
||||||
|
<p>
|
||||||
|
A managed catalog of IRS asset classes (class number, GDS/ADS recovery periods), pre-loaded from the IRS tables —
|
||||||
|
commonly-used assets, manufacturing industry, and business activity. <strong>Create, edit, and delete</strong> entries here;
|
||||||
|
filter by source or search by description/class number. These power the “Look up IRS asset class” search when you set a
|
||||||
|
category’s class number. Deleting a class never changes assets or categories — it only removes it from the lookup.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Asset categories</h3>
|
||||||
|
<p>
|
||||||
|
Create, rename, and delete the categories offered in the asset form’s <strong>Category</strong> dropdown — so users
|
||||||
|
choose from a controlled list instead of typing free text. The list is searchable and paginated and shows how many
|
||||||
|
assets use each category, plus the <strong>ID template</strong> assigned to it (chosen when you create or edit a category).
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Renaming</strong> a category automatically updates every asset that uses it, so it’s also the way to merge two categories (rename one to match the other).</li>
|
||||||
|
<li><strong>Deleting</strong> a category leaves existing assets’ values untouched; the category simply stops appearing in the dropdown. The confirmation tells you how many assets are affected first.</li>
|
||||||
|
<li><strong>Asset class number</strong> — an optional number on the category that is <strong>shared by every asset in it</strong>. New assets inherit it automatically, and changing it cascades to all existing assets in the category. It appears (read-only) on each asset’s Details tab. Use the built-in <strong>“Look up IRS asset class”</strong> search to pick from the IRS tables — <em>commonly-used assets</em>, <em>manufacturing industry</em>, and <em>business activity</em>; each result is tagged Common, Manufacturing, or Business and shows its GDS/ADS recovery periods. You can also type a custom number.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Asset departments</h3>
|
||||||
|
<p>
|
||||||
|
Works exactly like Asset categories, but for the asset form’s <strong>Department</strong> dropdown — create, rename
|
||||||
|
(cascades to assets, and merges by renaming to match), and delete (assets keep their value; it just leaves the dropdown).
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>PM categories</h3>
|
||||||
|
<p>
|
||||||
|
The same management, but for the <strong>Category</strong> dropdown on preventative-maintenance plans — and kept
|
||||||
|
<strong>separate from asset categories</strong>. Renaming cascades to PM plans; deleting leaves existing plans’ values
|
||||||
|
and removes the option from the dropdown.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Parts categories</h3>
|
||||||
|
<p>
|
||||||
|
The same management again, for the <strong>Category</strong> dropdown on parts inventory — separate from asset and PM
|
||||||
|
categories, and pre-loaded with a starter set (Electrical, Plumbing, Structural Component, Electronic Component, Fire
|
||||||
|
Safety, Mechanical Safety, Other). Renaming cascades to parts; deleting leaves existing parts’ values.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Email & alerts (SMTP)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Turn email alerts on/off, set how many days ahead to warn, and the recipient list.</li>
|
||||||
|
<li>Configure the mail server (host, port, security, username, from-address). The password is write-only — blank keeps the stored value.</li>
|
||||||
|
<li>Use <strong>Send test</strong> to verify delivery.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Alert webhook</h3>
|
||||||
|
<p>
|
||||||
|
Enable webhook delivery and set the destination URL to have each new alert posted as a JSON object. An optional signing
|
||||||
|
secret adds a verification signature to each request. <strong>Send test event</strong> confirms the endpoint works, and a sample
|
||||||
|
payload is shown for whoever builds the receiving side.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>ServiceNow integration</h3>
|
||||||
|
<p>Connection, incident push, and CMDB pull settings — covered in detail under <a href="#" @click.prevent="select('servicenow')">ServiceNow integration</a>.</p>
|
||||||
|
|
||||||
|
<h3>Maintenance (PM) defaults</h3>
|
||||||
|
<p>Set the default maintenance frequency for newly attached plans and how many days ahead PM-due alerts should appear.</p>
|
||||||
|
|
||||||
|
<h3>Workday connector</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Configure the connection to your Workday tenant to sync employees, departments, and locations.</li>
|
||||||
|
<li>Run a manual <strong>Sync workers</strong>, or enable <strong>automatic scheduled sync</strong> and set the interval in hours.</li>
|
||||||
|
<li>Synced employees populate both the assignments employee list and the Contacts directory (with manager and start-date enrichment).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Tax rule sets</h3>
|
||||||
|
<p>A quick reference list of the rule sets in the system; manage them fully on the <a href="#" @click.prevent="select('taxrules')">Tax rules</a> screen.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ROLES -->
|
||||||
|
<section v-show="isShown('roles')" class="manual-section">
|
||||||
|
<h2>Roles & permissions</h2>
|
||||||
|
<p>
|
||||||
|
Every user has one role, and a role is a set of <strong>capabilities</strong> (granular permissions such as “Create & edit
|
||||||
|
assets” or “Manage integrations”). Capabilities decide which screens appear and which actions are allowed. Roles are managed
|
||||||
|
in <strong>Admin → Users & Teams → Roles</strong>, where you can edit the built-in roles or create your own.
|
||||||
|
</p>
|
||||||
|
<h3>Built-in roles</h3>
|
||||||
|
<table class="manual-table">
|
||||||
|
<thead><tr><th>Role</th><th>Typical use & access</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><strong>Administrator</strong></td><td>Full access (every capability), including users, roles, settings, and integrations. Always retains all capabilities.</td></tr>
|
||||||
|
<tr><td><strong>Finance</strong></td><td>Assets, books, reports, tax rules, maintenance, parts, contacts, assignments, alerts — everything except system administration.</td></tr>
|
||||||
|
<tr><td><strong>Operations</strong></td><td>Day-to-day asset, maintenance, parts, contacts, scanning, assignments, and alert work. No financial or system configuration.</td></tr>
|
||||||
|
<tr><td><strong>PM Admin</strong></td><td>Owns preventative maintenance: create/edit PM plans & defaults, complete services, manage parts, and work alerts.</td></tr>
|
||||||
|
<tr><td><strong>PM User</strong></td><td>Completes preventative maintenance and views assets, parts, and alerts — but can’t change PM plans.</td></tr>
|
||||||
|
<tr><td><strong>Viewer</strong></td><td>Read-only access to dashboards, assets, maintenance, and reports.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Custom roles (role editor)</h3>
|
||||||
|
<p>In <strong>Admin → Users & Teams</strong>, the <strong>Roles</strong> panel lets you tailor access:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>New role</strong> — name it, then tick the capabilities it should grant (grouped by area: Assets, Maintenance, Finance, etc.).</li>
|
||||||
|
<li><strong>Edit</strong> any role to change its name, description, or capabilities. Changes take effect the next time affected users sign in. The Administrator role can’t be restricted.</li>
|
||||||
|
<li><strong>Delete</strong> a custom role once no users are assigned to it. Built-in roles can’t be deleted.</li>
|
||||||
|
<li>Assign a role to a user in the user’s edit dialog; the role list there always reflects your current roles.</li>
|
||||||
|
</ul>
|
||||||
|
<v-alert type="info" variant="tonal" density="comfortable" class="manual-callout">
|
||||||
|
If you expect a screen or button that isn’t there, your role likely doesn’t include the capability for it — an administrator
|
||||||
|
can grant it on the Roles panel or move you to a different role.
|
||||||
|
</v-alert>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="manual-footer quiet">MixedAssets User Guide · select a topic on the left, or use Print / Save PDF for the full manual.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
modelValue: { type: Boolean, default: false }
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
active: 'overview',
|
||||||
|
search: '',
|
||||||
|
printing: false,
|
||||||
|
sampleRuleSet: [
|
||||||
|
'{',
|
||||||
|
' "schema": "mixedassets.taxRuleSet.v1",',
|
||||||
|
' "jurisdiction": "US-CA",',
|
||||||
|
' "name": "California depreciation",',
|
||||||
|
' "version": "2026.1",',
|
||||||
|
' "effectiveDate": "2026-01-01",',
|
||||||
|
' "sourceNote": "Confirm against current state law before filing.",',
|
||||||
|
' "books": ["GAAP", "STATE"],',
|
||||||
|
' "conventions": ["half_year", "mid_quarter", "mid_month", "none"],',
|
||||||
|
' "limits": {',
|
||||||
|
' "section179": {',
|
||||||
|
' "deductionLimit": 25000,',
|
||||||
|
' "phaseOutBegins": 200000,',
|
||||||
|
' "cannotCreateLoss": true',
|
||||||
|
' },',
|
||||||
|
' "bonusDepreciation": {',
|
||||||
|
' "qualifiedPropertyPercent": 0',
|
||||||
|
' }',
|
||||||
|
' },',
|
||||||
|
' "assetLives": [',
|
||||||
|
' { "code": "5Y", "label": "5-year property", "months": 60 },',
|
||||||
|
' { "code": "7Y", "label": "7-year property", "months": 84 }',
|
||||||
|
' ],',
|
||||||
|
' "methods": [',
|
||||||
|
' {',
|
||||||
|
' "code": "straight_line",',
|
||||||
|
' "family": "GAAP",',
|
||||||
|
' "label": "Straight-line",',
|
||||||
|
' "formula": "straight_line",',
|
||||||
|
' "lifeMonths": 60,',
|
||||||
|
' "defaultConvention": "half_year"',
|
||||||
|
' },',
|
||||||
|
' {',
|
||||||
|
' "code": "state_200db_5",',
|
||||||
|
' "family": "MACRS",',
|
||||||
|
' "label": "200% declining balance, 5-year",',
|
||||||
|
' "formula": "macrs_declining_balance",',
|
||||||
|
' "lifeMonths": 60,',
|
||||||
|
' "rateMultiplier": 2,',
|
||||||
|
' "switchToStraightLine": true,',
|
||||||
|
' "defaultConvention": "half_year"',
|
||||||
|
' },',
|
||||||
|
' {',
|
||||||
|
' "code": "acrs_5yr_table",',
|
||||||
|
' "family": "ACRS",',
|
||||||
|
' "label": "ACRS 5-year (statutory rates)",',
|
||||||
|
' "formula": "rate_table",',
|
||||||
|
' "lifeMonths": 60,',
|
||||||
|
' "rates": [0.15, 0.22, 0.21, 0.21, 0.21]',
|
||||||
|
' }',
|
||||||
|
' ]',
|
||||||
|
'}'
|
||||||
|
].join('\n'),
|
||||||
|
sections: [
|
||||||
|
{ id: 'overview', title: 'Getting started', icon: 'mdi-rocket-launch-outline', keywords: 'intro begin login sign in welcome' },
|
||||||
|
{ id: 'navigation', title: 'Interface & navigation', icon: 'mdi-view-dashboard-outline', keywords: 'menu rail theme dark light table sort filter top bar' },
|
||||||
|
{ id: 'dashboard', title: 'Dashboard', icon: 'mdi-chart-box-outline', keywords: 'home summary chart category value' },
|
||||||
|
{ id: 'assets', title: 'Assets — creating & managing', icon: 'mdi-archive-search-outline', keywords: 'asset register create edit import export mass edit workbench files notes tasks warranty' },
|
||||||
|
{ id: 'lifecycle', title: 'Asset lifecycle', icon: 'mdi-cash-remove', keywords: 'dispose disposal sale retire gain loss lease amortization impairment adjustment write down' },
|
||||||
|
{ id: 'barcodes', title: 'Barcodes & scanning', icon: 'mdi-barcode-scan', keywords: 'barcode label print scan camera tag' },
|
||||||
|
{ id: 'books', title: 'Books, depreciation & tax', icon: 'mdi-book-open-variant', keywords: 'book gaap federal depreciation general ledger gl method convention 179 bonus reports' },
|
||||||
|
{ id: 'taxrules', title: 'Tax rule sets', icon: 'mdi-scale-balance', keywords: 'tax rules jurisdiction method limits activate version' },
|
||||||
|
{ id: 'templates', title: 'Templates', icon: 'mdi-form-select', keywords: 'template defaults custom fields data entry' },
|
||||||
|
{ id: 'pm', title: 'Preventative maintenance', icon: 'mdi-wrench-clock', keywords: 'pm maintenance plan steps complete close signature ratings guidance photos schedule' },
|
||||||
|
{ id: 'parts', title: 'Parts inventory', icon: 'mdi-package-variant-closed', keywords: 'parts stock aisle bin reserve reorder supplier inventory' },
|
||||||
|
{ id: 'contacts', title: 'Contacts & CRM', icon: 'mdi-card-account-details-outline', keywords: 'contacts crm vendor employee contractor work location supplier notes' },
|
||||||
|
{ id: 'assignments', title: 'Assignments (custody)', icon: 'mdi-account-switch-outline', keywords: 'assignment custody employee release traceability' },
|
||||||
|
{ id: 'alerts', title: 'Alerts & reminders', icon: 'mdi-bell-alert-outline', keywords: 'alerts reminders severity scan acknowledge dismiss email digest webhook servicenow' },
|
||||||
|
{ id: 'servicenow', title: 'ServiceNow integration', icon: 'mdi-cloud-sync-outline', keywords: 'servicenow incident ticket cmdb import sync connection' },
|
||||||
|
{ id: 'admin', title: 'Administration', icon: 'mdi-cog-outline', keywords: 'admin users roles teams settings smtp email webhook workday configuration' },
|
||||||
|
{ id: 'roles', title: 'Roles & permissions', icon: 'mdi-shield-account-outline', keywords: 'roles permissions admin finance operations viewer access' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
filteredSections() {
|
||||||
|
const needle = String(this.search || '').trim().toLowerCase();
|
||||||
|
if (!needle) return this.sections;
|
||||||
|
return this.sections.filter((s) =>
|
||||||
|
s.title.toLowerCase().includes(needle) || s.keywords.includes(needle) || s.keywords.split(' ').some((k) => k.startsWith(needle))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
modelValue(open) {
|
||||||
|
if (open) {
|
||||||
|
this.search = '';
|
||||||
|
this.printing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
isShown(id) {
|
||||||
|
return this.printing || this.active === id;
|
||||||
|
},
|
||||||
|
select(id) {
|
||||||
|
this.active = id;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.$refs.content) this.$refs.content.scrollTop = 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
print() {
|
||||||
|
this.printing = true;
|
||||||
|
this.$nextTick(() => {
|
||||||
|
window.print();
|
||||||
|
this.printing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.manual-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.user-manual-nav {
|
||||||
|
flex: 0 0 264px;
|
||||||
|
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.02);
|
||||||
|
}
|
||||||
|
.manual-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 28px 36px;
|
||||||
|
}
|
||||||
|
.manual-section {
|
||||||
|
max-width: 820px;
|
||||||
|
}
|
||||||
|
.manual-section h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 2px solid rgba(var(--v-theme-primary), 0.4);
|
||||||
|
}
|
||||||
|
.manual-section h3 {
|
||||||
|
font-size: 1.08rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 22px 0 8px;
|
||||||
|
}
|
||||||
|
.manual-section p {
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.manual-section ul,
|
||||||
|
.manual-section ol {
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
.manual-section li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.manual-section a {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.manual-callout {
|
||||||
|
margin: 14px 0 18px;
|
||||||
|
}
|
||||||
|
.manual-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 6px 0 16px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.manual-table th,
|
||||||
|
.manual-table td {
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
}
|
||||||
|
.manual-table th {
|
||||||
|
font-weight: 700;
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||||
|
}
|
||||||
|
.manual-section :deep(code),
|
||||||
|
.manual-section code {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
}
|
||||||
|
.manual-code {
|
||||||
|
margin: 6px 0 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.manual-doc-title h1 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.manual-footer {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
162
src/components/WebhookSettings.vue
Normal file
162
src/components/WebhookSettings.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<h2 class="section-title mb-1">Alert webhook</h2>
|
||||||
|
<p class="quiet text-body-2 mb-3">
|
||||||
|
Send every newly raised alert to an external HTTP endpoint as a JSON object (one POST per alert).
|
||||||
|
Delivery runs on the alert cycle alongside (or instead of) email. Leave the secret blank to keep the stored value.
|
||||||
|
</p>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-switch v-model="form.webhook_enabled" label="Enable webhook delivery" color="primary" density="compact" hide-details />
|
||||||
|
<div />
|
||||||
|
<v-text-field v-model="form.webhook_url" class="full" label="Webhook URL" placeholder="https://example.com/hooks/mixedassets" />
|
||||||
|
<v-text-field
|
||||||
|
v-model="form.webhook_secret"
|
||||||
|
type="password"
|
||||||
|
:label="settings.webhook_secret_set ? 'Signing secret (unchanged)' : 'Signing secret (optional)'"
|
||||||
|
hint="If set, each request is signed with HMAC-SHA256 in the X-MixedAssets-Signature header"
|
||||||
|
persistent-hint
|
||||||
|
autocomplete="new-password"
|
||||||
|
/>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-btn
|
||||||
|
v-if="settings.webhook_secret_set"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
prepend-icon="mdi-key-remove"
|
||||||
|
@click="clearSecret"
|
||||||
|
>Clear secret</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-row mt-3">
|
||||||
|
<v-btn variant="tonal" prepend-icon="mdi-webhook" :loading="testing" :disabled="!form.webhook_url" @click="sendTest">Send test event</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save webhook</v-btn>
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="status" class="mt-3" density="compact" :type="statusType">{{ status }}</v-alert>
|
||||||
|
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<div class="quiet text-caption mb-1">Example payload delivered per alert:</div>
|
||||||
|
<pre class="payload-sample">{{ samplePayload }}</pre>
|
||||||
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
settings: {},
|
||||||
|
form: {
|
||||||
|
webhook_enabled: false,
|
||||||
|
webhook_url: '',
|
||||||
|
webhook_secret: ''
|
||||||
|
},
|
||||||
|
saving: false,
|
||||||
|
testing: false,
|
||||||
|
status: '',
|
||||||
|
statusType: 'success',
|
||||||
|
samplePayload: JSON.stringify({
|
||||||
|
event: 'alert',
|
||||||
|
id: 142,
|
||||||
|
type: 'pm_overdue',
|
||||||
|
severity: 'critical',
|
||||||
|
title: 'PM overdue: Lift quarterly service',
|
||||||
|
message: 'Lift quarterly service on FA-1042 was due 2026-05-20.',
|
||||||
|
due_date: '2026-05-20',
|
||||||
|
status: 'open',
|
||||||
|
reference_type: 'asset_pm',
|
||||||
|
reference_id: 88,
|
||||||
|
asset_id: 1042,
|
||||||
|
asset_code: 'FA-1042',
|
||||||
|
created_at: '2026-06-04T12:00:00.000Z',
|
||||||
|
sent_at: '2026-06-04T12:05:00.000Z'
|
||||||
|
}, null, 2)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
const data = await (await this.api('/api/notifications/settings')).json();
|
||||||
|
this.settings = data.settings;
|
||||||
|
this.form = {
|
||||||
|
webhook_enabled: data.settings.webhook_enabled,
|
||||||
|
webhook_url: data.settings.webhook_url,
|
||||||
|
webhook_secret: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
this.saving = true;
|
||||||
|
this.status = '';
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
webhook_enabled: this.form.webhook_enabled,
|
||||||
|
webhook_url: this.form.webhook_url
|
||||||
|
};
|
||||||
|
if (this.form.webhook_secret) payload.webhook_secret = this.form.webhook_secret;
|
||||||
|
const data = await (await this.api('/api/notifications/settings', { method: 'PUT', body: JSON.stringify(payload) })).json();
|
||||||
|
this.settings = data.settings;
|
||||||
|
this.form.webhook_secret = '';
|
||||||
|
this.statusType = 'success';
|
||||||
|
this.status = 'Webhook settings saved.';
|
||||||
|
} catch (error) {
|
||||||
|
this.statusType = 'error';
|
||||||
|
this.status = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async clearSecret() {
|
||||||
|
this.status = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/notifications/settings', { method: 'PUT', body: JSON.stringify({ webhook_secret_clear: true }) })).json();
|
||||||
|
this.settings = data.settings;
|
||||||
|
this.form.webhook_secret = '';
|
||||||
|
this.statusType = 'success';
|
||||||
|
this.status = 'Signing secret cleared.';
|
||||||
|
} catch (error) {
|
||||||
|
this.statusType = 'error';
|
||||||
|
this.status = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async sendTest() {
|
||||||
|
this.testing = true;
|
||||||
|
this.status = '';
|
||||||
|
try {
|
||||||
|
// Persist the current URL/secret first so the test hits what the user typed.
|
||||||
|
await this.save();
|
||||||
|
const result = await (await this.api('/api/notifications/webhook-test', { method: 'POST', body: JSON.stringify({}) })).json();
|
||||||
|
this.statusType = 'success';
|
||||||
|
this.status = `Test event delivered (HTTP ${result.status}).`;
|
||||||
|
} catch (error) {
|
||||||
|
this.statusType = 'error';
|
||||||
|
this.status = `Test failed: ${error.message}`;
|
||||||
|
} finally {
|
||||||
|
this.testing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.payload-sample {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(127, 127, 127, 0.1);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
148
src/constants.js
148
src/constants.js
@@ -19,6 +19,78 @@ export const taskTypeItems = ['maintenance', 'inspection', 'calibration', 'renew
|
|||||||
|
|
||||||
export const conditionItems = ['excellent', 'good', 'fair', 'poor', 'out_of_service'];
|
export const conditionItems = ['excellent', 'good', 'fair', 'poor', 'out_of_service'];
|
||||||
|
|
||||||
|
export const disposalMethodItems = [
|
||||||
|
{ title: 'Sale', value: 'sale' },
|
||||||
|
{ title: 'Exchange', value: 'exchange' },
|
||||||
|
{ title: 'Involuntary conversion', value: 'involuntary_conversion' },
|
||||||
|
{ title: 'Like-kind exchange', value: 'like_kind' },
|
||||||
|
{ title: 'Abandonment', value: 'abandonment' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const propertyTypeItems = [
|
||||||
|
{ title: '§1245 tangible personal property', value: '1245' },
|
||||||
|
{ title: '§1250 real property', value: '1250' },
|
||||||
|
{ title: 'Other', value: 'other' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const adjustmentTypeItems = [
|
||||||
|
{ title: 'Impairment', value: 'impairment' },
|
||||||
|
{ title: 'Write-down', value: 'write_down' },
|
||||||
|
{ title: 'Write-up', value: 'write_up' },
|
||||||
|
{ title: 'Revaluation', value: 'revaluation' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const contactTypeItems = [
|
||||||
|
{ title: 'Employee', value: 'employee' },
|
||||||
|
{ title: 'Vendor', value: 'vendor' },
|
||||||
|
{ title: 'Service provider', value: 'service_provider' },
|
||||||
|
{ title: 'Contractor', value: 'contractor' },
|
||||||
|
{ title: 'Work location', value: 'work_location' },
|
||||||
|
{ title: 'Other', value: 'other' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const creditTermItems = [
|
||||||
|
{ title: 'N/A', value: 'na' },
|
||||||
|
{ title: 'Net 30 days', value: 'net_30' },
|
||||||
|
{ title: 'Net 60 days', value: 'net_60' },
|
||||||
|
{ title: 'Net 90 days', value: 'net_90' },
|
||||||
|
{ title: 'Net 180 days', value: 'net_180' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const pmFrequencyUnits = [
|
||||||
|
{ title: 'Days', value: 'days' },
|
||||||
|
{ title: 'Weeks', value: 'weeks' },
|
||||||
|
{ title: 'Months', value: 'months' },
|
||||||
|
{ title: 'Quarters', value: 'quarters' },
|
||||||
|
{ title: 'Bi-yearly (6 months)', value: 'semiannual' },
|
||||||
|
{ title: 'Years', value: 'years' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const partStatusItems = [
|
||||||
|
{ title: 'Active', value: 'active' },
|
||||||
|
{ title: 'Inactive', value: 'inactive' },
|
||||||
|
{ title: 'Discontinued', value: 'discontinued' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const unitOfMeasureItems = [
|
||||||
|
'each', 'box', 'case', 'pair', 'set', 'roll', 'foot', 'meter', 'liter', 'gallon', 'kg', 'lb'
|
||||||
|
];
|
||||||
|
|
||||||
|
export const partTransactionTypes = [
|
||||||
|
{ title: 'Receive (add stock)', value: 'receive' },
|
||||||
|
{ title: 'Issue (consume)', value: 'issue' },
|
||||||
|
{ title: 'Adjust (set on-hand count)', value: 'adjust' },
|
||||||
|
{ title: 'Reserve', value: 'reserve' },
|
||||||
|
{ title: 'Unreserve', value: 'unreserve' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const leaseFrequencyItems = [
|
||||||
|
{ title: 'Monthly', value: 'monthly' },
|
||||||
|
{ title: 'Quarterly', value: 'quarterly' },
|
||||||
|
{ title: 'Semiannual', value: 'semiannual' },
|
||||||
|
{ title: 'Annual', value: 'annual' }
|
||||||
|
];
|
||||||
|
|
||||||
export const customFieldTypes = [
|
export const customFieldTypes = [
|
||||||
{ title: 'Text', value: 'text' },
|
{ title: 'Text', value: 'text' },
|
||||||
{ title: 'Number', value: 'numeric' },
|
{ title: 'Number', value: 'numeric' },
|
||||||
@@ -27,15 +99,79 @@ export const customFieldTypes = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const themeItems = [
|
export const themeItems = [
|
||||||
{ title: 'Dark', value: 'mixedAssetsDark' },
|
{ title: 'Dark', value: 'mixedAssetsDark', props: { subtitle: 'Default dark' } },
|
||||||
{ title: 'Light', value: 'mixedAssets' }
|
{ title: 'Light', value: 'mixedAssets', props: { subtitle: 'Default light' } },
|
||||||
|
{ title: 'Ocean', value: 'mixedAssetsOcean', props: { subtitle: 'Dark · teal' } },
|
||||||
|
{ title: 'Slate', value: 'mixedAssetsSlate', props: { subtitle: 'Dark · neutral' } },
|
||||||
|
{ title: 'High contrast', value: 'mixedAssetsContrast', props: { subtitle: 'Dark · accessible' } },
|
||||||
|
{ title: 'Sepia', value: 'mixedAssetsSepia', props: { subtitle: 'Light · warm' } }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const darkThemes = ['mixedAssetsDark', 'mixedAssetsOcean', 'mixedAssetsSlate', 'mixedAssetsContrast'];
|
||||||
|
|
||||||
|
// Accessibility: user-adjustable base text size (scales the whole UI via the root font size).
|
||||||
|
export const fontSizeItems = [
|
||||||
|
{ title: 'Small', value: 'small', props: { subtitle: '88%' } },
|
||||||
|
{ title: 'Normal', value: 'normal', props: { subtitle: '100%' } },
|
||||||
|
{ title: 'Large', value: 'large', props: { subtitle: '113%' } },
|
||||||
|
{ title: 'Extra large', value: 'xlarge', props: { subtitle: '125%' } }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const fontScaleMap = {
|
||||||
|
small: '14px',
|
||||||
|
normal: '16px',
|
||||||
|
large: '18px',
|
||||||
|
xlarge: '20px'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Suggested accent colours; users may also enter any custom hex value.
|
||||||
|
export const accentPresets = [
|
||||||
|
'#2457a6', '#006c6a', '#7c3aed', '#c2185b',
|
||||||
|
'#b3261e', '#a56a00', '#2f7d4b', '#0277bd'
|
||||||
];
|
];
|
||||||
|
|
||||||
export const navItems = [
|
export const navItems = [
|
||||||
{ key: 'dashboard', label: 'Dashboard', icon: 'mdi-view-dashboard-outline' },
|
{ 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: 'assets',
|
||||||
|
label: 'Assets',
|
||||||
|
icon: 'mdi-archive-search-outline',
|
||||||
|
children: [
|
||||||
|
{ key: 'scan', label: 'Scan', icon: 'mdi-barcode-scan' },
|
||||||
|
{ key: 'assignments', label: 'Assignments', icon: 'mdi-account-switch-outline' },
|
||||||
|
{ key: 'templates', label: 'Templates', icon: 'mdi-form-select' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ key: 'alerts', label: 'Alerts', icon: 'mdi-bell-alert-outline' },
|
||||||
|
{
|
||||||
|
key: 'pm',
|
||||||
|
label: 'Maintenance',
|
||||||
|
icon: 'mdi-wrench-clock',
|
||||||
|
children: [
|
||||||
|
{ key: 'parts', label: 'Parts inventory', icon: 'mdi-package-variant-closed' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ key: 'contacts', label: 'Contacts', icon: 'mdi-card-account-details-outline' },
|
||||||
{ key: 'reports', label: 'Reports', icon: 'mdi-chart-bar' },
|
{ key: 'reports', label: 'Reports', icon: 'mdi-chart-bar' },
|
||||||
{ key: 'templates', label: 'Templates', icon: 'mdi-form-select' },
|
{
|
||||||
{ key: 'admin', label: 'Admin', icon: 'mdi-cog-outline' }
|
key: 'financial',
|
||||||
|
label: 'Financial',
|
||||||
|
icon: 'mdi-finance',
|
||||||
|
group: true,
|
||||||
|
children: [
|
||||||
|
{ key: 'books', label: 'Books', icon: 'mdi-book-open-variant' },
|
||||||
|
{ key: 'tax-rules', label: 'Tax rules', icon: 'mdi-scale-balance' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'admin',
|
||||||
|
label: 'Admin',
|
||||||
|
icon: 'mdi-cog-outline',
|
||||||
|
group: true,
|
||||||
|
children: [
|
||||||
|
{ key: 'admin-users', label: 'Users & Teams', icon: 'mdi-account-group-outline' },
|
||||||
|
{ key: 'admin-config', label: 'Application Configuration', icon: 'mdi-tune' },
|
||||||
|
{ key: 'admin-integrations', label: 'Integrations', icon: 'mdi-transit-connection-variant' }
|
||||||
|
]
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
56
src/main.js
56
src/main.js
@@ -43,6 +43,62 @@ const vuetify = createVuetify({
|
|||||||
warning: '#f0c06a',
|
warning: '#f0c06a',
|
||||||
error: '#ffb4ab'
|
error: '#ffb4ab'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
mixedAssetsOcean: {
|
||||||
|
dark: true,
|
||||||
|
colors: {
|
||||||
|
background: '#0b1620',
|
||||||
|
surface: '#10212f',
|
||||||
|
primary: '#4fd1c5',
|
||||||
|
secondary: '#6aa9ff',
|
||||||
|
accent: '#f6ad55',
|
||||||
|
info: '#7cc4f0',
|
||||||
|
success: '#7ee0a0',
|
||||||
|
warning: '#f6c453',
|
||||||
|
error: '#ff8f87'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mixedAssetsSlate: {
|
||||||
|
dark: true,
|
||||||
|
colors: {
|
||||||
|
background: '#17191e',
|
||||||
|
surface: '#21242b',
|
||||||
|
primary: '#aab4ff',
|
||||||
|
secondary: '#8fd3c8',
|
||||||
|
accent: '#d9b38c',
|
||||||
|
info: '#a9c4dd',
|
||||||
|
success: '#8fd7a3',
|
||||||
|
warning: '#e6c06a',
|
||||||
|
error: '#f2a59c'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mixedAssetsContrast: {
|
||||||
|
dark: true,
|
||||||
|
colors: {
|
||||||
|
background: '#000000',
|
||||||
|
surface: '#0d0d0d',
|
||||||
|
primary: '#ffd400',
|
||||||
|
secondary: '#00e5ff',
|
||||||
|
accent: '#ff4081',
|
||||||
|
info: '#40c4ff',
|
||||||
|
success: '#00e676',
|
||||||
|
warning: '#ffea00',
|
||||||
|
error: '#ff5252'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mixedAssetsSepia: {
|
||||||
|
dark: false,
|
||||||
|
colors: {
|
||||||
|
background: '#f3ead6',
|
||||||
|
surface: '#fbf6ea',
|
||||||
|
primary: '#9a5b2f',
|
||||||
|
secondary: '#2f6f6a',
|
||||||
|
accent: '#b3791f',
|
||||||
|
info: '#3f6f99',
|
||||||
|
success: '#2f7d4b',
|
||||||
|
warning: '#a56a00',
|
||||||
|
error: '#b3261e'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ export async function apiRequest(path, token, options = {}) {
|
|||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
|
// An invalid/expired token (401) means the session is no longer valid — signal the
|
||||||
|
// app to drop back to the login screen. (403 is a role/permission denial, not a session issue.)
|
||||||
|
if (response.status === 401) {
|
||||||
|
window.dispatchEvent(new CustomEvent('mixedassets:unauthorized'));
|
||||||
|
}
|
||||||
throw new Error(body.error || `Request failed: ${response.status}`);
|
throw new Error(body.error || `Request failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
|
|||||||
@@ -9,6 +9,88 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Accessibility ------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Keyboard focus indicator (visible only for keyboard users, not mouse clicks). */
|
||||||
|
a:focus-visible,
|
||||||
|
button:focus-visible,
|
||||||
|
[tabindex]:focus-visible,
|
||||||
|
.v-btn:focus-visible,
|
||||||
|
.v-field:focus-within {
|
||||||
|
outline: 3px solid rgb(var(--v-theme-info, 60 111 153));
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skip-to-content link: hidden until focused via Tab. */
|
||||||
|
.skip-link {
|
||||||
|
position: fixed;
|
||||||
|
top: 8px;
|
||||||
|
left: 8px;
|
||||||
|
z-index: 4000;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgb(var(--v-theme-primary));
|
||||||
|
color: rgb(var(--v-theme-on-primary));
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
transform: translateY(-150%);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.skip-link:focus {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen-reader-only utility. */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute !important;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect users who prefer reduced motion. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.001ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.001ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* High-contrast boost: stronger borders, bolder text, underlined links. Opt-in per user. */
|
||||||
|
body.ma-contrast-boost .v-card,
|
||||||
|
body.ma-contrast-boost .v-field__outline,
|
||||||
|
body.ma-contrast-boost .v-table,
|
||||||
|
body.ma-contrast-boost .asset-table th,
|
||||||
|
body.ma-contrast-boost .asset-table td,
|
||||||
|
body.ma-contrast-boost .manual-table th,
|
||||||
|
body.ma-contrast-boost .manual-table td {
|
||||||
|
border-color: rgb(var(--v-theme-on-surface)) !important;
|
||||||
|
}
|
||||||
|
body.ma-contrast-boost .v-card {
|
||||||
|
border: 1px solid rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
body.ma-contrast-boost .quiet,
|
||||||
|
body.ma-contrast-boost .text-caption,
|
||||||
|
body.ma-contrast-boost .v-list-item-subtitle {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
|
body.ma-contrast-boost a:not(.v-btn),
|
||||||
|
body.ma-contrast-boost .manual-section a {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
body.ma-contrast-boost .v-btn {
|
||||||
|
outline: 1px solid rgba(var(--v-theme-on-surface), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
@@ -20,7 +102,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rail {
|
.rail {
|
||||||
border-right: 1px solid #dde3ec;
|
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-lockup {
|
.brand-lockup {
|
||||||
@@ -50,12 +132,12 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page-band {
|
.page-band {
|
||||||
border-bottom: 1px solid #dde3ec;
|
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
padding: 22px 24px 18px;
|
padding: 22px 24px 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-nav {
|
.top-nav {
|
||||||
border-bottom: 1px solid rgba(120, 138, 163, 0.24);
|
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
padding: 0 18px;
|
padding: 0 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +199,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.metric-label {
|
.metric-label {
|
||||||
color: #617085;
|
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -131,7 +213,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.metric-subtle {
|
.metric-subtle {
|
||||||
color: #617085;
|
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
@@ -150,7 +232,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.quiet {
|
.quiet {
|
||||||
color: #617085;
|
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||||
}
|
}
|
||||||
|
|
||||||
.asset-table {
|
.asset-table {
|
||||||
@@ -160,7 +242,7 @@ body {
|
|||||||
|
|
||||||
.asset-table th,
|
.asset-table th,
|
||||||
.asset-table td {
|
.asset-table td {
|
||||||
border-bottom: 1px solid #e7ebf1;
|
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||||
font-size: 0.88rem;
|
font-size: 0.88rem;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -168,7 +250,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.asset-table th {
|
.asset-table th {
|
||||||
color: #617085;
|
color: rgba(var(--v-theme-on-surface), 0.62);
|
||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -176,7 +258,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.asset-table tr:hover td {
|
.asset-table tr:hover td {
|
||||||
background: #f8fafc;
|
background: rgba(var(--v-theme-on-surface), 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-chip {
|
.status-chip {
|
||||||
@@ -188,27 +270,184 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-in_service {
|
.status-in_service {
|
||||||
background: #e4f2eb;
|
background: rgba(var(--v-theme-success), 0.16);
|
||||||
color: #246140;
|
color: rgb(var(--v-theme-success));
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-disposed {
|
.status-disposed {
|
||||||
background: #f7e3df;
|
background: rgba(var(--v-theme-error), 0.16);
|
||||||
color: #8d2b1d;
|
color: rgb(var(--v-theme-error));
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-cip,
|
.status-cip,
|
||||||
.status-reserved {
|
.status-reserved {
|
||||||
background: #f4ead9;
|
background: rgba(var(--v-theme-warning), 0.18);
|
||||||
color: #7a4a00;
|
color: rgb(var(--v-theme-warning));
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-checked_out {
|
||||||
|
background: rgba(var(--v-theme-info), 0.16);
|
||||||
|
color: rgb(var(--v-theme-info));
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-retired {
|
||||||
|
background: rgba(var(--v-theme-on-surface), 0.1);
|
||||||
|
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-pad {
|
.panel-pad {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Reusable DataTable */
|
||||||
|
.table-scroll {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-table.sticky-head thead th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-table.sticky-head tfoot td {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-table th.sortable {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-table th.sortable:hover {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-table .th-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-table .lead-col {
|
||||||
|
width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table-footer {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PM completion photos */
|
||||||
|
.pm-photo-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.pm-photo-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.pm-photo-thumb {
|
||||||
|
width: 84px;
|
||||||
|
height: 84px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.2);
|
||||||
|
}
|
||||||
|
.pm-photo-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Asset identification photos */
|
||||||
|
.asset-photo-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.asset-photo-frame {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.18);
|
||||||
|
}
|
||||||
|
.asset-photo-frame img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.asset-photo-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PM plan guidance photos */
|
||||||
|
.pm-guidance-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.pm-guidance-fig {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.pm-guidance-fig img {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* User-guide print: render only the manual content as a clean document */
|
||||||
|
@media print {
|
||||||
|
body * {
|
||||||
|
visibility: hidden !important;
|
||||||
|
}
|
||||||
|
.user-manual-print,
|
||||||
|
.user-manual-print * {
|
||||||
|
visibility: visible !important;
|
||||||
|
}
|
||||||
|
.user-manual-print {
|
||||||
|
position: absolute !important;
|
||||||
|
inset: 0 !important;
|
||||||
|
height: auto !important;
|
||||||
|
max-height: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.user-manual-nav,
|
||||||
|
.d-print-none {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
.user-manual-content {
|
||||||
|
overflow: visible !important;
|
||||||
|
height: auto !important;
|
||||||
|
max-height: none !important;
|
||||||
|
}
|
||||||
|
.manual-section {
|
||||||
|
max-width: none !important;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.chart-box {
|
.chart-box {
|
||||||
|
position: relative;
|
||||||
height: 280px;
|
height: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-box canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-screen {
|
.login-screen {
|
||||||
@@ -246,47 +485,14 @@ body {
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v-theme--mixedAssetsDark {
|
/* Dark themes get a dark color-scheme so native controls/scrollbars match. */
|
||||||
|
.v-theme--mixedAssetsDark,
|
||||||
|
.v-theme--mixedAssetsOcean,
|
||||||
|
.v-theme--mixedAssetsSlate,
|
||||||
|
.v-theme--mixedAssetsContrast {
|
||||||
color-scheme: dark;
|
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) {
|
@media (max-width: 980px) {
|
||||||
.content-grid {
|
.content-grid {
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
@@ -340,3 +546,64 @@ body {
|
|||||||
min-width: 760px;
|
min-width: 760px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Barcode scanning + labels */
|
||||||
|
.scan-viewport {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-placeholder {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-cell {
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.35);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-id {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.barcode-caption {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const BOOK_TYPES = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
|
export const BOOK_TYPES = ['GAAP', 'FEDERAL', 'STATE', 'AMT', 'ACE', 'USER'];
|
||||||
|
|
||||||
export function makeDefaultBooks(asset = {}) {
|
export function makeDefaultBooks(asset = {}, codes = BOOK_TYPES) {
|
||||||
return BOOK_TYPES.map((bookType) => ({
|
return (codes && codes.length ? codes : BOOK_TYPES).map((bookType) => ({
|
||||||
book_type: bookType,
|
book_type: bookType,
|
||||||
active: bookType === 'GAAP' || bookType === 'FEDERAL',
|
active: bookType === 'GAAP' || bookType === 'FEDERAL',
|
||||||
cost: null,
|
cost: null,
|
||||||
@@ -15,6 +15,20 @@ export function makeDefaultBooks(asset = {}) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Merge an asset's saved books with the currently-enabled book codes: keep existing
|
||||||
|
// values, add defaults for newly-enabled books, and preserve any extra saved books.
|
||||||
|
export function mergeBooks(existing = [], codes = BOOK_TYPES, asset = {}) {
|
||||||
|
const byType = Object.fromEntries((existing || []).map((book) => [book.book_type, book]));
|
||||||
|
const defaults = makeDefaultBooks(asset, codes);
|
||||||
|
const merged = (codes && codes.length ? codes : BOOK_TYPES).map(
|
||||||
|
(code) => byType[code] || defaults.find((d) => d.book_type === code)
|
||||||
|
);
|
||||||
|
for (const book of existing || []) {
|
||||||
|
if (!merged.some((b) => b.book_type === book.book_type)) merged.push(book);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
export function blankAsset() {
|
export function blankAsset() {
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
const base = {
|
const base = {
|
||||||
@@ -44,6 +58,7 @@ export function blankAsset() {
|
|||||||
barcode_value: '',
|
barcode_value: '',
|
||||||
condition: '',
|
condition: '',
|
||||||
new_or_used: 'new',
|
new_or_used: 'new',
|
||||||
|
special_zone: '',
|
||||||
listed_property: false,
|
listed_property: false,
|
||||||
business_use_percent: 100,
|
business_use_percent: 100,
|
||||||
investment_use_percent: 0,
|
investment_use_percent: 0,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="content-grid">
|
<section class="content-grid">
|
||||||
|
<!-- USERS & TEAMS -->
|
||||||
|
<template v-if="adminSection === 'admin-users'">
|
||||||
<v-card class="span-12 panel-pad">
|
<v-card class="span-12 panel-pad">
|
||||||
<div class="toolbar-row mb-4">
|
<div class="toolbar-row mb-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -9,70 +11,154 @@
|
|||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn variant="tonal" prepend-icon="mdi-account-plus" @click="userDialog = true">Add user</v-btn>
|
<v-btn variant="tonal" prepend-icon="mdi-account-plus" @click="userDialog = true">Add user</v-btn>
|
||||||
</div>
|
</div>
|
||||||
<div style="overflow-x:auto">
|
<DataTable
|
||||||
<table class="asset-table">
|
:columns="userColumns"
|
||||||
<thead>
|
:rows="users"
|
||||||
<tr>
|
:page-size="10"
|
||||||
<th>Name</th>
|
row-key="id"
|
||||||
<th>Email</th>
|
has-actions
|
||||||
<th>Team</th>
|
searchable
|
||||||
<th>Role</th>
|
search-placeholder="Filter users"
|
||||||
<th>Status</th>
|
empty-text="No users."
|
||||||
<th></th>
|
>
|
||||||
</tr>
|
<template #cell-name="{ row }">
|
||||||
</thead>
|
<span class="font-weight-bold">{{ row.name }}</span>
|
||||||
<tbody>
|
</template>
|
||||||
<tr v-for="account in users" :key="account.id">
|
<template #cell-team_name="{ row }">{{ row.team_name || 'No team' }}</template>
|
||||||
<td class="font-weight-bold">{{ account.name }}</td>
|
<template #cell-role="{ row }">
|
||||||
<td>{{ account.email }}</td>
|
<v-chip size="small" variant="tonal">{{ roleName(row.role) }}</v-chip>
|
||||||
<td>{{ account.team_name || 'No team' }}</td>
|
</template>
|
||||||
<td><v-chip size="small" variant="tonal">{{ account.role }}</v-chip></td>
|
<template #cell-status="{ row }">
|
||||||
<td><span :class="['status-chip', account.status === 'active' ? 'status-in_service' : 'status-disposed']">{{ account.status }}</span></td>
|
<span :class="['status-chip', row.status === 'active' ? 'status-in_service' : 'status-disposed']">{{ row.status }}</span>
|
||||||
<td class="text-right">
|
</template>
|
||||||
<v-btn icon="mdi-pencil" size="small" variant="text" @click="editUser(account)" />
|
<template #actions="{ row }">
|
||||||
</td>
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="editUser(row)" />
|
||||||
</tr>
|
</template>
|
||||||
</tbody>
|
</DataTable>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<v-card class="span-5 panel-pad">
|
<v-card class="span-5 panel-pad">
|
||||||
<div class="toolbar-row mb-4">
|
<div class="toolbar-row mb-4">
|
||||||
<h2 class="section-title">Teams</h2>
|
<h2 class="section-title">Teams</h2>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn variant="tonal" size="small" prepend-icon="mdi-plus" @click="teamDialog = true">Add team</v-btn>
|
<v-btn variant="tonal" size="small" prepend-icon="mdi-plus" @click="openNewTeam">Add team</v-btn>
|
||||||
</div>
|
</div>
|
||||||
<v-list lines="two">
|
<v-alert v-if="teamNotice" type="error" density="compact" class="mb-3">{{ teamNotice }}</v-alert>
|
||||||
<v-list-item v-for="team in teams" :key="team.id" prepend-icon="mdi-account-group">
|
<DataTable
|
||||||
<v-list-item-title>{{ team.name }}</v-list-item-title>
|
:columns="teamColumns"
|
||||||
<v-list-item-subtitle>{{ team.description || 'No description' }}</v-list-item-subtitle>
|
:rows="teams"
|
||||||
</v-list-item>
|
row-key="id"
|
||||||
</v-list>
|
:page-size="10"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter teams"
|
||||||
|
empty-text="No teams."
|
||||||
|
>
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.name }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-description="{ row }">{{ row.description || 'No description' }}</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="editTeam(row)" />
|
||||||
|
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="removeTeam(row)" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<v-card class="span-7 panel-pad">
|
<v-card class="span-7 panel-pad">
|
||||||
<h2 class="section-title mb-4">Role permissions</h2>
|
<div class="toolbar-row mb-4">
|
||||||
<div style="overflow-x:auto">
|
<div>
|
||||||
<table class="asset-table">
|
<h2 class="section-title">Roles & permissions</h2>
|
||||||
<thead>
|
<div class="quiet text-body-2">Built-in and custom roles, each granting a set of capabilities.</div>
|
||||||
<tr>
|
</div>
|
||||||
<th>Capability</th>
|
<v-spacer />
|
||||||
<th v-for="role in roles" :key="role">{{ role }}</th>
|
<v-btn variant="tonal" size="small" prepend-icon="mdi-shield-plus-outline" @click="openNewRole">New role</v-btn>
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="capability in roleCapabilities" :key="capability.key">
|
|
||||||
<td>{{ capability.label }}</td>
|
|
||||||
<td v-for="role in roles" :key="role">
|
|
||||||
<v-icon :color="capability.roles.includes(role) ? 'success' : 'disabled'" :icon="capability.roles.includes(role) ? 'mdi-check-circle' : 'mdi-minus-circle'" size="18" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
<DataTable
|
||||||
|
:columns="roleColumns"
|
||||||
|
:rows="roles"
|
||||||
|
row-key="key"
|
||||||
|
:page-size="10"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter roles"
|
||||||
|
empty-text="No roles."
|
||||||
|
>
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.name }}</span>
|
||||||
|
<v-chip v-if="row.is_system" size="x-small" variant="tonal" class="ml-2">Built-in</v-chip>
|
||||||
|
</template>
|
||||||
|
<template #cell-capabilities="{ row }">{{ capabilitySummary(row) }}</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEditRole(row)" />
|
||||||
|
<v-btn v-if="!row.is_system" icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDeleteRole(row)" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
|
<!-- Role editor -->
|
||||||
|
<v-dialog v-model="roleDialog" max-width="640" scrollable>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ roleForm.exists ? `Edit role · ${roleForm.name}` : 'New role' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field v-model="roleForm.name" label="Role name" :readonly="roleForm.locked" />
|
||||||
|
<v-text-field v-if="!roleForm.exists" v-model="roleForm.key" label="Key (optional)" placeholder="auto from name" />
|
||||||
|
</div>
|
||||||
|
<v-textarea v-model="roleForm.description" label="Description" rows="2" class="mt-2" />
|
||||||
|
<v-alert v-if="roleForm.locked" type="info" density="compact" variant="tonal" class="mt-2">
|
||||||
|
The Administrator role always has every capability and can’t be restricted.
|
||||||
|
</v-alert>
|
||||||
|
<template v-else>
|
||||||
|
<v-divider class="my-3" />
|
||||||
|
<div class="toolbar-row mb-2">
|
||||||
|
<h3 class="section-title">Capabilities</h3>
|
||||||
|
<v-spacer />
|
||||||
|
<span class="quiet text-caption">{{ roleForm.capabilities.length }} selected</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="group in capabilityGroups" :key="group.name" class="cap-group">
|
||||||
|
<div class="cap-group-title">{{ group.name }}</div>
|
||||||
|
<v-checkbox
|
||||||
|
v-for="cap in group.items"
|
||||||
|
:key="cap.key"
|
||||||
|
v-model="roleForm.capabilities"
|
||||||
|
:value="cap.key"
|
||||||
|
:label="cap.label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<v-alert v-if="roleError" type="error" density="compact" class="mt-3">{{ roleError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="roleDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :disabled="!roleForm.name.trim()" @click="saveRole">Save role</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-dialog v-model="roleDeleteDialog" max-width="460">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-error">Delete role</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p>Delete the role <strong>“{{ roleDeleteTarget?.name }}”</strong>?</p>
|
||||||
|
<v-alert v-if="roleDeleteTarget && roleDeleteTarget.user_count" type="warning" variant="tonal" density="comfortable" class="mt-2">
|
||||||
|
{{ roleDeleteTarget.user_count }} user(s) currently have this role and must be reassigned first.
|
||||||
|
</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="roleDeleteDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDeleteRole">Delete role</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- APPLICATION CONFIGURATION -->
|
||||||
|
<template v-if="adminSection === 'admin-config'">
|
||||||
<v-card class="span-6 panel-pad">
|
<v-card class="span-6 panel-pad">
|
||||||
<h2 class="section-title mb-4">Application settings</h2>
|
<h2 class="section-title mb-4">Application settings</h2>
|
||||||
<v-text-field v-model="localSettings.server_fqdn" label="Server FQDN" />
|
<v-text-field v-model="localSettings.server_fqdn" label="Server FQDN" />
|
||||||
@@ -81,6 +167,20 @@
|
|||||||
<v-text-field v-model="localSettings.database_path" label="Database path" readonly />
|
<v-text-field v-model="localSettings.database_path" label="Database path" readonly />
|
||||||
<v-btn color="primary" prepend-icon="mdi-content-save" @click="$emit('save-settings', localSettings)">Save settings</v-btn>
|
<v-btn color="primary" prepend-icon="mdi-content-save" @click="$emit('save-settings', localSettings)">Save settings</v-btn>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
|
<AssetClassSettings :token="token" />
|
||||||
|
|
||||||
|
<DepreciationZoneSettings :token="token" />
|
||||||
|
|
||||||
|
<AssetIdTemplateSettings :token="token" />
|
||||||
|
|
||||||
|
<CategorySettings :token="token" @updated="$emit('categories-updated')" />
|
||||||
|
|
||||||
|
<DepartmentSettings :token="token" @updated="$emit('categories-updated')" />
|
||||||
|
|
||||||
|
<PmCategorySettings :token="token" />
|
||||||
|
|
||||||
|
<PartCategorySettings :token="token" />
|
||||||
<v-card class="span-6 panel-pad">
|
<v-card class="span-6 panel-pad">
|
||||||
<h2 class="section-title mb-4">Tax rule sets</h2>
|
<h2 class="section-title mb-4">Tax rule sets</h2>
|
||||||
<v-list lines="two">
|
<v-list lines="two">
|
||||||
@@ -90,6 +190,18 @@
|
|||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
|
<PmSettings :token="token" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- INTEGRATIONS -->
|
||||||
|
<template v-if="adminSection === 'admin-integrations'">
|
||||||
|
<NotificationSettings :token="token" />
|
||||||
|
|
||||||
|
<WebhookSettings :token="token" />
|
||||||
|
|
||||||
|
<ServiceNowSettings :token="token" />
|
||||||
|
|
||||||
<v-card class="span-12 panel-pad">
|
<v-card class="span-12 panel-pad">
|
||||||
<div class="toolbar-row mb-4">
|
<div class="toolbar-row mb-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -108,6 +220,8 @@
|
|||||||
<v-text-field v-model="localWorkday.client_secret" type="password" label="Client secret" />
|
<v-text-field v-model="localWorkday.client_secret" type="password" label="Client secret" />
|
||||||
<v-text-field v-model="localWorkday.workers_path" label="Workers path" />
|
<v-text-field v-model="localWorkday.workers_path" label="Workers path" />
|
||||||
<v-switch v-model="localWorkday.enabled" label="Enabled" color="primary" density="compact" hide-details />
|
<v-switch v-model="localWorkday.enabled" label="Enabled" color="primary" density="compact" hide-details />
|
||||||
|
<v-switch v-model="localWorkday.sync_enabled" label="Automatic scheduled sync" color="primary" density="compact" hide-details />
|
||||||
|
<v-text-field v-model.number="localWorkday.sync_interval_hours" type="number" label="Sync interval (hours)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-row mt-3">
|
<div class="toolbar-row mt-3">
|
||||||
<div class="quiet text-body-2">Last sync: {{ workdayConnection.last_sync_at ? shortDate(workdayConnection.last_sync_at) : 'Never' }}</div>
|
<div class="quiet text-body-2">Last sync: {{ workdayConnection.last_sync_at ? shortDate(workdayConnection.last_sync_at) : 'Never' }}</div>
|
||||||
@@ -116,6 +230,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<v-alert v-if="workdayConnection.last_sync_status" class="mt-3" density="compact" type="info">{{ workdayConnection.last_sync_status }}</v-alert>
|
<v-alert v-if="workdayConnection.last_sync_status" class="mt-3" density="compact" type="info">{{ workdayConnection.last_sync_status }}</v-alert>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
<v-dialog v-model="userDialog" max-width="560">
|
<v-dialog v-model="userDialog" max-width="560">
|
||||||
<v-card>
|
<v-card>
|
||||||
@@ -125,7 +240,7 @@
|
|||||||
<v-text-field v-model="userForm.name" label="Name" />
|
<v-text-field v-model="userForm.name" label="Name" />
|
||||||
<v-text-field v-model="userForm.email" label="Email" />
|
<v-text-field v-model="userForm.email" label="Email" />
|
||||||
<v-select v-model="userForm.team_id" :items="teamOptions" item-title="title" item-value="value" clearable label="Team" />
|
<v-select v-model="userForm.team_id" :items="teamOptions" item-title="title" item-value="value" clearable label="Team" />
|
||||||
<v-select v-model="userForm.role" :items="roles" label="Role" />
|
<v-select v-model="userForm.role" :items="roleSelectItems" item-title="title" item-value="value" label="Role" />
|
||||||
<v-select v-model="userForm.status" :items="['active', 'inactive']" label="Status" />
|
<v-select v-model="userForm.status" :items="['active', 'inactive']" label="Status" />
|
||||||
<v-text-field v-model="userForm.password" type="password" :label="userForm.id ? 'New password' : 'Password'" />
|
<v-text-field v-model="userForm.password" type="password" :label="userForm.id ? 'New password' : 'Password'" />
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +258,7 @@
|
|||||||
|
|
||||||
<v-dialog v-model="teamDialog" max-width="460">
|
<v-dialog v-model="teamDialog" max-width="460">
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title>Add team</v-card-title>
|
<v-card-title>{{ teamForm.id ? 'Edit team' : 'Add team' }}</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-text-field v-model="teamForm.name" label="Team name" />
|
<v-text-field v-model="teamForm.name" label="Team name" />
|
||||||
<v-textarea v-model="teamForm.description" label="Description" rows="2" />
|
<v-textarea v-model="teamForm.description" label="Description" rows="2" />
|
||||||
@@ -159,36 +274,88 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import AssetClassSettings from '../components/AssetClassSettings.vue';
|
||||||
|
import AssetIdTemplateSettings from '../components/AssetIdTemplateSettings.vue';
|
||||||
|
import CategorySettings from '../components/CategorySettings.vue';
|
||||||
|
import DataTable from '../components/DataTable.vue';
|
||||||
|
import DepartmentSettings from '../components/DepartmentSettings.vue';
|
||||||
|
import DepreciationZoneSettings from '../components/DepreciationZoneSettings.vue';
|
||||||
|
import NotificationSettings from '../components/NotificationSettings.vue';
|
||||||
|
import PartCategorySettings from '../components/PartCategorySettings.vue';
|
||||||
|
import PmCategorySettings from '../components/PmCategorySettings.vue';
|
||||||
|
import PmSettings from '../components/PmSettings.vue';
|
||||||
|
import ServiceNowSettings from '../components/ServiceNowSettings.vue';
|
||||||
|
import WebhookSettings from '../components/WebhookSettings.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
components: { AssetClassSettings, AssetIdTemplateSettings, CategorySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PmCategorySettings, PmSettings, ServiceNowSettings, WebhookSettings },
|
||||||
props: {
|
props: {
|
||||||
roleCapabilities: { type: Array, default: () => [] },
|
token: { type: String, default: '' },
|
||||||
rolePermissions: { type: Object, default: () => ({}) },
|
section: { type: String, default: 'admin-users' },
|
||||||
|
capabilityCatalog: { type: Array, default: () => [] },
|
||||||
roles: { type: Array, default: () => [] },
|
roles: { type: Array, default: () => [] },
|
||||||
settings: { type: Object, required: true },
|
settings: { type: Object, required: true },
|
||||||
shortDate: { type: Function, default: (value) => value || '' },
|
shortDate: { type: Function, default: (value) => value || '' },
|
||||||
taxRules: { type: Array, required: true },
|
taxRules: { type: Array, required: true },
|
||||||
teamSaving: { type: Boolean, default: false },
|
teamSaving: { type: Boolean, default: false },
|
||||||
teams: { type: Array, default: () => [] },
|
teams: { type: Array, default: () => [] },
|
||||||
|
teamNotice: { type: String, default: '' },
|
||||||
userSaving: { type: Boolean, default: false },
|
userSaving: { type: Boolean, default: false },
|
||||||
users: { type: Array, default: () => [] },
|
users: { type: Array, default: () => [] },
|
||||||
workdayConnection: { type: Object, default: () => ({}) },
|
workdayConnection: { type: Object, default: () => ({}) },
|
||||||
workdaySaving: { type: Boolean, default: false },
|
workdaySaving: { type: Boolean, default: false },
|
||||||
workdaySyncing: { type: Boolean, default: false }
|
workdaySyncing: { type: Boolean, default: false }
|
||||||
},
|
},
|
||||||
emits: ['create-team', 'create-user', 'save-settings', 'save-workday', 'sync-workday', 'update-user'],
|
emits: ['create-team', 'update-team', 'delete-team', 'create-user', 'update-user', 'create-role', 'update-role', 'delete-role', 'save-settings', 'save-workday', 'sync-workday', 'categories-updated'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
localSettings: { ...this.settings },
|
localSettings: { ...this.settings },
|
||||||
localWorkday: { ...this.workdayConnection, client_secret: '' },
|
localWorkday: { ...this.workdayConnection, client_secret: '' },
|
||||||
teamDialog: false,
|
teamDialog: false,
|
||||||
teamForm: { name: '', description: '' },
|
teamForm: { id: null, name: '', description: '' },
|
||||||
userDialog: false,
|
userDialog: false,
|
||||||
userForm: this.blankUser()
|
userForm: this.blankUser(),
|
||||||
|
roleDialog: false,
|
||||||
|
roleForm: this.blankRole(),
|
||||||
|
roleError: '',
|
||||||
|
roleDeleteDialog: false,
|
||||||
|
roleDeleteTarget: null,
|
||||||
|
userColumns: [
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'email', label: 'Email' },
|
||||||
|
{ key: 'team_name', label: 'Team' },
|
||||||
|
{ key: 'role', label: 'Role' },
|
||||||
|
{ key: 'status', label: 'Status' }
|
||||||
|
],
|
||||||
|
teamColumns: [
|
||||||
|
{ key: 'name', label: 'Team' },
|
||||||
|
{ key: 'description', label: 'Description' }
|
||||||
|
],
|
||||||
|
roleColumns: [
|
||||||
|
{ key: 'name', label: 'Role' },
|
||||||
|
{ key: 'capabilities', label: 'Capabilities', sortable: false },
|
||||||
|
{ key: 'user_count', label: 'Users', format: 'number' }
|
||||||
|
]
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
teamOptions() {
|
teamOptions() {
|
||||||
return this.teams.map((team) => ({ title: team.name, value: team.id }));
|
return this.teams.map((team) => ({ title: team.name, value: team.id }));
|
||||||
|
},
|
||||||
|
roleSelectItems() {
|
||||||
|
return this.roles.map((role) => ({ title: role.name, value: role.key }));
|
||||||
|
},
|
||||||
|
capabilityGroups() {
|
||||||
|
const groups = [];
|
||||||
|
for (const cap of this.capabilityCatalog) {
|
||||||
|
let group = groups.find((g) => g.name === cap.group);
|
||||||
|
if (!group) { group = { name: cap.group, items: [] }; groups.push(group); }
|
||||||
|
group.items.push(cap);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
},
|
||||||
|
adminSection() {
|
||||||
|
return ['admin-users', 'admin-config', 'admin-integrations'].includes(this.section) ? this.section : 'admin-users';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -229,13 +396,73 @@ export default {
|
|||||||
};
|
};
|
||||||
this.userDialog = true;
|
this.userDialog = true;
|
||||||
},
|
},
|
||||||
roleSummary(role) {
|
roleName(roleKey) {
|
||||||
return (this.rolePermissions[role] || []).join(' · ');
|
return (this.roles.find((r) => r.key === roleKey) || {}).name || roleKey;
|
||||||
|
},
|
||||||
|
roleSummary(roleKey) {
|
||||||
|
const role = this.roles.find((r) => r.key === roleKey);
|
||||||
|
if (!role) return '';
|
||||||
|
return role.description || this.capabilitySummary(role);
|
||||||
|
},
|
||||||
|
capabilitySummary(role) {
|
||||||
|
if ((role.capabilities || []).includes('*')) return 'All capabilities';
|
||||||
|
return `${(role.capabilities || []).length} capabilit${(role.capabilities || []).length === 1 ? 'y' : 'ies'}`;
|
||||||
|
},
|
||||||
|
blankRole() {
|
||||||
|
return { exists: false, locked: false, key: '', name: '', description: '', capabilities: [] };
|
||||||
|
},
|
||||||
|
openNewRole() {
|
||||||
|
this.roleForm = this.blankRole();
|
||||||
|
this.roleError = '';
|
||||||
|
this.roleDialog = true;
|
||||||
|
},
|
||||||
|
openEditRole(role) {
|
||||||
|
this.roleForm = {
|
||||||
|
exists: true,
|
||||||
|
locked: Boolean(role.locked),
|
||||||
|
key: role.key,
|
||||||
|
name: role.name,
|
||||||
|
description: role.description || '',
|
||||||
|
capabilities: [...(role.capabilities || [])].filter((c) => c !== '*')
|
||||||
|
};
|
||||||
|
this.roleError = '';
|
||||||
|
this.roleDialog = true;
|
||||||
|
},
|
||||||
|
saveRole() {
|
||||||
|
if (!this.roleForm.name.trim()) return;
|
||||||
|
const payload = {
|
||||||
|
key: this.roleForm.key,
|
||||||
|
name: this.roleForm.name.trim(),
|
||||||
|
description: this.roleForm.description,
|
||||||
|
capabilities: this.roleForm.capabilities
|
||||||
|
};
|
||||||
|
this.$emit(this.roleForm.exists ? 'update-role' : 'create-role', payload);
|
||||||
|
this.roleDialog = false;
|
||||||
|
},
|
||||||
|
confirmDeleteRole(role) {
|
||||||
|
this.roleDeleteTarget = role;
|
||||||
|
this.roleDeleteDialog = true;
|
||||||
|
},
|
||||||
|
performDeleteRole() {
|
||||||
|
if (this.roleDeleteTarget) this.$emit('delete-role', this.roleDeleteTarget.key);
|
||||||
|
this.roleDeleteDialog = false;
|
||||||
|
this.roleDeleteTarget = null;
|
||||||
|
},
|
||||||
|
openNewTeam() {
|
||||||
|
this.teamForm = { id: null, name: '', description: '' };
|
||||||
|
this.teamDialog = true;
|
||||||
|
},
|
||||||
|
editTeam(team) {
|
||||||
|
this.teamForm = { id: team.id, name: team.name, description: team.description || '' };
|
||||||
|
this.teamDialog = true;
|
||||||
|
},
|
||||||
|
removeTeam(team) {
|
||||||
|
if (window.confirm(`Delete team "${team.name}"?`)) this.$emit('delete-team', team.id);
|
||||||
},
|
},
|
||||||
saveTeam() {
|
saveTeam() {
|
||||||
this.$emit('create-team', { ...this.teamForm });
|
this.$emit(this.teamForm.id ? 'update-team' : 'create-team', { ...this.teamForm });
|
||||||
this.teamDialog = false;
|
this.teamDialog = false;
|
||||||
this.teamForm = { name: '', description: '' };
|
this.teamForm = { id: null, name: '', description: '' };
|
||||||
},
|
},
|
||||||
saveUser() {
|
saveUser() {
|
||||||
const payload = { ...this.userForm };
|
const payload = { ...this.userForm };
|
||||||
@@ -247,3 +474,17 @@ export default {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cap-group {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.cap-group-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 6px 0 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
219
src/views/AlertsView.vue
Normal file
219
src/views/AlertsView.vue
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<template>
|
||||||
|
<section class="content-grid">
|
||||||
|
<v-card class="metric span-3">
|
||||||
|
<div class="metric-label">Open</div>
|
||||||
|
<div class="metric-value">{{ summary.open || 0 }}</div>
|
||||||
|
<div class="metric-subtle">Needing attention</div>
|
||||||
|
</v-card>
|
||||||
|
<v-card class="metric span-3">
|
||||||
|
<div class="metric-label">Critical</div>
|
||||||
|
<div class="metric-value">{{ summary.critical || 0 }}</div>
|
||||||
|
<div class="metric-subtle">Overdue or expired</div>
|
||||||
|
</v-card>
|
||||||
|
<v-card class="metric span-3">
|
||||||
|
<div class="metric-label">Acknowledged</div>
|
||||||
|
<div class="metric-value">{{ summary.acknowledged || 0 }}</div>
|
||||||
|
<div class="metric-subtle">In progress</div>
|
||||||
|
</v-card>
|
||||||
|
<v-card class="metric span-3 d-flex flex-column justify-center">
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-refresh" :loading="loading" @click="refresh">Run scan</v-btn>
|
||||||
|
<v-btn v-if="isAdmin" class="mt-2" variant="tonal" size="small" prepend-icon="mdi-email-fast-outline" :loading="emailing" @click="emailDigest">Email digest now</v-btn>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<h2 class="section-title">Reminders & alerts</h2>
|
||||||
|
<v-spacer />
|
||||||
|
<v-select v-model="statusFilter" :items="statusItems" label="Status" density="compact" hide-details style="max-width:180px" />
|
||||||
|
<v-select v-model="typeFilter" :items="typeItems" label="Type" density="compact" hide-details clearable style="max-width:220px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="filteredAlerts"
|
||||||
|
row-key="id"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter alerts"
|
||||||
|
empty-text="No alerts — you're all caught up."
|
||||||
|
>
|
||||||
|
<template #cell-severity="{ row }">
|
||||||
|
<v-chip :color="severityColor(row.severity)" size="small" variant="tonal">{{ row.severity }}</v-chip>
|
||||||
|
</template>
|
||||||
|
<template #cell-type="{ row }">{{ humanize(row.type) }}</template>
|
||||||
|
<template #cell-asset_code="{ row }">{{ row.asset_code || '—' }}</template>
|
||||||
|
<template #cell-status="{ row }">
|
||||||
|
<span class="text-capitalize">{{ row.status }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-external_ticket_number="{ row }">
|
||||||
|
<a v-if="row.external_ticket_number" :href="row.external_ticket_url" target="_blank" rel="noopener" class="ticket-link">
|
||||||
|
<v-icon icon="mdi-ticket-confirmation-outline" size="14" /> {{ row.external_ticket_number }}
|
||||||
|
</a>
|
||||||
|
<span v-else class="quiet">—</span>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn v-if="row.status === 'open'" size="small" variant="text" @click="setStatus(row, 'acknowledge')">Ack</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="!row.external_ticket_number && row.status !== 'dismissed'"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
:loading="ticketingId === row.id"
|
||||||
|
@click="submitTicket(row)"
|
||||||
|
>ServiceNow</v-btn>
|
||||||
|
<v-btn v-if="row.status !== 'dismissed'" size="small" variant="text" color="error" @click="setStatus(row, 'dismiss')">Dismiss</v-btn>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
<v-alert v-if="message" type="success" density="compact" class="mt-3">{{ message }}</v-alert>
|
||||||
|
</v-card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DataTable from '../components/DataTable.vue';
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { DataTable },
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true },
|
||||||
|
isAdmin: { type: Boolean, default: false }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
alerts: [],
|
||||||
|
summary: {},
|
||||||
|
statusFilter: 'open',
|
||||||
|
typeFilter: null,
|
||||||
|
loading: false,
|
||||||
|
emailing: false,
|
||||||
|
error: '',
|
||||||
|
message: '',
|
||||||
|
statusItems: [
|
||||||
|
{ title: 'Open', value: 'open' },
|
||||||
|
{ title: 'Acknowledged', value: 'acknowledged' },
|
||||||
|
{ title: 'Dismissed', value: 'dismissed' },
|
||||||
|
{ title: 'Resolved', value: 'resolved' },
|
||||||
|
{ title: 'All', value: 'all' }
|
||||||
|
],
|
||||||
|
typeItems: [
|
||||||
|
{ title: 'Maintenance overdue', value: 'maintenance_overdue' },
|
||||||
|
{ title: 'Maintenance due', value: 'maintenance_due' },
|
||||||
|
{ title: 'Warranty expired', value: 'warranty_expired' },
|
||||||
|
{ title: 'Warranty expiring', value: 'warranty_expiring' },
|
||||||
|
{ title: 'Lease ending', value: 'lease_expiring' }
|
||||||
|
],
|
||||||
|
columns: [
|
||||||
|
{ key: 'severity', label: 'Severity' },
|
||||||
|
{ key: 'type', label: 'Type' },
|
||||||
|
{ key: 'title', label: 'Alert' },
|
||||||
|
{ key: 'message', label: 'Detail' },
|
||||||
|
{ key: 'due_date', label: 'Due', format: 'date' },
|
||||||
|
{ key: 'asset_code', label: 'Asset' },
|
||||||
|
{ key: 'status', label: 'Status' },
|
||||||
|
{ key: 'external_ticket_number', label: 'Ticket', sortable: false }
|
||||||
|
],
|
||||||
|
ticketingId: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
filteredAlerts() {
|
||||||
|
return this.alerts.filter((a) =>
|
||||||
|
(this.statusFilter === 'all' || a.status === this.statusFilter) &&
|
||||||
|
(!this.typeFilter || a.type === this.typeFilter)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.refresh();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async refresh() {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
this.message = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/alerts/scan', { method: 'POST' })).json();
|
||||||
|
this.alerts = data.alerts;
|
||||||
|
this.summary = data.summary;
|
||||||
|
} catch (error) {
|
||||||
|
// Fall back to a read-only list if the user cannot scan.
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/alerts')).json();
|
||||||
|
this.alerts = data.alerts;
|
||||||
|
this.summary = data.summary;
|
||||||
|
} catch (inner) {
|
||||||
|
this.error = inner.message;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async setStatus(row, action) {
|
||||||
|
try {
|
||||||
|
await this.api(`/api/alerts/${row.id}/${action}`, { method: 'PUT' });
|
||||||
|
const data = await (await this.api('/api/alerts')).json();
|
||||||
|
this.alerts = data.alerts;
|
||||||
|
this.summary = data.summary;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async emailDigest() {
|
||||||
|
this.emailing = true;
|
||||||
|
this.error = '';
|
||||||
|
this.message = '';
|
||||||
|
try {
|
||||||
|
const result = await (await this.api('/api/alerts/run', { method: 'POST' })).json();
|
||||||
|
this.message = result.emailed
|
||||||
|
? `Digest emailed (${result.count} alert(s)).`
|
||||||
|
: 'Scan complete. No email sent (alerts disabled, SMTP not set, or nothing new).';
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.emailing = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async submitTicket(row) {
|
||||||
|
this.ticketingId = row.id;
|
||||||
|
this.error = '';
|
||||||
|
this.message = '';
|
||||||
|
try {
|
||||||
|
const { ticket } = await (await this.api(`/api/alerts/${row.id}/ticket`, { method: 'POST', body: JSON.stringify({}) })).json();
|
||||||
|
this.message = ticket.duplicate
|
||||||
|
? `Alert already linked to ${ticket.number}.`
|
||||||
|
: `Created ServiceNow incident ${ticket.number}.`;
|
||||||
|
const data = await (await this.api('/api/alerts')).json();
|
||||||
|
this.alerts = data.alerts;
|
||||||
|
this.summary = data.summary;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = `ServiceNow ticket failed: ${error.message}`;
|
||||||
|
} finally {
|
||||||
|
this.ticketingId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
severityColor(severity) {
|
||||||
|
return { critical: 'error', warning: 'warning', info: 'info' }[severity] || 'info';
|
||||||
|
},
|
||||||
|
humanize(value) {
|
||||||
|
return String(value || '').replace(/_/g, ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ticket-link {
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ticket-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,6 +6,9 @@
|
|||||||
<v-select v-model="localFilters.status" :items="statusItems" clearable label="Status" hide-details style="max-width: 180px" @update:model-value="emitFilters" />
|
<v-select v-model="localFilters.status" :items="statusItems" clearable label="Status" hide-details style="max-width: 180px" @update:model-value="emitFilters" />
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn variant="tonal" prepend-icon="mdi-pencil-multiple" :disabled="!selectedAssetIds.length" @click="$emit('open-mass-edit')">Mass edit</v-btn>
|
<v-btn variant="tonal" prepend-icon="mdi-pencil-multiple" :disabled="!selectedAssetIds.length" @click="$emit('open-mass-edit')">Mass edit</v-btn>
|
||||||
|
<v-btn variant="tonal" prepend-icon="mdi-barcode" @click="$emit('print-labels')">
|
||||||
|
{{ selectedAssetIds.length ? `Labels (${selectedAssetIds.length})` : 'Labels' }}
|
||||||
|
</v-btn>
|
||||||
<v-btn variant="tonal" prepend-icon="mdi-upload" @click="$refs.importFile.click()">Import</v-btn>
|
<v-btn variant="tonal" prepend-icon="mdi-upload" @click="$refs.importFile.click()">Import</v-btn>
|
||||||
<input ref="importFile" hidden type="file" accept=".csv,.json,.xlsx,.xls,.xml" @change="$emit('import-assets', $event)" />
|
<input ref="importFile" hidden type="file" accept=".csv,.json,.xlsx,.xls,.xml" @change="$emit('import-assets', $event)" />
|
||||||
<v-menu>
|
<v-menu>
|
||||||
@@ -17,44 +20,39 @@
|
|||||||
</v-list>
|
</v-list>
|
||||||
</v-menu>
|
</v-menu>
|
||||||
</div>
|
</div>
|
||||||
<div style="overflow-x:auto">
|
<DataTable
|
||||||
<table class="asset-table">
|
:columns="columns"
|
||||||
<thead>
|
:rows="assets"
|
||||||
<tr>
|
row-key="id"
|
||||||
<th style="width:44px"><v-checkbox-btn :model-value="allSelected" @update:model-value="$emit('toggle-all', $event)" /></th>
|
has-lead
|
||||||
<th>Asset ID</th>
|
has-actions
|
||||||
<th>Description</th>
|
empty-text="No assets match the current filters."
|
||||||
<th>Entity</th>
|
>
|
||||||
<th>Category</th>
|
<template #lead-header>
|
||||||
<th>Status</th>
|
<v-checkbox-btn :model-value="allSelected" @update:model-value="$emit('toggle-all', $event)" />
|
||||||
<th>Cost</th>
|
</template>
|
||||||
<th>Service date</th>
|
<template #lead="{ row }">
|
||||||
<th></th>
|
<v-checkbox-btn :model-value="selectedAssetIds.includes(row.id)" @update:model-value="$emit('toggle-asset', row.id)" />
|
||||||
</tr>
|
</template>
|
||||||
</thead>
|
<template #cell-asset_id="{ row }">
|
||||||
<tbody>
|
<span class="font-weight-bold">{{ row.asset_id }}</span>
|
||||||
<tr v-for="asset in assets" :key="asset.id">
|
</template>
|
||||||
<td><v-checkbox-btn :model-value="selectedAssetIds.includes(asset.id)" @update:model-value="$emit('toggle-asset', asset.id)" /></td>
|
<template #cell-status="{ row }">
|
||||||
<td class="font-weight-bold">{{ asset.asset_id }}</td>
|
<span :class="['status-chip', `status-${row.status}`]">{{ row.status }}</span>
|
||||||
<td>{{ asset.description }}</td>
|
</template>
|
||||||
<td>{{ asset.entity_name }}</td>
|
<template #actions="{ row }">
|
||||||
<td>{{ asset.category }}</td>
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="$emit('edit-asset', row)" />
|
||||||
<td><span :class="['status-chip', `status-${asset.status}`]">{{ asset.status }}</span></td>
|
</template>
|
||||||
<td>{{ currency(asset.acquisition_cost) }}</td>
|
</DataTable>
|
||||||
<td>{{ asset.in_service_date }}</td>
|
|
||||||
<td class="text-right">
|
|
||||||
<v-btn icon="mdi-pencil" size="small" variant="text" @click="$emit('edit-asset', asset)" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</v-card>
|
</v-card>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import DataTable from '../components/DataTable.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
components: { DataTable },
|
||||||
props: {
|
props: {
|
||||||
allSelected: { type: Boolean, default: false },
|
allSelected: { type: Boolean, default: false },
|
||||||
assets: { type: Array, required: true },
|
assets: { type: Array, required: true },
|
||||||
@@ -63,10 +61,19 @@ export default {
|
|||||||
selectedAssetIds: { type: Array, required: true },
|
selectedAssetIds: { type: Array, required: true },
|
||||||
statusItems: { type: Array, required: true }
|
statusItems: { type: Array, required: true }
|
||||||
},
|
},
|
||||||
emits: ['edit-asset', 'export-assets', 'filter-assets', 'import-assets', 'open-mass-edit', 'toggle-all', 'toggle-asset'],
|
emits: ['edit-asset', 'export-assets', 'filter-assets', 'import-assets', 'open-mass-edit', 'print-labels', 'toggle-all', 'toggle-asset'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
localFilters: { ...this.assetFilters }
|
localFilters: { ...this.assetFilters },
|
||||||
|
columns: [
|
||||||
|
{ key: 'asset_id', label: 'Asset ID' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{ key: 'entity_name', label: 'Entity' },
|
||||||
|
{ key: 'category', label: 'Category' },
|
||||||
|
{ key: 'status', label: 'Status' },
|
||||||
|
{ key: 'acquisition_cost', label: 'Cost', format: 'currency' },
|
||||||
|
{ key: 'in_service_date', label: 'Service date', format: 'date' }
|
||||||
|
]
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|||||||
@@ -37,15 +37,22 @@
|
|||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn variant="tonal" prepend-icon="mdi-account-plus" @click="employeeDialog = true">Add employee</v-btn>
|
<v-btn variant="tonal" prepend-icon="mdi-account-plus" @click="employeeDialog = true">Add employee</v-btn>
|
||||||
</div>
|
</div>
|
||||||
<v-list lines="two">
|
<DataTable
|
||||||
<v-list-item v-for="employee in employees" :key="employee.id" prepend-icon="mdi-account">
|
:columns="employeeColumns"
|
||||||
<v-list-item-title>{{ employee.name }}</v-list-item-title>
|
:rows="employees"
|
||||||
<v-list-item-subtitle>{{ employee.email || employee.employee_number }} · {{ employee.department || 'No department' }} · {{ employee.location || 'No location' }}</v-list-item-subtitle>
|
row-key="id"
|
||||||
<template #append>
|
:page-size="10"
|
||||||
<v-chip size="small" variant="tonal">{{ employee.source }}</v-chip>
|
searchable
|
||||||
</template>
|
search-placeholder="Filter employees"
|
||||||
</v-list-item>
|
empty-text="No employees yet."
|
||||||
</v-list>
|
>
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.name }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-source="{ row }">
|
||||||
|
<v-chip size="small" variant="tonal">{{ row.source }}</v-chip>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<v-card class="span-12 panel-pad">
|
<v-card class="span-12 panel-pad">
|
||||||
@@ -54,37 +61,29 @@
|
|||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn variant="tonal" prepend-icon="mdi-refresh" @click="$emit('reload')">Refresh</v-btn>
|
<v-btn variant="tonal" prepend-icon="mdi-refresh" @click="$emit('reload')">Refresh</v-btn>
|
||||||
</div>
|
</div>
|
||||||
<div style="overflow-x:auto">
|
<DataTable
|
||||||
<table class="asset-table">
|
:columns="assignmentColumns"
|
||||||
<thead>
|
:rows="assignments"
|
||||||
<tr>
|
row-key="id"
|
||||||
<th>Asset</th>
|
has-actions
|
||||||
<th>Employee</th>
|
searchable
|
||||||
<th>Department</th>
|
search-placeholder="Filter assignments"
|
||||||
<th>Location</th>
|
empty-text="No assignment history yet."
|
||||||
<th>Assigned</th>
|
>
|
||||||
<th>Status</th>
|
<template #cell-asset_code="{ row }">
|
||||||
<th></th>
|
<strong>{{ row.asset_code }}</strong><br />
|
||||||
</tr>
|
<span class="quiet">{{ row.asset_description }}</span>
|
||||||
</thead>
|
</template>
|
||||||
<tbody>
|
<template #cell-employee_name="{ row }">{{ row.employee_name || 'Unassigned employee' }}</template>
|
||||||
<tr v-for="assignment in assignments" :key="assignment.id">
|
<template #cell-department="{ row }">{{ row.department || '-' }}</template>
|
||||||
<td>
|
<template #cell-location="{ row }">{{ row.location || '-' }}</template>
|
||||||
<strong>{{ assignment.asset_code }}</strong><br />
|
<template #cell-status="{ row }">
|
||||||
<span class="quiet">{{ assignment.asset_description }}</span>
|
<span :class="['status-chip', row.active ? 'status-in_service' : 'status-disposed']">{{ row.status }}</span>
|
||||||
</td>
|
</template>
|
||||||
<td>{{ assignment.employee_name || 'Unassigned employee' }}</td>
|
<template #actions="{ row }">
|
||||||
<td>{{ assignment.department || '-' }}</td>
|
<v-btn v-if="row.active" size="small" variant="text" color="warning" @click="$emit('release-assignment', row)">Release</v-btn>
|
||||||
<td>{{ assignment.location || '-' }}</td>
|
</template>
|
||||||
<td>{{ shortDate(assignment.assigned_at) }}</td>
|
</DataTable>
|
||||||
<td><span :class="['status-chip', assignment.active ? 'status-in_service' : 'status-disposed']">{{ assignment.status }}</span></td>
|
|
||||||
<td class="text-right">
|
|
||||||
<v-btn v-if="assignment.active" size="small" variant="text" color="warning" @click="$emit('release-assignment', assignment)">Release</v-btn>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<v-dialog v-model="employeeDialog" max-width="480">
|
<v-dialog v-model="employeeDialog" max-width="480">
|
||||||
@@ -108,7 +107,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import DataTable from '../components/DataTable.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
components: { DataTable },
|
||||||
props: {
|
props: {
|
||||||
assignments: { type: Array, required: true },
|
assignments: { type: Array, required: true },
|
||||||
assets: { type: Array, required: true },
|
assets: { type: Array, required: true },
|
||||||
@@ -120,6 +122,22 @@ export default {
|
|||||||
emits: ['add-employee', 'assign-asset', 'release-assignment', 'reload'],
|
emits: ['add-employee', 'assign-asset', 'release-assignment', 'reload'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
assignmentColumns: [
|
||||||
|
{ key: 'asset_code', label: 'Asset' },
|
||||||
|
{ key: 'employee_name', label: 'Employee' },
|
||||||
|
{ key: 'department', label: 'Department' },
|
||||||
|
{ key: 'location', label: 'Location' },
|
||||||
|
{ key: 'assigned_at', label: 'Assigned', format: 'date' },
|
||||||
|
{ key: 'status', label: 'Status' }
|
||||||
|
],
|
||||||
|
employeeColumns: [
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'email', label: 'Email' },
|
||||||
|
{ key: 'employee_number', label: 'Emp #' },
|
||||||
|
{ key: 'department', label: 'Department' },
|
||||||
|
{ key: 'location', label: 'Location' },
|
||||||
|
{ key: 'source', label: 'Source' }
|
||||||
|
],
|
||||||
assignmentForm: {
|
assignmentForm: {
|
||||||
asset_id: null,
|
asset_id: null,
|
||||||
employee_id: null,
|
employee_id: null,
|
||||||
|
|||||||
357
src/views/BooksView.vue
Normal file
357
src/views/BooksView.vue
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
<template>
|
||||||
|
<section class="content-grid">
|
||||||
|
<!-- Book configuration -->
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-1">
|
||||||
|
<h2 class="section-title">Books</h2>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" size="small" prepend-icon="mdi-plus" @click="openNewBook">New book</v-btn>
|
||||||
|
</div>
|
||||||
|
<p class="quiet text-body-2 mb-3">Configure each set of books, assign the depreciation rule set it should use, and set entry defaults. Add user-defined books for additional reporting scenarios.</p>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table class="asset-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Book</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Tax rule set</th>
|
||||||
|
<th>Default method</th>
|
||||||
|
<th>Convention</th>
|
||||||
|
<th>Assets</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="book in books" :key="book.id">
|
||||||
|
<td>
|
||||||
|
<div class="font-weight-bold">{{ book.code }}<v-chip v-if="book.is_primary" size="x-small" color="primary" variant="tonal" class="ml-2">primary</v-chip></div>
|
||||||
|
<v-text-field v-model="book.name" density="compact" hide-details variant="plain" />
|
||||||
|
</td>
|
||||||
|
<td style="min-width:140px">
|
||||||
|
<v-select v-model="book.book_type" :items="bookTypeItems" item-title="title" item-value="value" density="compact" hide-details />
|
||||||
|
</td>
|
||||||
|
<td style="min-width:220px">
|
||||||
|
<v-select
|
||||||
|
v-model="book.tax_rule_set_id"
|
||||||
|
:items="ruleSetItems"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
clearable
|
||||||
|
placeholder="Active set"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style="min-width:220px">
|
||||||
|
<v-select v-model="book.default_method" :items="methodItems" item-title="title" item-value="value" density="compact" hide-details />
|
||||||
|
</td>
|
||||||
|
<td style="min-width:160px">
|
||||||
|
<v-select v-model="book.default_convention" :items="conventionItems" item-title="title" item-value="value" density="compact" hide-details />
|
||||||
|
</td>
|
||||||
|
<td>{{ book.asset_count }}</td>
|
||||||
|
<td><v-switch v-model="book.enabled" color="primary" density="compact" hide-details /></td>
|
||||||
|
<td class="text-right" style="white-space:nowrap">
|
||||||
|
<v-btn size="small" color="primary" variant="tonal" prepend-icon="mdi-content-save" :loading="savingId === book.id" @click="save(book)">Save</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="!book.is_primary && book.book_type !== 'maintenance'"
|
||||||
|
icon="mdi-delete-outline"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
class="ml-1"
|
||||||
|
@click="removeBook(book)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
<v-alert v-if="message" type="success" density="compact" class="mt-3">{{ message }}</v-alert>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- General ledger -->
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<h2 class="section-title">General ledger</h2>
|
||||||
|
<v-spacer />
|
||||||
|
<v-select v-model="ledgerBook" :items="bookCodeItems" label="Book" density="compact" hide-details style="max-width:180px" @update:model-value="loadLedger" />
|
||||||
|
<v-text-field v-model.number="ledgerYear" type="number" label="Year" density="compact" hide-details style="max-width:120px" @update:model-value="loadLedger" />
|
||||||
|
<v-menu>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn v-bind="props" color="secondary" size="small" prepend-icon="mdi-download">Export</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item v-for="format in ['pdf', 'xlsx', 'csv']" :key="format" :title="format.toUpperCase()" @click="exportLedger(format)" />
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ledger">
|
||||||
|
<div v-if="ledger.kind === 'maintenance'" class="ledger-summary mb-4">
|
||||||
|
<div class="ledger-chip"><span class="quiet">Assets serviced</span><strong>{{ ledger.summary.assets }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Services ({{ ledger.year }})</span><strong>{{ ledger.summary.completions }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Total PM spend</span><strong>{{ currency(ledger.summary.cost) }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="ledger-summary mb-4">
|
||||||
|
<div class="ledger-chip"><span class="quiet">Assets</span><strong>{{ ledger.summary.assets }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Cost</span><strong>{{ currency(ledger.summary.cost) }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Accumulated depr.</span><strong>{{ currency(ledger.summary.accumulated) }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Net book value</span><strong>{{ currency(ledger.summary.nbv) }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">{{ ledger.year }} depreciation</span><strong>{{ currency(ledger.summary.depreciation) }}</strong></div>
|
||||||
|
<div class="ledger-chip"><span class="quiet">Rule set</span><strong>{{ ledger.rule_set.name || 'Active set' }}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table class="asset-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>GL account</th><th>Account type</th><th class="text-right">Debit</th><th class="text-right">Credit</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(line, index) in ledger.accounts" :key="index">
|
||||||
|
<td class="font-weight-bold">{{ line.account }}</td>
|
||||||
|
<td>{{ line.type }}</td>
|
||||||
|
<td class="text-right">{{ line.debit ? currency(line.debit) : '' }}</td>
|
||||||
|
<td class="text-right">{{ line.credit ? currency(line.credit) : '' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!ledger.accounts.length"><td colspan="4" class="quiet">No postings for this book and year.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td class="font-weight-bold" colspan="2">Totals</td>
|
||||||
|
<td class="text-right font-weight-bold">{{ currency(ledger.accountTotals.debit) }}</td>
|
||||||
|
<td class="text-right font-weight-bold">{{ currency(ledger.accountTotals.credit) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-expansion-panels class="mt-4" variant="accordion">
|
||||||
|
<v-expansion-panel title="Asset detail">
|
||||||
|
<v-expansion-panel-text>
|
||||||
|
<DataTable
|
||||||
|
:columns="detailColumns"
|
||||||
|
:rows="ledger.assets"
|
||||||
|
row-key="asset_id"
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter assets"
|
||||||
|
empty-text="No assets in this book."
|
||||||
|
>
|
||||||
|
<template #cell-asset_id="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.asset_id }}</span>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</v-expansion-panel-text>
|
||||||
|
</v-expansion-panel>
|
||||||
|
</v-expansion-panels>
|
||||||
|
</div>
|
||||||
|
<div v-else class="quiet text-body-2">Select a book to view its general ledger.</div>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-dialog v-model="newBookDialog" max-width="560">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>New book</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field v-model="newBook.code" label="Code" hint="Short unique code, e.g. IFRS" persistent-hint />
|
||||||
|
<v-text-field v-model="newBook.name" label="Name" />
|
||||||
|
<v-select v-model="newBook.book_type" :items="bookTypeItems" item-title="title" item-value="value" label="Type" />
|
||||||
|
<v-select v-model="newBook.tax_rule_set_id" :items="ruleSetItems" item-title="title" item-value="value" clearable placeholder="Active set" label="Tax rule set" />
|
||||||
|
<v-select v-model="newBook.default_method" :items="methodItems" item-title="title" item-value="value" label="Default method" />
|
||||||
|
<v-select v-model="newBook.default_convention" :items="conventionItems" item-title="title" item-value="value" label="Default convention" />
|
||||||
|
<v-textarea v-model="newBook.description" class="full" label="Description" rows="2" />
|
||||||
|
<v-switch v-model="newBook.enabled" label="Enabled (apply to assets)" color="primary" density="compact" hide-details />
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="newBookError" type="error" density="compact" class="mt-2">{{ newBookError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="newBookDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :disabled="!newBook.code" :loading="creating" @click="createBook">Create book</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DataTable from '../components/DataTable.vue';
|
||||||
|
import { apiRequest, saveBlob } from '../services/api';
|
||||||
|
import { currency } from '../utils/format';
|
||||||
|
import { conventionItems } from '../constants';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { DataTable },
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true },
|
||||||
|
methodItems: { type: Array, default: () => [] }
|
||||||
|
},
|
||||||
|
emits: ['updated'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
conventionItems,
|
||||||
|
bookTypeItems: [
|
||||||
|
{ title: 'Financial', value: 'financial' },
|
||||||
|
{ title: 'Tax', value: 'tax' },
|
||||||
|
{ title: 'Internal', value: 'internal' }
|
||||||
|
],
|
||||||
|
books: [],
|
||||||
|
ruleSets: [],
|
||||||
|
ledger: null,
|
||||||
|
ledgerBook: 'GAAP',
|
||||||
|
ledgerYear: new Date().getFullYear(),
|
||||||
|
savingId: null,
|
||||||
|
error: '',
|
||||||
|
message: '',
|
||||||
|
newBookDialog: false,
|
||||||
|
newBookError: '',
|
||||||
|
creating: false,
|
||||||
|
newBook: this.blankBook()
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
ruleSetItems() {
|
||||||
|
return this.ruleSets.map((set) => ({ title: `${set.name} · ${set.version}`, value: set.id }));
|
||||||
|
},
|
||||||
|
bookCodeItems() {
|
||||||
|
return this.books.map((book) => book.code);
|
||||||
|
},
|
||||||
|
detailColumns() {
|
||||||
|
const year = this.ledger?.year || this.ledgerYear;
|
||||||
|
if (this.ledger?.kind === 'maintenance') {
|
||||||
|
return [
|
||||||
|
{ key: 'asset_id', label: 'Asset ID' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{ key: 'completions', label: 'Services', format: 'number' },
|
||||||
|
{ key: 'last_service', label: 'Last service', format: 'date' },
|
||||||
|
{ key: 'cost', label: `${year} PM cost`, format: 'currency' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ key: 'asset_id', label: 'Asset ID' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{ key: 'asset_account', label: 'Asset acct' },
|
||||||
|
{ key: 'expense_account', label: 'Expense acct' },
|
||||||
|
{ key: 'cost', label: 'Cost', format: 'currency' },
|
||||||
|
{ key: 'depreciation', label: `${year} depr.`, format: 'currency' },
|
||||||
|
{ key: 'accumulated', label: 'Accum.', format: 'currency' },
|
||||||
|
{ key: 'nbv', label: 'NBV', format: 'currency' }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
currency,
|
||||||
|
blankBook() {
|
||||||
|
return { code: '', name: '', description: '', book_type: 'internal', tax_rule_set_id: null, default_method: 'straight_line', default_convention: 'half_year', enabled: true };
|
||||||
|
},
|
||||||
|
openNewBook() {
|
||||||
|
this.newBook = this.blankBook();
|
||||||
|
this.newBookError = '';
|
||||||
|
this.newBookDialog = true;
|
||||||
|
},
|
||||||
|
async createBook() {
|
||||||
|
this.creating = true;
|
||||||
|
this.newBookError = '';
|
||||||
|
try {
|
||||||
|
await this.api('/api/books', { method: 'POST', body: JSON.stringify(this.newBook) });
|
||||||
|
this.newBookDialog = false;
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.newBookError = error.message;
|
||||||
|
} finally {
|
||||||
|
this.creating = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async removeBook(book) {
|
||||||
|
this.error = '';
|
||||||
|
if (!window.confirm(`Delete book "${book.code}"?`)) return;
|
||||||
|
try {
|
||||||
|
await this.api(`/api/books/${book.id}`, { method: 'DELETE' });
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
const data = await (await this.api('/api/books')).json();
|
||||||
|
this.books = data.books;
|
||||||
|
this.ruleSets = data.ruleSets;
|
||||||
|
if (this.books.length) {
|
||||||
|
if (!this.books.some((book) => book.code === this.ledgerBook)) this.ledgerBook = this.books[0].code;
|
||||||
|
this.loadLedger();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async save(book) {
|
||||||
|
this.savingId = book.id;
|
||||||
|
this.error = '';
|
||||||
|
this.message = '';
|
||||||
|
try {
|
||||||
|
await this.api(`/api/books/${book.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: book.name,
|
||||||
|
book_type: book.book_type,
|
||||||
|
enabled: book.enabled,
|
||||||
|
tax_rule_set_id: book.tax_rule_set_id,
|
||||||
|
default_method: book.default_method,
|
||||||
|
default_convention: book.default_convention
|
||||||
|
})
|
||||||
|
});
|
||||||
|
this.message = `${book.code} saved.`;
|
||||||
|
this.$emit('updated');
|
||||||
|
if (book.code === this.ledgerBook) this.loadLedger();
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.savingId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadLedger() {
|
||||||
|
if (!this.ledgerBook) return;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api(`/api/books/${this.ledgerBook}/ledger?year=${this.ledgerYear}`)).json();
|
||||||
|
this.ledger = data.ledger;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async exportLedger(format) {
|
||||||
|
const blob = await (await this.api(`/api/books/${this.ledgerBook}/ledger/export`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ year: this.ledgerYear, format })
|
||||||
|
})).blob();
|
||||||
|
saveBlob(blob, `mixedassets-${this.ledgerBook}-ledger-${this.ledgerYear}.${format}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ledger-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.ledger-chip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.25);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.ledger-chip strong {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
253
src/views/ContactsView.vue
Normal file
253
src/views/ContactsView.vue
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<template>
|
||||||
|
<section class="content-grid">
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Contacts</h2>
|
||||||
|
<div class="quiet text-body-2">Employees, vendors, service providers, contractors, work locations, and more.</div>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-select v-model="typeFilter" :items="typeFilterItems" item-title="title" item-value="value" label="Type" density="compact" hide-details style="max-width:200px" @update:model-value="load" />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-account-plus" @click="openNew">New contact</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="contacts"
|
||||||
|
row-key="id"
|
||||||
|
:page-size="15"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter contacts"
|
||||||
|
empty-text="No contacts yet."
|
||||||
|
>
|
||||||
|
<template #cell-display_name="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.display_name }}</span>
|
||||||
|
<v-chip v-if="row.source === 'workday'" size="x-small" variant="tonal" class="ml-2">Workday</v-chip>
|
||||||
|
</template>
|
||||||
|
<template #cell-type="{ row }">{{ typeLabel(row.type) }}</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
|
||||||
|
<v-btn v-if="canDelete" icon="mdi-delete-outline" size="small" variant="text" color="error" @click="remove(row)" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-dialog v-model="dialog" max-width="760" scrollable>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ form.id ? 'Edit contact' : 'New contact' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-select v-model="form.type" :items="contactTypeItems" item-title="title" item-value="value" label="Type" />
|
||||||
|
<v-select v-model="form.status" :items="['active', 'inactive']" label="Status" />
|
||||||
|
|
||||||
|
<template v-if="showName">
|
||||||
|
<v-text-field v-model="form.first_name" label="First name" />
|
||||||
|
<v-text-field v-model="form.last_name" label="Last name" />
|
||||||
|
</template>
|
||||||
|
<v-text-field v-if="showOrg" v-model="form.organization" class="full" :label="form.type === 'work_location' ? 'Location name' : 'Organization'" />
|
||||||
|
|
||||||
|
<v-text-field v-model="form.email" label="Email" />
|
||||||
|
<v-text-field v-model="form.phone" label="Phone (international)" placeholder="+1 555-0100" />
|
||||||
|
|
||||||
|
<!-- Employee / contractor job info -->
|
||||||
|
<template v-if="showJob">
|
||||||
|
<v-text-field v-if="form.type === 'employee'" v-model="form.employee_id" label="Employee ID" />
|
||||||
|
<v-text-field v-if="form.type === 'contractor'" v-model="form.tax_id" label="Tax ID / business license #" />
|
||||||
|
<v-text-field v-model="form.department" label="Department" />
|
||||||
|
<v-text-field v-model="form.work_location" label="Work location" />
|
||||||
|
<v-text-field v-model="form.start_date" type="date" label="Start date" />
|
||||||
|
<v-text-field v-model="form.manager_first_name" label="Manager first name" />
|
||||||
|
<v-text-field v-model="form.manager_last_name" label="Manager last name" />
|
||||||
|
<v-text-field v-model="form.manager_email" label="Manager email" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Vendor / service provider -->
|
||||||
|
<template v-if="showVendor">
|
||||||
|
<div>
|
||||||
|
<div class="quiet text-caption mb-1">Rating</div>
|
||||||
|
<v-rating v-model="form.rating" length="5" color="amber" active-color="amber" half-increments="false" density="compact" clearable />
|
||||||
|
</div>
|
||||||
|
<v-select v-model="form.credit_term" :items="creditTermItems" item-title="title" item-value="value" label="Credit term" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<h3 class="section-title mb-2">Address</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field v-model="form.address_line1" class="full" label="Address line 1" />
|
||||||
|
<v-text-field v-model="form.address_line2" class="full" label="Address line 2" />
|
||||||
|
<v-text-field v-model="form.city" label="City / locality" />
|
||||||
|
<v-text-field v-model="form.state_region" label="State / region / province" />
|
||||||
|
<v-text-field v-model="form.postal_code" label="Postal code" />
|
||||||
|
<v-text-field v-model="form.country" label="Country" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-textarea v-model="form.notes" class="mt-3" label="Notes" rows="2" />
|
||||||
|
|
||||||
|
<template v-if="form.id">
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<div class="toolbar-row mb-2">
|
||||||
|
<h3 class="section-title">Notes log</h3>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-row">
|
||||||
|
<v-text-field v-model="noteDraft" label="Add a note" density="compact" hide-details @keyup.enter="addNote" />
|
||||||
|
<v-btn variant="tonal" prepend-icon="mdi-plus" :disabled="!noteDraft.trim()" @click="addNote">Add</v-btn>
|
||||||
|
</div>
|
||||||
|
<v-list v-if="form.contact_notes && form.contact_notes.length" density="compact">
|
||||||
|
<v-list-item v-for="note in form.contact_notes" :key="note.id">
|
||||||
|
<v-list-item-title style="white-space:normal">{{ note.body }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ note.author || 'Unknown' }} · {{ note.created_at }}</v-list-item-subtitle>
|
||||||
|
<template #append>
|
||||||
|
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="deleteNote(note.id)" />
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-alert v-if="dialogError" type="error" density="compact" class="mt-3">{{ dialogError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Save contact</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DataTable from '../components/DataTable.vue';
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
import { contactTypeItems, creditTermItems } from '../constants';
|
||||||
|
import { clonePlain } from '../utils/clone';
|
||||||
|
|
||||||
|
function blankContact() {
|
||||||
|
return {
|
||||||
|
id: null, type: 'employee', status: 'active', first_name: '', last_name: '', organization: '',
|
||||||
|
email: '', phone: '', address_line1: '', address_line2: '', city: '', state_region: '', postal_code: '', country: '',
|
||||||
|
notes: '', employee_id: '', tax_id: '', department: '', work_location: '',
|
||||||
|
manager_first_name: '', manager_last_name: '', manager_email: '', start_date: '', rating: null, credit_term: 'na',
|
||||||
|
contact_notes: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { DataTable },
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true },
|
||||||
|
canDelete: { type: Boolean, default: false }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
contactTypeItems,
|
||||||
|
creditTermItems,
|
||||||
|
contacts: [],
|
||||||
|
typeFilter: null,
|
||||||
|
dialog: false,
|
||||||
|
form: blankContact(),
|
||||||
|
noteDraft: '',
|
||||||
|
saving: false,
|
||||||
|
error: '',
|
||||||
|
dialogError: '',
|
||||||
|
columns: [
|
||||||
|
{ key: 'display_name', label: 'Name / organization' },
|
||||||
|
{ key: 'type', label: 'Type' },
|
||||||
|
{ key: 'email', label: 'Email' },
|
||||||
|
{ key: 'phone', label: 'Phone' },
|
||||||
|
{ key: 'department', label: 'Department' },
|
||||||
|
{ key: 'work_location', label: 'Location' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
typeFilterItems() {
|
||||||
|
return [{ title: 'All types', value: null }, ...contactTypeItems];
|
||||||
|
},
|
||||||
|
showName() {
|
||||||
|
return ['employee', 'contractor', 'other'].includes(this.form.type);
|
||||||
|
},
|
||||||
|
showOrg() {
|
||||||
|
return ['vendor', 'service_provider', 'work_location', 'other'].includes(this.form.type);
|
||||||
|
},
|
||||||
|
showJob() {
|
||||||
|
return ['employee', 'contractor'].includes(this.form.type);
|
||||||
|
},
|
||||||
|
showVendor() {
|
||||||
|
return ['vendor', 'service_provider'].includes(this.form.type);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
typeLabel(type) {
|
||||||
|
return (contactTypeItems.find((t) => t.value === type) || {}).title || type;
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const query = this.typeFilter ? `?type=${this.typeFilter}` : '';
|
||||||
|
const data = await (await this.api(`/api/contacts${query}`)).json();
|
||||||
|
this.contacts = data.contacts;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openNew() {
|
||||||
|
this.form = blankContact();
|
||||||
|
this.dialogError = '';
|
||||||
|
this.noteDraft = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
async openEdit(contact) {
|
||||||
|
this.dialogError = '';
|
||||||
|
this.noteDraft = '';
|
||||||
|
const data = await (await this.api(`/api/contacts/${contact.id}`)).json();
|
||||||
|
this.form = { ...blankContact(), ...clonePlain(data.contact) };
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
this.saving = true;
|
||||||
|
this.dialogError = '';
|
||||||
|
try {
|
||||||
|
const method = this.form.id ? 'PUT' : 'POST';
|
||||||
|
const url = this.form.id ? `/api/contacts/${this.form.id}` : '/api/contacts';
|
||||||
|
const data = await (await this.api(url, { method, body: JSON.stringify(this.form) })).json();
|
||||||
|
this.form = { ...blankContact(), ...data.contact };
|
||||||
|
await this.load();
|
||||||
|
} catch (error) {
|
||||||
|
this.dialogError = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async remove(contact) {
|
||||||
|
if (!window.confirm(`Delete ${contact.display_name}?`)) return;
|
||||||
|
try {
|
||||||
|
await this.api(`/api/contacts/${contact.id}`, { method: 'DELETE' });
|
||||||
|
await this.load();
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async addNote() {
|
||||||
|
const body = this.noteDraft.trim();
|
||||||
|
if (!body || !this.form.id) return;
|
||||||
|
await this.api(`/api/contacts/${this.form.id}/notes`, { method: 'POST', body: JSON.stringify({ body }) });
|
||||||
|
this.noteDraft = '';
|
||||||
|
const data = await (await this.api(`/api/contacts/${this.form.id}`)).json();
|
||||||
|
this.form.contact_notes = data.contact.contact_notes;
|
||||||
|
},
|
||||||
|
async deleteNote(noteId) {
|
||||||
|
await this.api(`/api/contacts/${this.form.id}/notes/${noteId}`, { method: 'DELETE' });
|
||||||
|
this.form.contact_notes = this.form.contact_notes.filter((n) => n.id !== noteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -28,6 +28,34 @@
|
|||||||
<div class="chart-box">
|
<div class="chart-box">
|
||||||
<Bar :data="categoryChart" :options="chartOptions" />
|
<Bar :data="categoryChart" :options="chartOptions" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-4" style="overflow-x:auto">
|
||||||
|
<table class="asset-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Category</th>
|
||||||
|
<th class="text-right">Assets</th>
|
||||||
|
<th class="text-right">Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in dashboard.byCategory" :key="row.category">
|
||||||
|
<td class="font-weight-bold">{{ row.category }}</td>
|
||||||
|
<td class="text-right">{{ row.count }}</td>
|
||||||
|
<td class="text-right">{{ currency(row.value) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!dashboard.byCategory.length">
|
||||||
|
<td colspan="3" class="quiet">No assets yet.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot v-if="dashboard.byCategory.length">
|
||||||
|
<tr>
|
||||||
|
<td class="font-weight-bold">Total</td>
|
||||||
|
<td class="text-right font-weight-bold">{{ totalAssets }}</td>
|
||||||
|
<td class="text-right font-weight-bold">{{ currency(totalValue) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<v-card class="span-5 panel-pad">
|
<v-card class="span-5 panel-pad">
|
||||||
@@ -56,6 +84,14 @@ export default {
|
|||||||
currency: { type: Function, required: true },
|
currency: { type: Function, required: true },
|
||||||
dashboard: { type: Object, required: true },
|
dashboard: { type: Object, required: true },
|
||||||
shortDate: { type: Function, required: true }
|
shortDate: { type: Function, required: true }
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
totalAssets() {
|
||||||
|
return this.dashboard.byCategory.reduce((sum, row) => sum + Number(row.count || 0), 0);
|
||||||
|
},
|
||||||
|
totalValue() {
|
||||||
|
return this.dashboard.byCategory.reduce((sum, row) => sum + Number(row.value || 0), 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
509
src/views/PartsView.vue
Normal file
509
src/views/PartsView.vue
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
<template>
|
||||||
|
<section class="content-grid">
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Parts inventory</h2>
|
||||||
|
<div class="quiet text-body-2">Maintenance parts & supplies — stock levels, aisle/bin locations, and reservations. Separate from the depreciation register.</div>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-select
|
||||||
|
v-model="categoryFilter"
|
||||||
|
:items="categoryFilterItems"
|
||||||
|
label="Category"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
style="max-width:200px"
|
||||||
|
@update:model-value="load"
|
||||||
|
/>
|
||||||
|
<v-select
|
||||||
|
v-model="statusFilter"
|
||||||
|
:items="statusFilterItems"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
label="Status"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
style="max-width:170px"
|
||||||
|
@update:model-value="load"
|
||||||
|
/>
|
||||||
|
<v-switch v-model="lowStockOnly" label="Low stock" color="warning" density="compact" hide-details inset @update:model-value="load" />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New part</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kpi-row mb-3">
|
||||||
|
<div class="kpi"><span class="kpi-value">{{ parts.length }}</span><span class="kpi-label">Parts</span></div>
|
||||||
|
<div class="kpi"><span class="kpi-value">{{ totalOnHand }}</span><span class="kpi-label">Units on hand</span></div>
|
||||||
|
<div class="kpi"><span class="kpi-value">{{ totalReserved }}</span><span class="kpi-label">Reserved</span></div>
|
||||||
|
<div class="kpi"><span class="kpi-value">{{ currency(totalValue) }}</span><span class="kpi-label">Stock value</span></div>
|
||||||
|
<div class="kpi"><span class="kpi-value text-warning">{{ lowStockCount }}</span><span class="kpi-label">Below reorder</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="parts"
|
||||||
|
row-key="id"
|
||||||
|
:page-size="15"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter parts"
|
||||||
|
empty-text="No parts yet."
|
||||||
|
>
|
||||||
|
<template #cell-part_number="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.part_number }}</span>
|
||||||
|
<v-chip v-if="row.low_stock" size="x-small" color="warning" variant="flat" class="ml-2">Low</v-chip>
|
||||||
|
<v-chip v-if="row.status !== 'active'" size="x-small" variant="tonal" class="ml-2">{{ row.status }}</v-chip>
|
||||||
|
</template>
|
||||||
|
<template #cell-available="{ row }">
|
||||||
|
<span :class="{ 'text-warning font-weight-bold': row.low_stock }">{{ row.available }}</span>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
|
||||||
|
<v-btn v-if="canDelete" icon="mdi-delete-outline" size="small" variant="text" color="error" @click="remove(row)" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-dialog v-model="dialog" max-width="920" scrollable>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ form.id ? `Edit part · ${form.part_number}` : 'New part' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field v-model="form.part_number" label="Part number *" />
|
||||||
|
<v-text-field v-model="form.name" label="Name *" />
|
||||||
|
<v-combobox v-model="form.category" :items="categoryOptions" label="Category" />
|
||||||
|
<v-select v-model="form.status" :items="partStatusItems" item-title="title" item-value="value" label="Status" />
|
||||||
|
<v-combobox v-model="form.unit_of_measure" :items="unitOfMeasureItems" label="Unit of measure" />
|
||||||
|
<v-text-field v-model.number="form.unit_cost" type="number" label="Unit cost" prefix="$" />
|
||||||
|
<v-text-field v-model.number="form.reorder_point" type="number" label="Reorder point" hint="Flag low stock at/below this" persistent-hint />
|
||||||
|
<v-text-field v-model.number="form.reorder_quantity" type="number" label="Reorder quantity" />
|
||||||
|
<v-text-field v-model="form.manufacturer" label="Manufacturer" />
|
||||||
|
<v-text-field v-model="form.manufacturer_part_number" label="Manufacturer part #" />
|
||||||
|
<v-text-field v-model="form.barcode" label="Barcode / UPC" />
|
||||||
|
<v-autocomplete
|
||||||
|
v-model="form.supplier_contact_id"
|
||||||
|
:items="supplierItems"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
label="Supplier (from contacts)"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-textarea v-model="form.measurements" class="mt-3" label="Measurements / specifications" rows="2" placeholder="e.g. 12mm x 50mm, M8 thread, 24V" />
|
||||||
|
<v-textarea v-model="form.description" class="mt-2" label="Description / identification notes" rows="2" />
|
||||||
|
<v-textarea v-model="form.notes" class="mt-2" label="Internal notes" rows="2" />
|
||||||
|
|
||||||
|
<template v-if="form.id">
|
||||||
|
<!-- ---- Stock locations ---- -->
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<div class="toolbar-row mb-2">
|
||||||
|
<h3 class="section-title">Stock locations</h3>
|
||||||
|
<v-spacer />
|
||||||
|
<v-chip size="small" variant="tonal">On hand {{ form.on_hand }} · Reserved {{ form.reserved }} · Available {{ form.available }}</v-chip>
|
||||||
|
</div>
|
||||||
|
<v-table density="compact" class="mb-3">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Location</th><th>Aisle</th><th>Bin</th>
|
||||||
|
<th class="text-right">On hand</th><th class="text-right">Reserved</th><th class="text-right">Available</th><th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="s in form.stock" :key="s.id">
|
||||||
|
<td>{{ s.location_name }}</td>
|
||||||
|
<td>{{ s.aisle }}</td>
|
||||||
|
<td>{{ s.bin }}</td>
|
||||||
|
<td class="text-right">{{ s.quantity }}</td>
|
||||||
|
<td class="text-right">{{ s.reserved }}</td>
|
||||||
|
<td class="text-right">{{ s.available }}</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<v-btn icon="mdi-pencil" size="x-small" variant="text" @click="editStock(s)" />
|
||||||
|
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="removeStock(s)" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!form.stock.length"><td colspan="7" class="quiet">No stock locations yet.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-select v-model="stockDraft.location_contact_id" :items="locationItems" item-title="title" item-value="value" label="Work location" clearable />
|
||||||
|
<v-text-field v-model="stockDraft.aisle" label="Aisle" />
|
||||||
|
<v-text-field v-model="stockDraft.bin" label="Bin" />
|
||||||
|
<v-text-field v-model.number="stockDraft.quantity" type="number" label="On hand" />
|
||||||
|
<v-text-field v-model.number="stockDraft.reserved" type="number" label="Reserved" />
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-btn variant="tonal" prepend-icon="mdi-content-save" @click="saveStock">{{ stockDraft.id ? 'Update location' : 'Add location' }}</v-btn>
|
||||||
|
<v-btn v-if="stockDraft.id" variant="text" class="ml-1" @click="resetStockDraft">Cancel</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ---- Stock movements ---- -->
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<h3 class="section-title mb-2">Record movement</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-select v-model="txDraft.stock_id" :items="stockLocationItems" item-title="title" item-value="value" label="Stock location" />
|
||||||
|
<v-select v-model="txDraft.type" :items="partTransactionTypes" item-title="title" item-value="value" label="Movement" />
|
||||||
|
<v-text-field v-model.number="txDraft.quantity" type="number" :label="txDraft.type === 'adjust' ? 'New on-hand count' : 'Quantity'" />
|
||||||
|
<v-autocomplete v-model="txDraft.asset_id" :items="assetItems" item-title="title" item-value="value" label="Asset (optional)" clearable />
|
||||||
|
<v-text-field v-model="txDraft.notes" class="full" label="Notes / reference" />
|
||||||
|
</div>
|
||||||
|
<div class="text-right mb-3">
|
||||||
|
<v-btn color="primary" variant="tonal" prepend-icon="mdi-swap-vertical" :disabled="!txDraft.stock_id" @click="recordTransaction">Apply movement</v-btn>
|
||||||
|
</div>
|
||||||
|
<v-table v-if="form.transactions && form.transactions.length" density="compact" class="movement-log">
|
||||||
|
<thead>
|
||||||
|
<tr><th>When</th><th>Movement</th><th class="text-right">Qty</th><th>Location</th><th>Asset</th><th>By</th><th>Notes</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="t in form.transactions" :key="t.id">
|
||||||
|
<td>{{ shortDate(t.created_at) }}</td>
|
||||||
|
<td>{{ txLabel(t.type) }}</td>
|
||||||
|
<td class="text-right">{{ t.quantity }}</td>
|
||||||
|
<td>{{ t.location_name }}<span v-if="t.aisle"> · {{ t.aisle }}/{{ t.bin }}</span></td>
|
||||||
|
<td>{{ t.asset_code }}</td>
|
||||||
|
<td>{{ t.created_by_name }}</td>
|
||||||
|
<td style="white-space:normal">{{ t.notes }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
|
||||||
|
<!-- ---- Photos ---- -->
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<div class="toolbar-row mb-2">
|
||||||
|
<h3 class="section-title">Photos</h3>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="tonal" prepend-icon="mdi-camera-plus-outline" @click="$refs.photoInput.click()">Add photo</v-btn>
|
||||||
|
<input ref="photoInput" type="file" accept="image/*" hidden @change="onPhoto" />
|
||||||
|
</div>
|
||||||
|
<div v-if="form.photos && form.photos.length" class="photo-grid">
|
||||||
|
<div v-for="p in form.photos" :key="p.id" class="photo-tile">
|
||||||
|
<img :src="p.data" :alt="p.name || 'part photo'" />
|
||||||
|
<v-btn icon="mdi-close" size="x-small" color="error" variant="flat" class="photo-remove" @click="removePhoto(p.id)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="quiet text-body-2">No photos yet.</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<v-alert v-if="dialogError" type="error" density="compact" class="mt-3">{{ dialogError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="dialog = false">Close</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">{{ form.id ? 'Save changes' : 'Create part' }}</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DataTable from '../components/DataTable.vue';
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
import { currency, shortDate } from '../utils/format';
|
||||||
|
import { clonePlain } from '../utils/clone';
|
||||||
|
import { partStatusItems, partTransactionTypes, unitOfMeasureItems } from '../constants';
|
||||||
|
|
||||||
|
function blankPart() {
|
||||||
|
return {
|
||||||
|
id: null, part_number: '', name: '', description: '', category: '', manufacturer: '',
|
||||||
|
manufacturer_part_number: '', barcode: '', unit_of_measure: 'each', unit_cost: 0,
|
||||||
|
reorder_point: 0, reorder_quantity: 0, measurements: '', supplier_contact_id: null,
|
||||||
|
status: 'active', notes: '',
|
||||||
|
on_hand: 0, reserved: 0, available: 0, stock: [], photos: [], transactions: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankStockDraft() {
|
||||||
|
return { id: null, location_contact_id: null, aisle: '', bin: '', quantity: 0, reserved: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankTxDraft() {
|
||||||
|
return { stock_id: null, type: 'receive', quantity: 1, asset_id: null, notes: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { DataTable },
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true },
|
||||||
|
canDelete: { type: Boolean, default: false }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
partStatusItems,
|
||||||
|
partTransactionTypes,
|
||||||
|
unitOfMeasureItems,
|
||||||
|
parts: [],
|
||||||
|
contacts: [],
|
||||||
|
assets: [],
|
||||||
|
managedCategories: [],
|
||||||
|
categoryFilter: null,
|
||||||
|
statusFilter: null,
|
||||||
|
lowStockOnly: false,
|
||||||
|
dialog: false,
|
||||||
|
form: blankPart(),
|
||||||
|
stockDraft: blankStockDraft(),
|
||||||
|
txDraft: blankTxDraft(),
|
||||||
|
saving: false,
|
||||||
|
error: '',
|
||||||
|
dialogError: '',
|
||||||
|
columns: [
|
||||||
|
{ key: 'part_number', label: 'Part #' },
|
||||||
|
{ key: 'name', label: 'Name' },
|
||||||
|
{ key: 'category', label: 'Category' },
|
||||||
|
{ key: 'supplier_name', label: 'Supplier' },
|
||||||
|
{ key: 'on_hand', label: 'On hand', format: 'number' },
|
||||||
|
{ key: 'reserved', label: 'Reserved', format: 'number' },
|
||||||
|
{ key: 'available', label: 'Available', format: 'number' },
|
||||||
|
{ key: 'location_count', label: 'Locations', format: 'number' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
categoryOptions() {
|
||||||
|
// Admin-managed parts categories, plus any value already in use so existing parts stay selectable.
|
||||||
|
const inUse = this.parts.map((p) => p.category).filter(Boolean);
|
||||||
|
return [...new Set([...this.managedCategories, ...inUse])].sort((a, b) => String(a).localeCompare(String(b)));
|
||||||
|
},
|
||||||
|
categoryFilterItems() {
|
||||||
|
return ['All categories', ...this.categoryOptions].map((c) => (c === 'All categories' ? { title: c, value: null } : c));
|
||||||
|
},
|
||||||
|
statusFilterItems() {
|
||||||
|
return [{ title: 'All statuses', value: null }, ...partStatusItems];
|
||||||
|
},
|
||||||
|
supplierItems() {
|
||||||
|
return this.contacts
|
||||||
|
.filter((c) => ['vendor', 'service_provider', 'contractor'].includes(c.type))
|
||||||
|
.map((c) => ({ title: c.display_name, value: c.id }));
|
||||||
|
},
|
||||||
|
locationItems() {
|
||||||
|
return this.contacts
|
||||||
|
.filter((c) => c.type === 'work_location')
|
||||||
|
.map((c) => ({ title: c.display_name, value: c.id }));
|
||||||
|
},
|
||||||
|
assetItems() {
|
||||||
|
return this.assets.map((a) => ({ title: `${a.asset_id} · ${a.description}`, value: a.id }));
|
||||||
|
},
|
||||||
|
stockLocationItems() {
|
||||||
|
return (this.form.stock || []).map((s) => ({
|
||||||
|
title: `${s.location_name}${s.aisle ? ` · ${s.aisle}/${s.bin || '—'}` : ''} (on hand ${s.quantity})`,
|
||||||
|
value: s.id
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
totalOnHand() {
|
||||||
|
return this.parts.reduce((sum, p) => sum + Number(p.on_hand || 0), 0);
|
||||||
|
},
|
||||||
|
totalReserved() {
|
||||||
|
return this.parts.reduce((sum, p) => sum + Number(p.reserved || 0), 0);
|
||||||
|
},
|
||||||
|
totalValue() {
|
||||||
|
return this.parts.reduce((sum, p) => sum + Number(p.stock_value || 0), 0);
|
||||||
|
},
|
||||||
|
lowStockCount() {
|
||||||
|
return this.parts.filter((p) => p.low_stock).length;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
this.loadContacts();
|
||||||
|
this.loadAssets();
|
||||||
|
this.loadCategories();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
currency,
|
||||||
|
shortDate,
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
txLabel(type) {
|
||||||
|
return (partTransactionTypes.find((t) => t.value === type) || {}).title || type;
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (this.categoryFilter) params.set('category', this.categoryFilter);
|
||||||
|
if (this.statusFilter) params.set('status', this.statusFilter);
|
||||||
|
if (this.lowStockOnly) params.set('low_stock', 'true');
|
||||||
|
const data = await (await this.api(`/api/parts?${params.toString()}`)).json();
|
||||||
|
this.parts = data.parts;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadContacts() {
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/contacts')).json();
|
||||||
|
this.contacts = data.contacts;
|
||||||
|
} catch {
|
||||||
|
this.contacts = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadAssets() {
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/assets')).json();
|
||||||
|
this.assets = data.assets;
|
||||||
|
} catch {
|
||||||
|
this.assets = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadCategories() {
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/part-categories')).json();
|
||||||
|
this.managedCategories = (data.categories || []).map((category) => category.name).filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
this.managedCategories = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openNew() {
|
||||||
|
this.form = blankPart();
|
||||||
|
this.resetStockDraft();
|
||||||
|
this.txDraft = blankTxDraft();
|
||||||
|
this.dialogError = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
async openEdit(part) {
|
||||||
|
this.dialogError = '';
|
||||||
|
this.resetStockDraft();
|
||||||
|
this.txDraft = blankTxDraft();
|
||||||
|
const data = await (await this.api(`/api/parts/${part.id}`)).json();
|
||||||
|
this.form = { ...blankPart(), ...clonePlain(data.part) };
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
setForm(part) {
|
||||||
|
this.form = { ...blankPart(), ...clonePlain(part) };
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
this.saving = true;
|
||||||
|
this.dialogError = '';
|
||||||
|
try {
|
||||||
|
const method = this.form.id ? 'PUT' : 'POST';
|
||||||
|
const url = this.form.id ? `/api/parts/${this.form.id}` : '/api/parts';
|
||||||
|
const data = await (await this.api(url, { method, body: JSON.stringify(this.form) })).json();
|
||||||
|
this.setForm(data.part);
|
||||||
|
await this.load();
|
||||||
|
} catch (error) {
|
||||||
|
this.dialogError = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async remove(part) {
|
||||||
|
if (!window.confirm(`Delete part ${part.part_number}?`)) return;
|
||||||
|
try {
|
||||||
|
await this.api(`/api/parts/${part.id}`, { method: 'DELETE' });
|
||||||
|
await this.load();
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resetStockDraft() {
|
||||||
|
this.stockDraft = blankStockDraft();
|
||||||
|
},
|
||||||
|
editStock(s) {
|
||||||
|
this.stockDraft = { id: s.id, location_contact_id: s.location_contact_id, aisle: s.aisle, bin: s.bin, quantity: s.quantity, reserved: s.reserved };
|
||||||
|
},
|
||||||
|
async saveStock() {
|
||||||
|
this.dialogError = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api(`/api/parts/${this.form.id}/stock`, { method: 'POST', body: JSON.stringify(this.stockDraft) })).json();
|
||||||
|
this.setForm(data.part);
|
||||||
|
this.resetStockDraft();
|
||||||
|
await this.load();
|
||||||
|
} catch (error) {
|
||||||
|
this.dialogError = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async removeStock(s) {
|
||||||
|
if (!window.confirm('Remove this stock location?')) return;
|
||||||
|
const data = await (await this.api(`/api/parts/${this.form.id}/stock/${s.id}`, { method: 'DELETE' })).json();
|
||||||
|
this.setForm(data.part);
|
||||||
|
await this.load();
|
||||||
|
},
|
||||||
|
async recordTransaction() {
|
||||||
|
this.dialogError = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api(`/api/parts/${this.form.id}/transactions`, { method: 'POST', body: JSON.stringify(this.txDraft) })).json();
|
||||||
|
this.setForm(data.part);
|
||||||
|
this.txDraft = blankTxDraft();
|
||||||
|
await this.load();
|
||||||
|
} catch (error) {
|
||||||
|
this.dialogError = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPhoto(event) {
|
||||||
|
const [file] = event.target.files || [];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async () => {
|
||||||
|
try {
|
||||||
|
const data = await (await this.api(`/api/parts/${this.form.id}/photos`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name: file.name, mime: file.type, data: reader.result })
|
||||||
|
})).json();
|
||||||
|
this.setForm(data.part);
|
||||||
|
} catch (error) {
|
||||||
|
this.dialogError = error.message;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
event.target.value = '';
|
||||||
|
},
|
||||||
|
async removePhoto(id) {
|
||||||
|
const data = await (await this.api(`/api/parts/${this.form.id}/photos/${id}`, { method: 'DELETE' })).json();
|
||||||
|
this.setForm(data.part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.kpi-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.kpi {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(127, 127, 127, 0.08);
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
.kpi-value {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.kpi-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.photo-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.photo-tile {
|
||||||
|
position: relative;
|
||||||
|
width: 110px;
|
||||||
|
height: 110px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(127, 127, 127, 0.25);
|
||||||
|
}
|
||||||
|
.photo-tile img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.photo-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
}
|
||||||
|
.movement-log {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
363
src/views/PmView.vue
Normal file
363
src/views/PmView.vue
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
<template>
|
||||||
|
<section class="content-grid">
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Preventative maintenance plans</h2>
|
||||||
|
<div class="quiet text-body-2">Reusable maintenance procedures with a frequency and step checklist. Attach plans to assets from the asset's PM tab.</div>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-plus" @click="openNew">New plan</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:columns="columns"
|
||||||
|
:rows="plans"
|
||||||
|
row-key="id"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter plans"
|
||||||
|
empty-text="No PM plans yet. Create one to get started."
|
||||||
|
>
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.name }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-frequency="{ row }">every {{ row.frequency_value }} {{ unitLabel(row.frequency_unit) }}</template>
|
||||||
|
<template #cell-steps="{ row }">{{ row.steps.length }}</template>
|
||||||
|
<template #cell-estimated_minutes="{ row }">{{ row.estimated_minutes ? row.estimated_minutes + ' min' : '—' }}</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
|
||||||
|
<v-btn icon="mdi-delete-outline" size="small" variant="text" color="error" @click="remove(row)" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card class="span-12 panel-pad">
|
||||||
|
<h2 class="section-title mb-1">Completed PM forms</h2>
|
||||||
|
<p class="quiet text-body-2 mb-3">Signed completion records with condition ratings, notes, and photos.</p>
|
||||||
|
<DataTable
|
||||||
|
:columns="completionColumns"
|
||||||
|
:rows="completions"
|
||||||
|
row-key="id"
|
||||||
|
:page-size="15"
|
||||||
|
has-actions
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter completions"
|
||||||
|
empty-text="No completed PM forms yet."
|
||||||
|
>
|
||||||
|
<template #cell-completed_at="{ row }">{{ (row.completed_at || '').slice(0, 10) }}</template>
|
||||||
|
<template #cell-rating_safety="{ row }">{{ row.rating_safety ? row.rating_safety + '★' : '—' }}</template>
|
||||||
|
<template #cell-photo_count="{ row }">{{ row.photo_count }}</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn size="small" variant="text" prepend-icon="mdi-file-eye-outline" @click="viewCompletion(row.id)">View</v-btn>
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<PmCompletionDialog v-model="completionDialog" :token="token" :completion-id="completionId" />
|
||||||
|
|
||||||
|
<v-dialog v-model="dialog" max-width="720" scrollable>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>{{ form.id ? 'Edit PM plan' : 'New PM plan' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-text-field v-model="form.name" class="full" label="Plan name" />
|
||||||
|
<v-combobox v-model="form.category" :items="categoryItems" label="Category" clearable />
|
||||||
|
<v-text-field :model-value="totalStepMinutes" type="number" label="Estimated minutes (from steps)" readonly hint="Auto-totaled from step times" persistent-hint />
|
||||||
|
<v-text-field v-model.number="form.frequency_value" type="number" label="Every" />
|
||||||
|
<v-select v-model="form.frequency_unit" :items="pmFrequencyUnits" item-title="title" item-value="value" label="Frequency unit" />
|
||||||
|
<v-textarea v-model="form.description" class="full" label="Description" rows="2" />
|
||||||
|
<v-textarea v-model="form.instructions" class="full" label="Safety / general instructions" rows="2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-row mt-4 mb-1">
|
||||||
|
<h3 class="section-title">Steps</h3>
|
||||||
|
<v-spacer />
|
||||||
|
<span class="quiet text-caption">Total {{ totalStepMinutes }} min</span>
|
||||||
|
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="addStep">Add step</v-btn>
|
||||||
|
</div>
|
||||||
|
<p v-if="!form.steps.length" class="quiet text-body-2">No steps yet — add the procedure steps (e.g. "Tighten lug bolt A3").</p>
|
||||||
|
<div v-for="(step, index) in form.steps" :key="index" class="pm-step">
|
||||||
|
<div class="pm-step-num">{{ index + 1 }}</div>
|
||||||
|
<div class="pm-step-fields">
|
||||||
|
<div class="d-flex" style="gap:8px">
|
||||||
|
<v-text-field v-model="step.title" density="compact" hide-details placeholder="Step title" style="flex:1 1 auto" />
|
||||||
|
<v-text-field v-model.number="step.est_minutes" type="number" density="compact" hide-details placeholder="Min" style="max-width:88px" suffix="min" />
|
||||||
|
</div>
|
||||||
|
<v-text-field v-model="step.details" density="compact" hide-details placeholder="Details (optional)" class="mt-1" />
|
||||||
|
</div>
|
||||||
|
<div class="pm-step-actions">
|
||||||
|
<v-btn icon="mdi-arrow-up" size="x-small" variant="text" :disabled="index === 0" @click="moveStep(index, -1)" />
|
||||||
|
<v-btn icon="mdi-arrow-down" size="x-small" variant="text" :disabled="index === form.steps.length - 1" @click="moveStep(index, 1)" />
|
||||||
|
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="form.steps.splice(index, 1)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-row mt-4 mb-1">
|
||||||
|
<h3 class="section-title">Components / parts</h3>
|
||||||
|
<v-spacer />
|
||||||
|
<span class="quiet text-caption">Total {{ currency(totalComponentCost) }}</span>
|
||||||
|
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="addComponent">Add component</v-btn>
|
||||||
|
</div>
|
||||||
|
<p v-if="!form.components.length" class="quiet text-body-2">No components — add parts (part #, description, supplier, cost) to track PM cost.</p>
|
||||||
|
<div v-for="(component, index) in form.components" :key="`c${index}`" class="pm-component">
|
||||||
|
<v-text-field v-model="component.part_number" density="compact" hide-details placeholder="Part #" style="max-width:120px" />
|
||||||
|
<v-text-field v-model="component.description" density="compact" hide-details placeholder="Description" style="flex:1 1 auto" />
|
||||||
|
<v-text-field v-model="component.supplier" density="compact" hide-details placeholder="Supplier" style="max-width:140px" />
|
||||||
|
<v-text-field v-model.number="component.cost" type="number" density="compact" hide-details placeholder="Cost" prefix="$" style="max-width:110px" />
|
||||||
|
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="form.components.splice(index, 1)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-row mt-4 mb-1">
|
||||||
|
<h3 class="section-title">Guidance photos</h3>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="small" variant="tonal" prepend-icon="mdi-camera-plus-outline" @click="$refs.planPhotoInput.click()">Add photos</v-btn>
|
||||||
|
<input ref="planPhotoInput" hidden type="file" accept="image/*" capture="environment" multiple @change="onPhotoPick" />
|
||||||
|
</div>
|
||||||
|
<p v-if="!form.photos.length" class="quiet text-body-2">Add reference photos (diagrams, part locations, before/after) shown to technicians during the service.</p>
|
||||||
|
<div v-else class="pm-plan-photo-grid">
|
||||||
|
<div v-for="(photo, index) in form.photos" :key="photo.id || `np${index}`" class="pm-plan-photo">
|
||||||
|
<div class="pm-plan-photo-frame">
|
||||||
|
<img :src="photo.data" :alt="photo.caption || 'guidance'" />
|
||||||
|
<v-btn icon="mdi-close" size="x-small" color="error" variant="flat" class="pm-plan-photo-remove" @click="form.photos.splice(index, 1)" />
|
||||||
|
</div>
|
||||||
|
<v-text-field v-model="photo.caption" density="compact" hide-details placeholder="Caption" class="mt-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :disabled="!form.name" :loading="saving" @click="save">Save plan</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DataTable from '../components/DataTable.vue';
|
||||||
|
import PmCompletionDialog from '../components/PmCompletionDialog.vue';
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
import { pmFrequencyUnits } from '../constants';
|
||||||
|
import { currency } from '../utils/format';
|
||||||
|
import { clonePlain } from '../utils/clone';
|
||||||
|
|
||||||
|
function blankPlan() {
|
||||||
|
return { id: null, name: '', description: '', category: '', frequency_value: 3, frequency_unit: 'months', estimated_minutes: null, instructions: '', steps: [], components: [], photos: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { DataTable, PmCompletionDialog },
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
pmFrequencyUnits,
|
||||||
|
plans: [],
|
||||||
|
pmCategories: [],
|
||||||
|
completions: [],
|
||||||
|
completionDialog: false,
|
||||||
|
completionId: null,
|
||||||
|
dialog: false,
|
||||||
|
form: blankPlan(),
|
||||||
|
saving: false,
|
||||||
|
error: '',
|
||||||
|
columns: [
|
||||||
|
{ key: 'name', label: 'Plan' },
|
||||||
|
{ key: 'category', label: 'Category' },
|
||||||
|
{ key: 'frequency', label: 'Frequency', sortable: false },
|
||||||
|
{ key: 'steps', label: 'Steps' },
|
||||||
|
{ key: 'estimated_minutes', label: 'Est. time' },
|
||||||
|
{ key: 'components_total', label: 'Parts cost', format: 'currency' }
|
||||||
|
],
|
||||||
|
completionColumns: [
|
||||||
|
{ key: 'asset_code', label: 'Asset' },
|
||||||
|
{ key: 'plan_name', label: 'Plan' },
|
||||||
|
{ key: 'completed_at', label: 'Completed', format: 'date' },
|
||||||
|
{ key: 'completed_by_name', label: 'By' },
|
||||||
|
{ key: 'rating_safety', label: 'Safety' },
|
||||||
|
{ key: 'photo_count', label: 'Photos', format: 'number' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
categoryItems() {
|
||||||
|
// Admin-managed PM categories, plus any value already in use so existing plans stay selectable.
|
||||||
|
const inUse = this.plans.map((plan) => plan.category).filter(Boolean);
|
||||||
|
return [...new Set([...this.pmCategories, ...inUse])].sort((a, b) => String(a).localeCompare(String(b)));
|
||||||
|
},
|
||||||
|
totalStepMinutes() {
|
||||||
|
return this.form.steps.reduce((sum, step) => sum + (Number(step.est_minutes) || 0), 0);
|
||||||
|
},
|
||||||
|
totalComponentCost() {
|
||||||
|
return this.form.components.reduce((sum, component) => sum + (Number(component.cost) || 0), 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
this.loadCompletions();
|
||||||
|
this.loadCategories();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
currency,
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
const data = await (await this.api('/api/pm-plans')).json();
|
||||||
|
this.plans = data.plans;
|
||||||
|
},
|
||||||
|
async loadCompletions() {
|
||||||
|
const data = await (await this.api('/api/pm-completions')).json();
|
||||||
|
this.completions = data.completions;
|
||||||
|
},
|
||||||
|
async loadCategories() {
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/pm-categories')).json();
|
||||||
|
this.pmCategories = (data.categories || []).map((category) => category.name).filter(Boolean);
|
||||||
|
} catch {
|
||||||
|
this.pmCategories = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
viewCompletion(id) {
|
||||||
|
this.completionId = id;
|
||||||
|
this.completionDialog = true;
|
||||||
|
},
|
||||||
|
unitLabel(unit) {
|
||||||
|
return (pmFrequencyUnits.find((u) => u.value === unit) || {}).title || unit;
|
||||||
|
},
|
||||||
|
openNew() {
|
||||||
|
this.form = blankPlan();
|
||||||
|
this.error = '';
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
async openEdit(plan) {
|
||||||
|
this.error = '';
|
||||||
|
// Fetch the full plan so guidance-photo image data (omitted from the list) is available.
|
||||||
|
let full = plan;
|
||||||
|
try {
|
||||||
|
const data = await (await this.api(`/api/pm-plans/${plan.id}`)).json();
|
||||||
|
full = data.plan;
|
||||||
|
} catch {
|
||||||
|
// fall back to the list row (photos will lack image data)
|
||||||
|
}
|
||||||
|
this.form = {
|
||||||
|
...clonePlain(full),
|
||||||
|
steps: clonePlain(full.steps || []),
|
||||||
|
components: clonePlain(full.components || []),
|
||||||
|
photos: clonePlain(full.photos || [])
|
||||||
|
};
|
||||||
|
this.dialog = true;
|
||||||
|
},
|
||||||
|
addStep() {
|
||||||
|
this.form.steps.push({ title: '', details: '', est_minutes: null });
|
||||||
|
},
|
||||||
|
addComponent() {
|
||||||
|
this.form.components.push({ part_number: '', description: '', supplier: '', cost: null });
|
||||||
|
},
|
||||||
|
onPhotoPick(event) {
|
||||||
|
for (const file of Array.from(event.target.files || [])) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => this.form.photos.push({ name: file.name, mime: file.type, data: String(reader.result), caption: '' });
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
event.target.value = '';
|
||||||
|
},
|
||||||
|
moveStep(index, dir) {
|
||||||
|
const target = index + dir;
|
||||||
|
const steps = this.form.steps;
|
||||||
|
[steps[index], steps[target]] = [steps[target], steps[index]];
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
this.saving = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const method = this.form.id ? 'PUT' : 'POST';
|
||||||
|
const url = this.form.id ? `/api/pm-plans/${this.form.id}` : '/api/pm-plans';
|
||||||
|
// Existing photos carry an id; resend only {id, caption} so the stored blob isn't re-uploaded.
|
||||||
|
const photos = (this.form.photos || []).map((p) => (p.id
|
||||||
|
? { id: p.id, caption: p.caption }
|
||||||
|
: { data: p.data, name: p.name, mime: p.mime, caption: p.caption }));
|
||||||
|
await this.api(url, { method, body: JSON.stringify({ ...this.form, photos }) });
|
||||||
|
this.dialog = false;
|
||||||
|
await this.load();
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async remove(plan) {
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
await this.api(`/api/pm-plans/${plan.id}`, { method: 'DELETE' });
|
||||||
|
await this.load();
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pm-step {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
}
|
||||||
|
.pm-step-num {
|
||||||
|
flex: 0 0 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(var(--v-theme-primary), 0.15);
|
||||||
|
color: rgb(var(--v-theme-primary));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.pm-step-fields {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.pm-step-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.pm-component {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.08);
|
||||||
|
}
|
||||||
|
.pm-plan-photo-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.pm-plan-photo-frame {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(var(--v-theme-on-surface), 0.18);
|
||||||
|
}
|
||||||
|
.pm-plan-photo-frame img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.pm-plan-photo-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,76 +1,287 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="content-grid">
|
<section class="content-grid">
|
||||||
<v-card class="span-12 panel-pad">
|
<!-- Report picker -->
|
||||||
<div class="toolbar-row mb-4">
|
<v-card class="span-4 panel-pad">
|
||||||
<v-select v-model="localFilters.book" :items="bookItems" label="Book" hide-details style="max-width: 180px" @update:model-value="emitFilters" />
|
<h2 class="section-title mb-2">Reports</h2>
|
||||||
<v-text-field v-model.number="localFilters.year" type="number" label="Year" hide-details style="max-width: 140px" @update:model-value="emitFilters" />
|
<v-list density="compact" nav>
|
||||||
<v-spacer />
|
<template v-for="group in groupedReports" :key="group.name">
|
||||||
<div class="font-weight-bold">Annual depreciation: {{ currency(depreciationReport.totals.depreciation) }}</div>
|
<v-list-subheader>{{ group.name }}</v-list-subheader>
|
||||||
</div>
|
<v-list-item
|
||||||
<div class="chart-box mb-4">
|
v-for="report in group.reports"
|
||||||
<Bar :data="monthlyChart" :options="chartOptions" />
|
:key="report.key"
|
||||||
</div>
|
:active="selectedType === report.key"
|
||||||
<div style="overflow-x:auto">
|
:title="report.title"
|
||||||
<table class="asset-table">
|
rounded="sm"
|
||||||
<thead>
|
@click="selectReport(report.key)"
|
||||||
<tr>
|
/>
|
||||||
<th>Asset ID</th>
|
</template>
|
||||||
<th>Description</th>
|
</v-list>
|
||||||
<th>Method</th>
|
|
||||||
<th>Cost</th>
|
<template v-if="savedReports.length">
|
||||||
<th>Depreciation</th>
|
<v-divider class="my-3" />
|
||||||
<th>Accumulated</th>
|
<h3 class="section-title mb-2">Saved reports</h3>
|
||||||
<th>NBV</th>
|
<v-list density="compact">
|
||||||
</tr>
|
<v-list-item v-for="saved in savedReports" :key="saved.id" :title="saved.name" :subtitle="saved.report_type" @click="applySaved(saved)">
|
||||||
</thead>
|
<template #append>
|
||||||
<tbody>
|
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click.stop="deleteSaved(saved.id)" />
|
||||||
<tr v-for="row in depreciationReport.rows" :key="row.asset_id">
|
</template>
|
||||||
<td class="font-weight-bold">{{ row.asset_id }}</td>
|
</v-list-item>
|
||||||
<td>{{ row.description }}</td>
|
</v-list>
|
||||||
<td>{{ row.methodLabel || row.method }}</td>
|
</template>
|
||||||
<td>{{ currency(row.cost) }}</td>
|
|
||||||
<td>{{ currency(row.depreciation) }}</td>
|
|
||||||
<td>{{ currency(row.accumulated) }}</td>
|
|
||||||
<td>{{ currency(row.net_book_value) }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
|
<!-- Report output -->
|
||||||
|
<v-card class="span-8 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3" style="flex-wrap:wrap;gap:10px">
|
||||||
|
<template v-for="key in currentParams" :key="key">
|
||||||
|
<v-select
|
||||||
|
v-if="paramSpec(key).type === 'select'"
|
||||||
|
v-model="options[key]"
|
||||||
|
:items="normalizeOptions(paramSpec(key).options)"
|
||||||
|
:label="paramSpec(key).label"
|
||||||
|
:clearable="paramSpec(key).clearable"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
style="max-width:200px"
|
||||||
|
@update:model-value="run"
|
||||||
|
/>
|
||||||
|
<v-select
|
||||||
|
v-else-if="paramSpec(key).type === 'multiselect'"
|
||||||
|
v-model="options[key]"
|
||||||
|
:items="normalizeOptions(paramSpec(key).options)"
|
||||||
|
:label="paramSpec(key).label"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
chips
|
||||||
|
multiple
|
||||||
|
closable-chips
|
||||||
|
style="min-width:280px"
|
||||||
|
@update:model-value="run"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-else-if="paramSpec(key).type === 'date'"
|
||||||
|
v-model="options[key]"
|
||||||
|
:label="paramSpec(key).label"
|
||||||
|
type="date"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
style="max-width:180px"
|
||||||
|
@update:model-value="run"
|
||||||
|
/>
|
||||||
|
<v-text-field
|
||||||
|
v-else
|
||||||
|
v-model.number="options[key]"
|
||||||
|
:label="paramSpec(key).label"
|
||||||
|
type="number"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
style="max-width:130px"
|
||||||
|
@update:model-value="run"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="tonal" size="small" prepend-icon="mdi-content-save-outline" @click="openSave">Save</v-btn>
|
||||||
|
<v-menu>
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-btn v-bind="props" color="secondary" size="small" prepend-icon="mdi-download">Export</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item v-for="format in ['pdf', 'xlsx', 'csv']" :key="format" :title="format.toUpperCase()" @click="exportReport(format)" />
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="report">
|
||||||
|
<div class="toolbar-row mb-2">
|
||||||
|
<h2 class="section-title">{{ report.title }}</h2>
|
||||||
|
<v-spacer />
|
||||||
|
<span class="quiet text-caption">{{ report.rows.length }} row(s)</span>
|
||||||
|
</div>
|
||||||
|
<v-alert v-if="report.meta && report.meta.note" type="info" density="compact" variant="tonal" class="mb-3">{{ report.meta.note }}</v-alert>
|
||||||
|
|
||||||
|
<div v-if="report.meta && report.meta.asset" class="form-grid mb-3">
|
||||||
|
<div v-for="(value, key) in report.meta.asset" :key="key">
|
||||||
|
<div class="quiet text-caption">{{ key.replace(/_/g, ' ') }}</div>
|
||||||
|
<div class="text-body-2 font-weight-medium">{{ value || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="report.chart" class="chart-box mb-4">
|
||||||
|
<Bar :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
v-if="report.columns.length"
|
||||||
|
:columns="report.columns"
|
||||||
|
:totals="hasTotals ? report.totals : null"
|
||||||
|
:rows="report.rows"
|
||||||
|
searchable
|
||||||
|
search-placeholder="Filter this report"
|
||||||
|
empty-text="No data for the selected options."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="quiet text-body-2">Select a report to begin.</div>
|
||||||
|
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-dialog v-model="saveDialog" max-width="440">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Save report</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-text-field v-model="saveName" label="Report name" autofocus />
|
||||||
|
<div class="quiet text-caption">{{ report ? report.title : '' }} · current options will be saved.</div>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="saveDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="primary" :disabled="!saveName" @click="saveReport">Save</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { Bar } from 'vue-chartjs';
|
import { Bar } from 'vue-chartjs';
|
||||||
import '../utils/charts';
|
import '../utils/charts';
|
||||||
|
import DataTable from '../components/DataTable.vue';
|
||||||
|
import { apiRequest, saveBlob } from '../services/api';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { Bar },
|
components: { Bar, DataTable },
|
||||||
props: {
|
props: {
|
||||||
bookItems: { type: Array, required: true },
|
token: { type: String, required: true }
|
||||||
chartOptions: { type: Object, required: true },
|
|
||||||
currency: { type: Function, required: true },
|
|
||||||
depreciationReport: { type: Object, required: true },
|
|
||||||
monthlyChart: { type: Object, required: true },
|
|
||||||
reportFilters: { type: Object, required: true }
|
|
||||||
},
|
},
|
||||||
emits: ['filter-reports'],
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
localFilters: { ...this.reportFilters }
|
reports: [],
|
||||||
|
params: {},
|
||||||
|
selectedType: null,
|
||||||
|
options: {},
|
||||||
|
report: null,
|
||||||
|
savedReports: [],
|
||||||
|
saveDialog: false,
|
||||||
|
saveName: '',
|
||||||
|
error: '',
|
||||||
|
chartOptions: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false } },
|
||||||
|
scales: { y: { beginAtZero: true, ticks: { callback: (value) => `$${Number(value).toLocaleString()}` } } }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
watch: {
|
computed: {
|
||||||
reportFilters: {
|
groupedReports() {
|
||||||
handler(value) {
|
const groups = {};
|
||||||
this.localFilters = { ...value };
|
for (const report of this.reports) {
|
||||||
},
|
groups[report.group] = groups[report.group] || [];
|
||||||
deep: true
|
groups[report.group].push(report);
|
||||||
|
}
|
||||||
|
return Object.entries(groups).map(([name, reports]) => ({ name, reports }));
|
||||||
|
},
|
||||||
|
currentParams() {
|
||||||
|
return this.reports.find((report) => report.key === this.selectedType)?.params || [];
|
||||||
|
},
|
||||||
|
hasTotals() {
|
||||||
|
return this.report && this.report.totals && Object.keys(this.report.totals).length > 0;
|
||||||
|
},
|
||||||
|
chartData() {
|
||||||
|
const chart = this.report?.chart;
|
||||||
|
if (!chart) return { labels: [], datasets: [] };
|
||||||
|
return {
|
||||||
|
labels: chart.labels,
|
||||||
|
datasets: [{ data: chart.values, backgroundColor: '#2457a6' }]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadCatalog();
|
||||||
|
this.loadSavedReports();
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
emitFilters() {
|
async api(path, options = {}) {
|
||||||
this.$emit('filter-reports', { ...this.localFilters });
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async loadCatalog() {
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/reports/catalog')).json();
|
||||||
|
this.reports = data.reports;
|
||||||
|
this.params = data.params;
|
||||||
|
if (this.reports.length) this.selectReport(this.reports[0].key);
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadSavedReports() {
|
||||||
|
const data = await (await this.api('/api/saved-reports')).json();
|
||||||
|
this.savedReports = data.reports;
|
||||||
|
},
|
||||||
|
paramSpec(key) {
|
||||||
|
return this.params[key] || { label: key, type: 'text' };
|
||||||
|
},
|
||||||
|
normalizeOptions(options) {
|
||||||
|
return (options || []).map((option) => (typeof option === 'object' ? option : { title: String(option), value: option }));
|
||||||
|
},
|
||||||
|
defaultOptions(type) {
|
||||||
|
const report = this.reports.find((item) => item.key === type);
|
||||||
|
const options = {};
|
||||||
|
for (const key of report?.params || []) {
|
||||||
|
const spec = this.paramSpec(key);
|
||||||
|
options[key] = Array.isArray(spec.default) ? [...spec.default] : spec.default;
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
},
|
||||||
|
selectReport(type) {
|
||||||
|
this.selectedType = type;
|
||||||
|
this.options = this.defaultOptions(type);
|
||||||
|
this.run();
|
||||||
|
},
|
||||||
|
applySaved(saved) {
|
||||||
|
this.selectedType = saved.report_type;
|
||||||
|
this.options = { ...this.defaultOptions(saved.report_type), ...(saved.options_json || {}) };
|
||||||
|
this.run();
|
||||||
|
},
|
||||||
|
async run() {
|
||||||
|
if (!this.selectedType) return;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/reports/run', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ type: this.selectedType, options: this.options })
|
||||||
|
})).json();
|
||||||
|
this.report = data.report;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async exportReport(format) {
|
||||||
|
const blob = await (await this.api('/api/reports/export', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ type: this.selectedType, options: this.options, format })
|
||||||
|
})).blob();
|
||||||
|
saveBlob(blob, `mixedassets-${this.selectedType}.${format}`);
|
||||||
|
},
|
||||||
|
openSave() {
|
||||||
|
this.saveName = this.report?.title || 'Saved report';
|
||||||
|
this.saveDialog = true;
|
||||||
|
},
|
||||||
|
async saveReport() {
|
||||||
|
await this.api('/api/saved-reports', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name: this.saveName, report_type: this.selectedType, options: this.options })
|
||||||
|
});
|
||||||
|
this.saveDialog = false;
|
||||||
|
await this.loadSavedReports();
|
||||||
|
},
|
||||||
|
async deleteSaved(id) {
|
||||||
|
await this.api(`/api/saved-reports/${id}`, { method: 'DELETE' });
|
||||||
|
await this.loadSavedReports();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
224
src/views/ScanView.vue
Normal file
224
src/views/ScanView.vue
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<template>
|
||||||
|
<section class="content-grid">
|
||||||
|
<v-card class="span-6 panel-pad">
|
||||||
|
<h2 class="section-title mb-1">Scan an asset</h2>
|
||||||
|
<p class="quiet text-body-2 mb-3">
|
||||||
|
Point your camera at an asset barcode, or type the code to look it up and update it in the field.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="scan-viewport mb-3">
|
||||||
|
<video ref="video" class="scan-video" muted playsinline></video>
|
||||||
|
<div v-if="!scanning" class="scan-placeholder">
|
||||||
|
<v-icon icon="mdi-barcode-scan" size="40" />
|
||||||
|
<span>Camera off</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-row">
|
||||||
|
<v-btn v-if="!scanning" color="primary" prepend-icon="mdi-camera" :loading="starting" @click="startScan">Start camera</v-btn>
|
||||||
|
<v-btn v-else color="error" variant="tonal" prepend-icon="mdi-stop" @click="stopScan">Stop</v-btn>
|
||||||
|
<v-select
|
||||||
|
v-if="deviceItems.length > 1"
|
||||||
|
v-model="deviceId"
|
||||||
|
:items="deviceItems"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
label="Camera"
|
||||||
|
style="max-width: 240px"
|
||||||
|
@update:model-value="restartScan"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
|
||||||
|
<div class="toolbar-row">
|
||||||
|
<v-text-field
|
||||||
|
v-model="manualCode"
|
||||||
|
label="Enter barcode / asset ID"
|
||||||
|
hide-details
|
||||||
|
prepend-inner-icon="mdi-keyboard-outline"
|
||||||
|
@keyup.enter="lookup(manualCode)"
|
||||||
|
/>
|
||||||
|
<v-btn variant="tonal" prepend-icon="mdi-magnify" @click="lookup(manualCode)">Look up</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
|
||||||
|
<v-alert v-if="message" type="success" density="compact" class="mt-3">{{ message }}</v-alert>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card v-if="asset" class="span-6 panel-pad">
|
||||||
|
<div class="toolbar-row mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">{{ asset.asset_id }}</h2>
|
||||||
|
<div class="quiet text-body-2">{{ asset.description }}</div>
|
||||||
|
</div>
|
||||||
|
<v-spacer />
|
||||||
|
<span :class="['status-chip', `status-${form.status}`]">{{ form.status }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<v-select v-model="form.status" :items="statusItems" label="Status" />
|
||||||
|
<v-select v-model="form.condition" :items="conditionItems" clearable label="Condition" />
|
||||||
|
<v-text-field v-model="form.location" label="Location" />
|
||||||
|
<v-text-field v-model="form.department" label="Department" />
|
||||||
|
<v-text-field v-model="form.custodian" label="Custodian" />
|
||||||
|
<v-textarea v-model="fieldNote" class="full" label="Add field note" rows="2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-row mt-3">
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="clearAsset">Clear</v-btn>
|
||||||
|
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" @click="save">Update asset</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card v-else class="span-6 panel-pad d-flex align-center justify-center" style="min-height: 220px">
|
||||||
|
<div class="quiet text-body-2 text-center">
|
||||||
|
<v-icon icon="mdi-cube-scan" size="40" class="mb-2" /><br>
|
||||||
|
Scan or look up an asset to update it here.
|
||||||
|
</div>
|
||||||
|
</v-card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { apiRequest } from '../services/api';
|
||||||
|
import { conditionItems, statusItems } from '../constants';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true }
|
||||||
|
},
|
||||||
|
emits: ['asset-updated'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
conditionItems,
|
||||||
|
statusItems,
|
||||||
|
reader: null,
|
||||||
|
controls: null,
|
||||||
|
scanning: false,
|
||||||
|
starting: false,
|
||||||
|
devices: [],
|
||||||
|
deviceId: null,
|
||||||
|
asset: null,
|
||||||
|
form: { status: 'in_service', condition: null, location: '', department: '', custodian: '' },
|
||||||
|
fieldNote: '',
|
||||||
|
manualCode: '',
|
||||||
|
error: '',
|
||||||
|
message: '',
|
||||||
|
saving: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
deviceItems() {
|
||||||
|
return this.devices.map((device, index) => ({
|
||||||
|
title: device.label || `Camera ${index + 1}`,
|
||||||
|
value: device.deviceId
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
this.stopScan();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async startScan() {
|
||||||
|
this.error = '';
|
||||||
|
this.message = '';
|
||||||
|
this.starting = true;
|
||||||
|
try {
|
||||||
|
const { BrowserMultiFormatReader } = await import('@zxing/browser');
|
||||||
|
this.reader = new BrowserMultiFormatReader();
|
||||||
|
this.devices = await BrowserMultiFormatReader.listVideoInputDevices();
|
||||||
|
if (!this.devices.length) throw new Error('No camera found on this device');
|
||||||
|
// Prefer a rear-facing camera when we can identify one.
|
||||||
|
if (!this.deviceId) {
|
||||||
|
const back = this.devices.find((device) => /back|rear|environment/i.test(device.label || ''));
|
||||||
|
this.deviceId = (back || this.devices[this.devices.length - 1]).deviceId;
|
||||||
|
}
|
||||||
|
this.scanning = true;
|
||||||
|
this.controls = await this.reader.decodeFromVideoDevice(this.deviceId, this.$refs.video, (result, _err, controls) => {
|
||||||
|
if (result) {
|
||||||
|
controls.stop();
|
||||||
|
this.scanning = false;
|
||||||
|
this.lookup(result.getText());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.scanning = false;
|
||||||
|
this.error = `Camera unavailable: ${error.message}. Camera access needs HTTPS or localhost — you can still enter codes manually.`;
|
||||||
|
} finally {
|
||||||
|
this.starting = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stopScan() {
|
||||||
|
try {
|
||||||
|
this.controls?.stop();
|
||||||
|
} catch {
|
||||||
|
// controls may already be stopped
|
||||||
|
}
|
||||||
|
this.controls = null;
|
||||||
|
this.scanning = false;
|
||||||
|
},
|
||||||
|
async restartScan() {
|
||||||
|
if (this.scanning) {
|
||||||
|
this.stopScan();
|
||||||
|
await this.startScan();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async lookup(code) {
|
||||||
|
const value = String(code || '').trim();
|
||||||
|
if (!value) return;
|
||||||
|
this.error = '';
|
||||||
|
this.message = '';
|
||||||
|
try {
|
||||||
|
const response = await apiRequest(`/api/assets/lookup?code=${encodeURIComponent(value)}`, this.token);
|
||||||
|
const data = await response.json();
|
||||||
|
this.asset = data.asset;
|
||||||
|
this.manualCode = value;
|
||||||
|
this.form = {
|
||||||
|
status: data.asset.status,
|
||||||
|
condition: data.asset.condition || null,
|
||||||
|
location: data.asset.location || '',
|
||||||
|
department: data.asset.department || '',
|
||||||
|
custodian: data.asset.custodian || ''
|
||||||
|
};
|
||||||
|
this.fieldNote = '';
|
||||||
|
} catch (error) {
|
||||||
|
this.asset = null;
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
if (!this.asset) return;
|
||||||
|
this.saving = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
await apiRequest(`/api/assets/${this.asset.id}`, this.token, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(this.form)
|
||||||
|
});
|
||||||
|
if (this.fieldNote.trim()) {
|
||||||
|
await apiRequest(`/api/assets/${this.asset.id}/notes`, this.token, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ body: this.fieldNote.trim() })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.message = `${this.asset.asset_id} updated`;
|
||||||
|
this.$emit('asset-updated');
|
||||||
|
this.clearAsset();
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clearAsset() {
|
||||||
|
this.asset = null;
|
||||||
|
this.fieldNote = '';
|
||||||
|
this.manualCode = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
254
src/views/TaxRulesView.vue
Normal file
254
src/views/TaxRulesView.vue
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<template>
|
||||||
|
<section class="content-grid">
|
||||||
|
<!-- Rule set list -->
|
||||||
|
<v-card class="span-4 panel-pad">
|
||||||
|
<div class="toolbar-row mb-2">
|
||||||
|
<h2 class="section-title">Tax rule sets</h2>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus" @click="newDraft">New</v-btn>
|
||||||
|
</div>
|
||||||
|
<p class="quiet text-body-2 mb-2">Template-driven JSON rule sets for federal, state, and local depreciation law updates.</p>
|
||||||
|
|
||||||
|
<v-btn block variant="tonal" prepend-icon="mdi-upload" class="mb-3" @click="$refs.uploadInput.click()">Upload JSON</v-btn>
|
||||||
|
<input ref="uploadInput" hidden type="file" accept=".json,application/json" @change="onUpload" />
|
||||||
|
|
||||||
|
<v-list density="compact" nav>
|
||||||
|
<v-list-item
|
||||||
|
v-for="ruleSet in ruleSets"
|
||||||
|
:key="ruleSet.id"
|
||||||
|
:active="selected && selected.id === ruleSet.id"
|
||||||
|
rounded="sm"
|
||||||
|
@click="select(ruleSet)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>{{ ruleSet.name }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ ruleSet.jurisdiction }} · {{ ruleSet.version }} · {{ ruleSet.effective_date }}</v-list-item-subtitle>
|
||||||
|
<template #append>
|
||||||
|
<v-chip v-if="ruleSet.active" size="x-small" color="success" variant="tonal">active</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<!-- Editor -->
|
||||||
|
<v-card class="span-8 panel-pad">
|
||||||
|
<div class="form-grid mb-3">
|
||||||
|
<v-text-field v-model="form.name" label="Name" />
|
||||||
|
<v-text-field v-model="form.jurisdiction" label="Jurisdiction" placeholder="US-FED, US-CA, ..." />
|
||||||
|
<v-text-field v-model="form.version" label="Version" />
|
||||||
|
<v-text-field v-model="form.effective_date" type="date" label="Effective date" />
|
||||||
|
<v-text-field v-model="form.source_note" class="full" label="Source note" />
|
||||||
|
<v-switch v-model="form.active" label="Active" color="primary" density="compact" hide-details />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-row mb-2" style="flex-wrap:wrap;gap:8px">
|
||||||
|
<v-btn size="small" variant="text" prepend-icon="mdi-code-braces" @click="formatJson">Format</v-btn>
|
||||||
|
<v-btn size="small" variant="text" prepend-icon="mdi-table-refresh" :loading="expanding" @click="regenerateMethods">Regenerate method catalog</v-btn>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn size="small" variant="text" prepend-icon="mdi-download" @click="exportJson">Export</v-btn>
|
||||||
|
<v-btn v-if="selected && !form.active" size="small" variant="tonal" prepend-icon="mdi-check-circle-outline" @click="activate">Activate</v-btn>
|
||||||
|
<v-btn v-if="selected" size="small" variant="text" color="error" prepend-icon="mdi-delete-outline" @click="remove">Delete</v-btn>
|
||||||
|
<v-btn color="primary" size="small" prepend-icon="mdi-content-save" :disabled="!jsonValid" :loading="saving" @click="save">Save</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-alert v-if="jsonError" type="error" density="compact" class="mb-2">{{ jsonError }}</v-alert>
|
||||||
|
<v-alert v-if="message" type="success" density="compact" class="mb-2">{{ message }}</v-alert>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mb-2">{{ error }}</v-alert>
|
||||||
|
|
||||||
|
<component :is="editorComponent" ref="editor" v-model="editorContent" :theme="monacoTheme" />
|
||||||
|
</v-card>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { defineAsyncComponent } from 'vue';
|
||||||
|
import { apiRequest, saveBlob } from '../services/api';
|
||||||
|
|
||||||
|
function blankForm() {
|
||||||
|
return {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
jurisdiction: 'US-FED',
|
||||||
|
version: '',
|
||||||
|
effective_date: new Date().toISOString().slice(0, 10),
|
||||||
|
source_note: '',
|
||||||
|
active: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
token: { type: String, required: true },
|
||||||
|
dark: { type: Boolean, default: true }
|
||||||
|
},
|
||||||
|
emits: ['updated'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
editorComponent: defineAsyncComponent(() => import('../components/MonacoJsonEditor.vue')),
|
||||||
|
ruleSets: [],
|
||||||
|
selected: null,
|
||||||
|
form: blankForm(),
|
||||||
|
editorContent: '{\n "jurisdiction": "US-FED",\n "name": "New rule set",\n "version": "1.0",\n "effectiveDate": "2026-01-01",\n "limits": {}\n}',
|
||||||
|
saving: false,
|
||||||
|
expanding: false,
|
||||||
|
error: '',
|
||||||
|
message: ''
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
monacoTheme() {
|
||||||
|
return this.dark ? 'vs-dark' : 'vs';
|
||||||
|
},
|
||||||
|
parsed() {
|
||||||
|
try {
|
||||||
|
return { value: JSON.parse(this.editorContent), error: '' };
|
||||||
|
} catch (error) {
|
||||||
|
return { value: null, error: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
jsonValid() {
|
||||||
|
return this.parsed.error === '';
|
||||||
|
},
|
||||||
|
jsonError() {
|
||||||
|
return this.editorContent.trim() ? this.parsed.error : '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async api(path, options = {}) {
|
||||||
|
return apiRequest(path, this.token, options);
|
||||||
|
},
|
||||||
|
async load() {
|
||||||
|
const data = await (await this.api('/api/tax-rules')).json();
|
||||||
|
this.ruleSets = data.ruleSets;
|
||||||
|
if (!this.selected && this.ruleSets.length) this.select(this.ruleSets[0]);
|
||||||
|
},
|
||||||
|
select(ruleSet) {
|
||||||
|
this.message = '';
|
||||||
|
this.error = '';
|
||||||
|
this.selected = ruleSet;
|
||||||
|
this.form = {
|
||||||
|
id: ruleSet.id,
|
||||||
|
name: ruleSet.name,
|
||||||
|
jurisdiction: ruleSet.jurisdiction,
|
||||||
|
version: ruleSet.version,
|
||||||
|
effective_date: ruleSet.effective_date,
|
||||||
|
source_note: ruleSet.source_note || '',
|
||||||
|
active: ruleSet.active
|
||||||
|
};
|
||||||
|
this.editorContent = JSON.stringify(ruleSet.rules_json, null, 2);
|
||||||
|
},
|
||||||
|
newDraft() {
|
||||||
|
this.selected = null;
|
||||||
|
this.form = blankForm();
|
||||||
|
this.editorContent = JSON.stringify({
|
||||||
|
jurisdiction: 'US-FED',
|
||||||
|
name: 'New rule set',
|
||||||
|
version: '1.0',
|
||||||
|
effectiveDate: new Date().toISOString().slice(0, 10),
|
||||||
|
limits: { section179: { deductionLimit: 0, phaseOutBegins: 0 }, bonusDepreciation: { qualifiedPropertyPercent: 0 } }
|
||||||
|
}, null, 2);
|
||||||
|
this.message = '';
|
||||||
|
this.error = '';
|
||||||
|
},
|
||||||
|
onUpload(event) {
|
||||||
|
const [file] = event.target.files || [];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
this.selected = null;
|
||||||
|
this.editorContent = String(reader.result || '');
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(this.editorContent);
|
||||||
|
this.form = {
|
||||||
|
id: null,
|
||||||
|
name: parsed.name || file.name.replace(/\.json$/i, ''),
|
||||||
|
jurisdiction: parsed.jurisdiction || 'US-FED',
|
||||||
|
version: parsed.version || '1.0',
|
||||||
|
effective_date: parsed.effectiveDate || parsed.effective_date || new Date().toISOString().slice(0, 10),
|
||||||
|
source_note: parsed.sourceNote || '',
|
||||||
|
active: false
|
||||||
|
};
|
||||||
|
this.message = 'File loaded — review and Save to import.';
|
||||||
|
} catch {
|
||||||
|
this.message = '';
|
||||||
|
this.error = 'Uploaded file is not valid JSON; fix it in the editor before saving.';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
event.target.value = '';
|
||||||
|
},
|
||||||
|
formatJson() {
|
||||||
|
this.$refs.editor?.format?.();
|
||||||
|
},
|
||||||
|
async regenerateMethods() {
|
||||||
|
if (!this.jsonValid) return;
|
||||||
|
this.expanding = true;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api('/api/tax-rules/expand', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ rules_json: this.parsed.value })
|
||||||
|
})).json();
|
||||||
|
this.editorContent = JSON.stringify(data.rules_json, null, 2);
|
||||||
|
this.message = 'Method catalog regenerated (68 methods).';
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.expanding = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
if (!this.jsonValid) return;
|
||||||
|
this.saving = true;
|
||||||
|
this.error = '';
|
||||||
|
this.message = '';
|
||||||
|
try {
|
||||||
|
const payload = { ...this.form, rules_json: this.parsed.value };
|
||||||
|
const method = this.form.id ? 'PUT' : 'POST';
|
||||||
|
const url = this.form.id ? `/api/tax-rules/${this.form.id}` : '/api/tax-rules';
|
||||||
|
const data = await (await this.api(url, { method, body: JSON.stringify(payload) })).json();
|
||||||
|
this.message = 'Rule set saved.';
|
||||||
|
await this.load();
|
||||||
|
this.select(this.ruleSets.find((set) => set.id === data.ruleSet.id) || data.ruleSet);
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async activate() {
|
||||||
|
if (!this.selected) return;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
const data = await (await this.api(`/api/tax-rules/${this.selected.id}/activate`, { method: 'POST' })).json();
|
||||||
|
this.ruleSets = data.ruleSets;
|
||||||
|
this.select(this.ruleSets.find((set) => set.id === this.selected.id));
|
||||||
|
this.message = 'Rule set activated.';
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async remove() {
|
||||||
|
if (!this.selected) return;
|
||||||
|
this.error = '';
|
||||||
|
try {
|
||||||
|
await this.api(`/api/tax-rules/${this.selected.id}`, { method: 'DELETE' });
|
||||||
|
this.selected = null;
|
||||||
|
this.form = blankForm();
|
||||||
|
await this.load();
|
||||||
|
this.$emit('updated');
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.message;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exportJson() {
|
||||||
|
const name = `${this.form.jurisdiction || 'rules'}-${this.form.version || 'export'}.json`.replace(/\s+/g, '-');
|
||||||
|
saveBlob(new Blob([this.editorContent], { type: 'application/json' }), name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="content-grid">
|
<section class="content-grid">
|
||||||
<v-card class="span-5 panel-pad">
|
<v-card class="span-5 panel-pad">
|
||||||
<h2 class="section-title mb-4">Asset template</h2>
|
<div class="toolbar-row mb-4">
|
||||||
|
<h2 class="section-title">{{ localForm.id ? 'Edit template' : 'New asset template' }}</h2>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn v-if="localForm.id" variant="text" size="small" prepend-icon="mdi-plus" @click="startNew">New</v-btn>
|
||||||
|
</div>
|
||||||
<v-text-field v-model="localForm.name" label="Name" />
|
<v-text-field v-model="localForm.name" label="Name" />
|
||||||
<v-textarea v-model="localForm.description" label="Description" rows="2" />
|
<v-textarea v-model="localForm.description" label="Description" rows="2" />
|
||||||
<v-select v-model="localForm.defaults.category" :items="categoryItems" label="Category" />
|
<v-select v-model="localForm.defaults.category" :items="categoryItems" label="Category" />
|
||||||
@@ -23,41 +27,115 @@
|
|||||||
<v-switch v-model="field.required" label="Required" color="primary" density="compact" hide-details />
|
<v-switch v-model="field.required" label="Required" color="primary" density="compact" hide-details />
|
||||||
<v-btn icon="mdi-delete" variant="text" color="error" @click="localForm.custom_fields.splice(index, 1)" />
|
<v-btn icon="mdi-delete" variant="text" color="error" @click="localForm.custom_fields.splice(index, 1)" />
|
||||||
</div>
|
</div>
|
||||||
<v-btn color="primary" prepend-icon="mdi-content-save" @click="$emit('save-template', localForm)">Save template</v-btn>
|
<v-btn color="primary" prepend-icon="mdi-content-save" :disabled="!localForm.name" @click="$emit('save-template', localForm)">
|
||||||
|
{{ localForm.id ? 'Update template' : 'Save template' }}
|
||||||
|
</v-btn>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<v-card class="span-7 panel-pad">
|
<v-card class="span-7 panel-pad">
|
||||||
<h2 class="section-title mb-4">Templates</h2>
|
<h2 class="section-title mb-3">Templates</h2>
|
||||||
<v-list lines="two">
|
<DataTable
|
||||||
<v-list-item v-for="template in templates" :key="template.id" prepend-icon="mdi-form-select">
|
:columns="columns"
|
||||||
<v-list-item-title>{{ template.name }}</v-list-item-title>
|
:rows="templateRows"
|
||||||
<v-list-item-subtitle>{{ template.description }}</v-list-item-subtitle>
|
row-key="id"
|
||||||
<template #append>
|
:page-size="10"
|
||||||
<v-chip size="small" variant="tonal">{{ template.custom_fields.length }} fields</v-chip>
|
has-actions
|
||||||
</template>
|
searchable
|
||||||
</v-list-item>
|
search-placeholder="Filter templates"
|
||||||
</v-list>
|
empty-text="No templates yet. Create one on the left."
|
||||||
|
>
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<span class="font-weight-bold">{{ row.name }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-description="{ row }">{{ row.description || '—' }}</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<v-btn v-if="canDelete" icon="mdi-pencil" size="small" variant="text" @click="editTemplate(row)" />
|
||||||
|
<v-btn v-if="canDelete" icon="mdi-delete-outline" size="small" variant="text" color="error" @click="confirmDelete(row)" />
|
||||||
|
</template>
|
||||||
|
</DataTable>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
|
<v-dialog v-model="deleteDialog" max-width="520">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-error">Delete template</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="mb-3">Delete the template <strong>“{{ deleteTarget?.name }}”</strong>?</p>
|
||||||
|
<v-alert
|
||||||
|
:type="deleteTarget && deleteTarget.asset_count ? 'warning' : 'info'"
|
||||||
|
variant="tonal"
|
||||||
|
density="comfortable"
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
|
<template v-if="deleteTarget && deleteTarget.asset_count">
|
||||||
|
This template is currently assigned to <strong>{{ deleteTarget.asset_count }}</strong>
|
||||||
|
asset{{ deleteTarget.asset_count === 1 ? '' : 's' }}.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
This template is not assigned to any assets.
|
||||||
|
</template>
|
||||||
|
</v-alert>
|
||||||
|
<p class="mb-2">When you delete it:</p>
|
||||||
|
<ul class="delete-impact">
|
||||||
|
<li>Assigned assets <strong>keep all their data and custom-field values</strong> — nothing is removed from them.</li>
|
||||||
|
<li>Those assets are simply <strong>unlinked</strong> from this template.</li>
|
||||||
|
<li>The template will <strong>no longer be available</strong> when creating new assets.</li>
|
||||||
|
</ul>
|
||||||
|
<p class="quiet text-body-2 mt-3">This action cannot be undone.</p>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn variant="text" @click="deleteDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn color="error" prepend-icon="mdi-delete-outline" @click="performDelete">Delete template</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import DataTable from '../components/DataTable.vue';
|
||||||
import { slugFieldKey } from '../utils/assets';
|
import { slugFieldKey } from '../utils/assets';
|
||||||
import { clonePlain } from '../utils/clone';
|
import { clonePlain } from '../utils/clone';
|
||||||
|
|
||||||
|
function fieldId() {
|
||||||
|
return crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random());
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
components: { DataTable },
|
||||||
props: {
|
props: {
|
||||||
categoryItems: { type: Array, required: true },
|
categoryItems: { type: Array, required: true },
|
||||||
customFieldTypes: { type: Array, required: true },
|
customFieldTypes: { type: Array, required: true },
|
||||||
methodItems: { type: Array, required: true },
|
methodItems: { type: Array, required: true },
|
||||||
templateForm: { type: Object, required: true },
|
templateForm: { type: Object, required: true },
|
||||||
templates: { type: Array, required: true }
|
templates: { type: Array, required: true },
|
||||||
|
canDelete: { type: Boolean, default: false }
|
||||||
},
|
},
|
||||||
emits: ['save-template'],
|
emits: ['save-template', 'delete-template'],
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
localForm: clonePlain(this.templateForm)
|
localForm: clonePlain(this.templateForm),
|
||||||
|
deleteDialog: false,
|
||||||
|
deleteTarget: null,
|
||||||
|
columns: [
|
||||||
|
{ key: 'name', label: 'Template' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
{ key: 'fields_count', label: 'Fields', format: 'number' },
|
||||||
|
{ key: 'asset_count', label: 'Assets', format: 'number' }
|
||||||
|
]
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
templateRows() {
|
||||||
|
return this.templates.map((template) => ({
|
||||||
|
id: template.id,
|
||||||
|
name: template.name,
|
||||||
|
description: template.description,
|
||||||
|
fields_count: (template.custom_fields || []).length,
|
||||||
|
asset_count: template.asset_count || 0
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
},
|
||||||
watch: {
|
watch: {
|
||||||
templateForm: {
|
templateForm: {
|
||||||
handler(value) {
|
handler(value) {
|
||||||
@@ -69,7 +147,7 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
addField() {
|
addField() {
|
||||||
this.localForm.custom_fields.push({
|
this.localForm.custom_fields.push({
|
||||||
local_id: crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random()),
|
local_id: fieldId(),
|
||||||
key: '',
|
key: '',
|
||||||
label: '',
|
label: '',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -79,7 +157,59 @@ export default {
|
|||||||
},
|
},
|
||||||
syncFieldKey(field) {
|
syncFieldKey(field) {
|
||||||
if (!field.key) field.key = slugFieldKey(field.label);
|
if (!field.key) field.key = slugFieldKey(field.label);
|
||||||
|
},
|
||||||
|
startNew() {
|
||||||
|
this.localForm = {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
defaults: { category: 'Computer Equipment', useful_life_months: 60, depreciation_method: 'straight_line' },
|
||||||
|
custom_fields: []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
editTemplate(row) {
|
||||||
|
const template = this.templates.find((item) => item.id === row.id);
|
||||||
|
if (!template) return;
|
||||||
|
this.localForm = {
|
||||||
|
id: template.id,
|
||||||
|
name: template.name,
|
||||||
|
description: template.description || '',
|
||||||
|
defaults: {
|
||||||
|
category: 'Computer Equipment',
|
||||||
|
useful_life_months: 60,
|
||||||
|
depreciation_method: 'straight_line',
|
||||||
|
...(template.defaults || {})
|
||||||
|
},
|
||||||
|
custom_fields: (template.custom_fields || []).map((f) => ({
|
||||||
|
local_id: fieldId(),
|
||||||
|
key: f.key || '',
|
||||||
|
label: f.label || '',
|
||||||
|
type: f.type || 'text',
|
||||||
|
required: Boolean(f.required),
|
||||||
|
defaultValue: f.defaultValue ?? f.default ?? ''
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
},
|
||||||
|
confirmDelete(row) {
|
||||||
|
this.deleteTarget = row;
|
||||||
|
this.deleteDialog = true;
|
||||||
|
},
|
||||||
|
performDelete() {
|
||||||
|
if (this.deleteTarget) this.$emit('delete-template', this.deleteTarget.id);
|
||||||
|
this.deleteDialog = false;
|
||||||
|
this.deleteTarget = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.delete-impact {
|
||||||
|
padding-left: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.delete-impact li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user