48 Commits

Author SHA1 Message Date
mfwadejr 0de8e80019 Auto-add successfully scanned devices
Publish container / publish (push) Failing after 4m13s
2026-09-21 11:01:28 -04:00
mfwadejr ccb27f2f09 Improve label OCR preprocessing 2026-09-21 10:50:50 -04:00
mfwadejr c1191c90a9 Normalize OCR MAC address formatting 2026-09-21 10:47:11 -04:00
mfwadejr e4259d27f3 Read printed label identifiers with OCR 2026-09-21 10:18:10 -04:00
mfwadejr 05278e9ae3 Preprocess photographed barcode labels 2026-09-21 10:16:40 -04:00
mfwadejr 4f71779a12 Fix photo barcode image decoding 2026-09-21 09:55:53 -04:00
mfwadejr c1fb21d165 Improve photographed label decoding 2026-09-21 09:40:44 -04:00
mfwadejr 3515edbc46 Fix iPhone label photo input 2026-09-21 09:17:28 -04:00
mfwadejr b3ba13ad14 Map photographed label barcodes by position 2026-09-21 09:07:06 -04:00
mfwadejr 046416d62c Add label photo capture fallback 2026-09-21 08:53:19 -04:00
mfwadejr 86d3aac771 Fix mobile camera fallback and scanner theme 2026-09-20 23:33:46 -04:00
mfwadejr a8fe8147c0 Add mobile batch inventory scanning 2026-09-20 23:23:51 -04:00
mfwadejr 672f4042e7 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	CHANGELOG.md
#	package.json
#	public/app.js
#	public/extras.css
#	public/theme.css
#	server.mjs
#	test/server.test.mjs
2026-09-20 22:55:59 -04:00
mfwadejr e5dc37e0ce Add multi-item sales 2026-09-20 22:51:56 -04:00
mfwadejr 855a84b157 Remove mistakenly added Site Gateway icon 2026-09-01 09:42:37 -04:00
mfwadejr 76277a0f20 Add Site Gateway product icon 2026-09-01 09:37:39 -04:00
mfwadejr ebf0744134 Delete docs/screenshots/admin-system-backups.png 2026-08-30 17:59:52 -04:00
mfwadejr 675ed30254 Delete docs/screenshots/admin-warranties.png 2026-08-30 17:59:42 -04:00
mfwadejr d693300292 Delete docs/screenshots/sale-fulfillment-warranty.png 2026-08-30 17:59:31 -04:00
mfwadejr 3ead753ac0 Replace Sales history screenshot 2026-08-30 17:59:00 -04:00
mfwadejr b12313f262 Streamline README screenshot gallery 2026-08-30 17:58:45 -04:00
mfwadejr dce101246b Streamline README screenshot gallery 2026-08-30 17:58:08 -04:00
mfwadejr 8a9c6fd34a Fix README screenshot framing 2026-08-30 17:48:14 -04:00
mfwadejr 0b7ba49f87 Fix README screenshot framing 2026-08-30 17:42:23 -04:00
mfwadejr fa517c8100 Update automatic backup administration screenshot 2026-08-30 16:29:34 -04:00
mfwadejr 5e6efe3671 Test daily and weekly backup settings 2026-08-30 16:29:19 -04:00
mfwadejr 625fae11ab Move automatic scheduling into Database backups 2026-08-30 16:29:02 -04:00
mfwadejr a53d50d239 Document v3.3.1 automatic backup options 2026-08-30 16:28:47 -04:00
mfwadejr 99ae45c630 Add daily and weekly automatic backup schedules 2026-08-30 16:28:31 -04:00
mfwadejr c1af6d2f84 Bump vBoxStock to 3.3.1 2026-08-30 16:28:13 -04:00
mfwadejr 0ecfe2bc94 Release v3.3.1 automatic backup scheduling 2026-08-30 16:27:01 -04:00
mfwadejr 8612ab4797 Add v3.3.0 system and sales screenshots 2026-08-30 16:16:52 -04:00
mfwadejr f87e00d8ff Test v3.3.0 sale exports diagnostics and backups 2026-08-30 16:16:33 -04:00
mfwadejr 132c180a39 Add customer matching filters exports and diagnostics UI 2026-08-30 16:16:17 -04:00
mfwadejr 4cd2861e01 Release v3.3.0 operational polish 2026-08-30 16:15:59 -04:00
mfwadejr aa5f288fc8 Release v3.3.0 operational polish 2026-08-30 16:14:36 -04:00
mfwadejr bc0f55caf5 Add sale fulfillment and warranty screenshot 2026-08-30 15:49:26 -04:00
mfwadejr b6ce70fb38 Add warranty administration screenshot 2026-08-30 15:48:39 -04:00
mfwadejr 8f8cfcdbcb Test fulfillment, warranty, migration, and restock behavior 2026-08-30 15:48:05 -04:00
mfwadejr e9879a7528 Add adaptive fulfillment and warranty interface 2026-08-30 15:47:52 -04:00
mfwadejr 574e1244bf Release v3.2.0 fulfillment and warranty tracking 2026-08-30 15:47:18 -04:00
mfwadejr 0ff468cdfd Use host port 8269 for new installations 2026-08-30 09:46:24 -04:00
mfwadejr 2e9e7f1da3 Clarify container ownership fallback 2026-08-30 09:32:57 -04:00
mfwadejr 4c0f7673d0 Clarify PUID and PGID by Docker platform 2026-08-30 09:28:20 -04:00
mfwadejr 53494145f2 Document model, backup, and audit administration 2026-08-30 09:19:27 -04:00
mfwadejr 22b4670e33 Expand Admin documentation screenshots 2026-08-30 09:19:13 -04:00
mfwadejr 507247e699 Update sign-in screenshot to light theme 2026-08-30 09:15:35 -04:00
mfwadejr 32e12df22d Add ZimaOS install and container icon metadata 2026-08-30 09:06:45 -04:00
17 changed files with 378 additions and 53 deletions
+55
View File
@@ -1,5 +1,56 @@
# Changelog # Changelog
## 3.5.0
- Added batch inventory receiving with quantity, model, condition, received date, cost, and notes shared across the batch.
- Added mobile camera barcode scanning for the three-code UID, serial number, and MAC labels used by vSeeBox devices.
- Added manual-entry fallback, per-device review/removal, progress feedback, and duplicate checks against the batch and existing inventory.
- Made batch receiving transactional so a duplicate or invalid device leaves the entire batch unsaved.
- Added mobile-friendly scanner and batch-list styling.
## 3.4.0
- Added multi-item sales so one customer transaction can include multiple available inventory devices.
- Added an individual sale-price field for every selected device and a live transaction total.
- Applied shared customer, payment, fulfillment, warranty, date, and notes to every item in the transaction.
- Made multi-item sales atomic so validation or inventory conflicts leave every selected device unchanged.
- Retained the single-device sale API for backward compatibility.
- Added API coverage for successful multi-item sales and failed-transaction rollback behavior.
## 3.3.1
- Moved automatic-backup scheduling into the Database backups section.
- Renamed the control to **Enable automatic backups**.
- Added Every day and Weekly frequencies with a conditional weekday selector.
- Replaced the numeric hour field with a clearer local-time selection.
- Added last and next automatic-backup status information.
- Renamed new scheduled files to `automatic-*.db` while retaining cleanup compatibility for earlier `scheduled-*.db` files.
- Kept automatic retention isolated from manual, pre-upgrade, and pre-restore backups.
## 3.3.0
- Added returning-customer autocomplete and server-side duplicate safeguards using customer IDs, normalized names, and phone numbers.
- Expanded sale correction to include customer, phone, sale date, price, payment method, payment reference, fulfillment, tracking, warranty, and notes.
- Added model, payment, fulfillment, warranty-status, and date-range filters.
- Added CSV exports for inventory, sales, customers, warranties, and the administrator audit log.
- Added configurable daily SQLite backups with local-time scheduling and automatic retention pruning.
- Added an Admin diagnostics panel with application and schema versions, Node.js version, database size, data-directory writability, storage capacity, time zone, and backup health.
- Prevented duplicate sale submissions while a save is in progress.
- Corrected the delivery-exception status value shared by the UI and API.
- Expanded API tests for full sale correction, customer reuse, exports, diagnostics, and backup settings.
## 3.2.0
- Added sale fulfillment methods for Shipped, Dropped Off, Installed At, and Meet with context-sensitive fields and validation.
- Added UPS, FedEx, USPS, and Other carrier records, optional tracking numbers, official carrier tracking links, and editable delivery status.
- Added Admin-managed warranty periods with custom day, month, or year durations, a configurable default, archival, usage protection, and historical sale snapshots.
- Added green in-warranty countdowns and red expired indicators to sales, sale details, and customer purchase history.
- Added post-sale editing for fulfillment, tracking, delivery status, warranty, and transaction notes.
- Kept meetup and drop-off locations separate from permanent customer addresses; only shipping and installation update the customer address.
- Cleared fulfillment and warranty data when a sale is voided and its product is restocked.
- Improved mobile sign-in and form behavior on iPhone and iPad by preventing focus zoom and horizontal overflow without disabling user zoom.
- Added migration safety backups and automated coverage for warranty and fulfillment behavior.
## 3.1.0 ## 3.1.0
- Added an Admin-managed product model catalog. - Added an Admin-managed product model catalog.
@@ -9,6 +60,10 @@
- Added automatic pre-migration database backups for upgrades from the fixed model list. - Added automatic pre-migration database backups for upgrades from the fixed model list.
- Added responsive, light, and dark theme styling for model management. - Added responsive, light, and dark theme styling for model management.
- Added API and migration regression coverage. - Added API and migration regression coverage.
- Added a ZimaOS-specific Compose definition with project metadata, persistent storage defaults, and dashboard icon.
- Added the canonical container icon URL to the ZimaOS and Unraid templates and installation documentation.
- Clarified platform-specific PUID and PGID values for general Docker, Docker Desktop, ZimaOS, and Unraid.
- Changed the recommended host WebUI port to `8269` while retaining internal container port `3000`.
## 3.0.2 ## 3.0.2
+106 -14
View File
@@ -16,24 +16,33 @@ General inventory tools can be larger and more complicated than a small reseller
1. Receive an individually identifiable device into inventory. 1. Receive an individually identifiable device into inventory.
2. Record its cost, model, condition, and notes. 2. Record its cost, model, condition, and notes.
3. Complete a sale with customer, payment, shipping, and transaction details. 3. Complete a sale for one or more devices with customer, payment, fulfillment, warranty, and transaction details.
4. Revisit the customer or sale later for support and follow-up. 4. Revisit the customer or sale later for support and follow-up.
5. Back up the complete business record without managing a separate database server. 5. Back up the complete business record without managing a separate database server.
## Highlights ## Highlights
- Track available and sold devices by UID, serial number, or MAC address. - Track available and sold devices by UID, serial number, or MAC address.
- Receive one device or a batch of devices from a mobile phone, with shared batch details, camera barcode scanning, review before saving, and duplicate-identifier protection.
- Start with vSeeBox V3 Plus, V5 Pro, V6 Plus, and V6 Pro, then add any additional model you carry. - Start with vSeeBox V3 Plus, V5 Pro, V6 Plus, and V6 Pro, then add any additional model you carry.
- Manage the model catalog from the Admin page: rename unused models, archive end-of-life models, reactivate them later, or delete models that have never been used. - Manage the model catalog from the Admin page: rename unused models, archive end-of-life models, reactivate them later, or delete models that have never been used.
- Record New, Used, or Refurbished condition and purchase cost. - Record New, Used, or Refurbished condition and purchase cost.
- Capture customer name, phone number, shipped-to address, and shipping notes. - Record whether a product was shipped, dropped off, installed, or exchanged at a meetup using fields tailored to that method.
- Capture shipping carriers, tracking numbers, direct official tracking links, and an editable delivery status.
- Configure warranty periods in Admin and see live green in-warranty countdowns or red expired indicators throughout sale history.
- Keep shipped-to and installed-at addresses while allowing venue or notes-based details for drop-offs and meetups.
- Record Cash, Venmo, or PayPal payments with an optional reference. - Record Cash, Venmo, or PayPal payments with an optional reference.
- Sell multiple inventory items in one checkout, assign an individual price to each device, and review the calculated transaction total.
- Attach transaction notes to a sale and time-stamped support notes to a customer. - Attach transaction notes to a sale and time-stamped support notes to a customer.
- Browse inventory, sales, and customers in searchable 10-record pages. - Browse inventory, sales, and customers in searchable 10-record pages.
- Filter records by model and date, with additional payment, fulfillment, and warranty filters for sales.
- Export inventory, sales, customers, warranty configuration, and audit history to CSV.
- View sale details and complete purchase history for each customer. - View sale details and complete purchase history for each customer.
- Select returning customers during a sale and safely correct all transaction details afterward.
- Void a sale and return the device to available inventory. - Void a sale and return the device to available inventory.
- Use Admin and Read-Only accounts with server-enforced permissions. - Use Admin and Read-Only accounts with server-enforced permissions.
- Create, download, restore, and delete SQLite backups from the Admin page. - Create, download, restore, and delete SQLite backups from the Admin page.
- Schedule automatic daily backups with configurable retention and review storage/database diagnostics.
- Review an audit log of authentication, account administration, backups, and data changes. - Review an audit log of authentication, account administration, backups, and data changes.
- Keep all persistent application data in one mounted directory. - Keep all persistent application data in one mounted directory.
@@ -43,9 +52,21 @@ General inventory tools can be larger and more complicated than a small reseller
![vBoxStock sign-in page](docs/screenshots/login.jpg) ![vBoxStock sign-in page](docs/screenshots/login.jpg)
### User administration, backups, and audit history ### Product model administration
![vBoxStock Admin page](docs/screenshots/admin.jpg) ![vBoxStock product model administration](docs/screenshots/admin.jpg)
### Sales filters and CSV export
![vBoxStock sales filters and export controls](docs/screenshots/sales-filters.png)
### User administration and database backups
![vBoxStock user administration and database backups](docs/screenshots/admin-users-backups.jpg)
### Audit history
![vBoxStock audit history](docs/screenshots/admin-audit.jpg)
## Accounts and security ## Accounts and security
@@ -60,6 +81,7 @@ vBoxStock immediately requires a new password and blocks access to application d
| --- | :---: | :---: | | --- | :---: | :---: |
| View and search inventory, sales, customers, and notes | Yes | Yes | | View and search inventory, sales, customers, and notes | Yes | Yes |
| Receive inventory and record sales | Yes | No | | Receive inventory and record sales | Yes | No |
| Edit fulfillment, tracking, warranty, and transaction details | Yes | No |
| Edit notes, void sales, or delete records | Yes | No | | Edit notes, void sales, or delete records | Yes | No |
| Manage product models, users, and view the audit log | Yes | No | | Manage product models, users, and view the audit log | Yes | No |
| Create, download, delete, or restore backups | Yes | No | | Create, download, delete, or restore backups | Yes | No |
@@ -71,30 +93,47 @@ For use outside a trusted private network, place vBoxStock behind an HTTPS rever
## Quick start with Docker ## Quick start with Docker
On a typical Linux Docker host, create a persistent data directory and run vBoxStock using the UID and GID of the current user:
```sh ```sh
mkdir -p vboxstock-data
docker run -d \ docker run -d \
--name vboxstock \ --name vboxstock \
--restart unless-stopped \ --restart unless-stopped \
-p 3000:3000 \ -p 8269:3000 \
-e TZ=America/New_York \ -e TZ=America/New_York \
-e PUID=99 \ -e PUID="$(id -u)" \
-e PGID=100 \ -e PGID="$(id -g)" \
-v /your/persistent/path:/data \ -v "$PWD/vboxstock-data:/data" \
ghcr.io/mfwadejr/vboxstock:latest ghcr.io/mfwadejr/vboxstock:latest
``` ```
Open `http://YOUR-SERVER-IP:3000`, sign in with the initial credentials above, and change the password when prompted. Open `http://YOUR-SERVER-IP:8269`, sign in with the initial credentials above, and change the password when prompted. Port `8269` is the recommended host port; it maps to vBoxStock's internal container port `3000`.
The host path mounted at `/data` is essential. Removing the container is safe when this mount remains intact; running without a persistent mount means the database can be lost when the container is replaced. `PUID` and `PGID` determine which host user and group own the mounted data. The defaults are Unraid's `nobody:users` IDs, `99:100`. The host path mounted at `/data` is essential. Removing the container is safe when this mount remains intact; running without a persistent mount means the database can be lost when the container is replaced. `PUID` and `PGID` determine which host user and group own the database and backup files. They should match the account that owns the host-side data directory.
Typical identity settings:
| Platform | PUID | PGID | Guidance |
| --- | ---: | ---: | --- |
| General Linux Docker | Output of `id -u` | Output of `id -g` | The quick-start command determines these automatically. |
| Docker Desktop on macOS or Windows | `1000` | `1000` | Normally suitable because bind-mount permissions are mediated by Docker Desktop. |
| ZimaOS | `1000` | `1000` | Included in `docker-compose.zimaos.yml`. |
| Unraid | `99` | `100` | Maps to Unraid's standard `nobody:users` ownership. |
These values control file ownership only; they are not vBoxStock login credentials.
## Docker Compose ## Docker Compose
The included `docker-compose.yml` stores data in `./data` alongside the Compose file: The included `docker-compose.yml` has the explicit project name `vboxstock` and stores data in `./data` alongside the Compose file. Its identity values can be overridden with `PUID`, `PGID`, and `TZ`; otherwise it uses the common general-Docker defaults `1000:1000` and `America/New_York`:
```sh ```sh
docker compose up -d docker compose up -d
``` ```
The `docker run` example above is a terminal command. Do not paste it into a Compose or YAML editor; use the Compose file instead.
To update later: To update later:
```sh ```sh
@@ -102,6 +141,37 @@ docker compose pull
docker compose up -d docker compose up -d
``` ```
## ZimaOS
Use [`docker-compose.zimaos.yml`](docker-compose.zimaos.yml) with ZimaOS's custom app/Compose installer. It includes the required `vboxstock` project name, ZimaOS dashboard metadata, the application icon, and a persistent data mapping to `/DATA/AppData/vboxstock`.
If entering the configuration manually, replace any ZimaOS-generated `<your project name>` placeholder with `vboxstock`. The angle brackets and placeholder text must not remain in the saved YAML. Do not paste the `docker run` command into the YAML editor.
ZimaOS settings:
| Setting | Value |
| --- | --- |
| Project name | `vboxstock` |
| Image | `ghcr.io/mfwadejr/vboxstock:latest` |
| WebUI host port | `8269` |
| Internal container port | `3000` |
| Container data path | `/data` |
| ZimaOS host path | `/DATA/AppData/vboxstock` |
| PUID | `1000` |
| PGID | `1000` |
After installation, open `http://YOUR-ZIMAOS-IP:8269`. The ZimaOS-specific Compose file supplies the icon automatically through its `x-casaos` metadata.
## Container icon
Use this direct PNG URL in ZimaOS, Unraid, Portainer, or another container dashboard that accepts a custom icon:
```text
https://raw.githubusercontent.com/mfwadejr/vboxstock/main/public/assets/vboxstock-icon-512.png
```
The included ZimaOS Compose file and Unraid XML template already contain this URL.
## Unraid ## Unraid
Use the included `vboxstock-unraid.xml` template or create a container with these settings: Use the included `vboxstock-unraid.xml` template or create a container with these settings:
@@ -109,7 +179,8 @@ Use the included `vboxstock-unraid.xml` template or create a container with thes
| Setting | Value | | Setting | Value |
| --- | --- | | --- | --- |
| Repository | `ghcr.io/mfwadejr/vboxstock:latest` | | Repository | `ghcr.io/mfwadejr/vboxstock:latest` |
| WebUI port | `3000` | | WebUI host port | `8269` |
| Internal container port | `3000` |
| Container data path | `/data` | | Container data path | `/data` |
| Suggested Unraid host path | `/mnt/user/appdata/vboxstock` | | Suggested Unraid host path | `/mnt/user/appdata/vboxstock` |
| Network mode | `bridge` | | Network mode | `bridge` |
@@ -140,13 +211,33 @@ A fresh installation creates an empty inventory, sales history, and customer lis
The Admin page can create a transactionally consistent snapshot, download it to another device, restore a local snapshot, or upload and restore a downloaded copy. A pre-restore snapshot is created automatically before the active database is replaced. The Admin page can create a transactionally consistent snapshot, download it to another device, restore a local snapshot, or upload and restore a downloaded copy. A pre-restore snapshot is created automatically before the active database is replaced.
Automatic backups can be enabled under **Admin → Database backups**. Choose **Every day** or **Weekly**; weekly schedules also provide a weekday selection. Select the local backup time and retain between 1 and 365 automatic snapshots. Retention applies only to `automatic-*.db` files and legacy `scheduled-*.db` files; manual, pre-upgrade, and pre-restore backups are never removed automatically. The application checks the schedule every 15 minutes and shows the last and next automatic-backup times.
The separate **System diagnostics** section reports application and database-schema versions, Node.js version, database size, `/data` writability, free disk space, configured time zone, and the latest backup. These checks are local to the container and do not transmit system information anywhere.
## Product model catalog ## Product model catalog
Administrators manage product models from **Admin → Product models**. Active models appear alphabetically in the Receive Product dropdown. Archiving a model removes it from that dropdown but does not change existing inventory, sales, customer history, or reports. Available units that use an archived model can still be sold, and an archived model can be reactivated at any time. Administrators manage product models from **Admin → Product models**. Active models appear alphabetically in the Receive Product dropdown. Archiving a model removes it from that dropdown but does not change existing inventory, sales, customer history, or reports. Available units that use an archived model can still be sold, and an archived model can be reactivated at any time.
Model names are unique regardless of capitalization and may contain up to 60 characters. An unused model can be renamed or permanently deleted. Once a model has been used by an inventory or sales record, its name is preserved for historical accuracy; archive it and create a new model instead of renaming or deleting it. The Admin page displays separate available and sold usage counts before an archive is confirmed. Model names are unique regardless of capitalization and may contain up to 60 characters. An unused model can be renamed or permanently deleted. Once a model has been used by an inventory or sales record, its name is preserved for historical accuracy; archive it and create a new model instead of renaming or deleting it. The Admin page displays separate available and sold usage counts before an archive is confirmed.
The model catalog is stored in `vboxstock.db`, so it is included automatically in every backup and restore. Upgrading from a release with the original fixed model list migrates the existing database in place and first creates a `pre-model-catalog-*.db` safety backup in `/data/backups`. ### Fulfillment and warranty records
Every new sale records a delivery method: **Shipped**, **Dropped Off**, **Installed At**, or **Meet**. Shipping and installation require an address. Drop-off and meetup records accept a venue, an optional address, or a descriptive fulfillment note, so locations such as a store or gas station do not become the customer's permanent address. Only Shipped and Installed At sales update the address shown on the customer record.
Shipped sales support UPS, FedEx, USPS, or Other, an optional tracking number, and a manually maintained delivery status. Recognized carriers receive a direct link to their official tracking page. Carrier websites are not scraped and delivery statuses are not fetched automatically.
Administrators manage reusable warranty periods under **Admin → Warranty periods**. The initial choices are No Warranty, 30 Days, 60 Days, 90 Days, and 1 Year. Custom durations can use days, months, or years, and one active period is the default for new sales. Once used, a period is preserved for historical accuracy and can be archived but not edited or deleted. Each sale stores the selected warranty and calculated end date as a snapshot; changing the default does not rewrite previous sales.
### Customer matching, filters, and exports
The sale form suggests existing customers by name and phone number. Selecting a suggestion reuses its customer ID, while the server also normalizes phone digits and names to reduce accidental duplicates. Meetup and drop-off locations remain sale-specific and do not overwrite a customer's permanent address.
Administrators can correct every sale field later, including customer, phone, date, price, payment, fulfillment, tracking, warranty, and notes. Changing the sale date recalculates the selected warranty end date. Inventory and sales can be filtered by model and date; sales also support payment, fulfillment, and warranty-status filters.
CSV downloads are available for inventory, sales, customers, and warranty configuration. Administrators can additionally export the security audit log. Exports are generated directly from the active database and do not use an external reporting service.
The model catalog is stored in `vboxstock.db`, so it is included automatically in every backup and restore. Schema-changing upgrades migrate the existing database in place and create a pre-upgrade safety backup in `/data/backups` when required.
Backups contain customer information and password hashes. Store downloaded copies securely. Restoring a database also restores the user accounts contained in that backup and signs out every active session. An older backup without user accounts starts the first-login `admin` / `admin` setup flow. Backups contain customer information and password hashes. Store downloaded copies securely. Restoring a database also restores the user accounts contained in that backup and signs out every active session. An older backup without user accounts starts the first-login `admin` / `admin` setup flow.
@@ -168,8 +259,9 @@ docker exec -e RESET_ADMIN_PASSWORD=NewPassword123 -it vboxstock node server.mjs
- Image: `ghcr.io/mfwadejr/vboxstock:latest` - Image: `ghcr.io/mfwadejr/vboxstock:latest`
- Application port: `3000/tcp` - Application port: `3000/tcp`
- Recommended host port: `8269/tcp` (mapped to container port `3000`)
- Persistent volume: `/data` - Persistent volume: `/data`
- Runtime ownership: configurable with `PUID` and `PGID` (`99:100` by default) - Runtime ownership: configurable with `PUID` and `PGID`; installation examples set platform-appropriate values. The image fallback is `99:100` for Unraid compatibility.
- Health check: `GET /api/health` - Health check: `GET /api/health`
- Runtime: Node.js 22 - Runtime: Node.js 22
- Database: SQLite - Database: SQLite
+6 -4
View File
@@ -1,13 +1,15 @@
name: vboxstock
services: services:
vboxstock: vboxstock:
image: ghcr.io/mfwadejr/vboxstock:latest image: ghcr.io/mfwadejr/vboxstock:latest
container_name: vboxstock container_name: vboxstock
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "8269:3000"
volumes: volumes:
- ./data:/data - ./data:/data
environment: environment:
- TZ=America/New_York TZ: ${TZ:-America/New_York}
- PUID=99 PUID: ${PUID:-1000}
- PGID=100 PGID: ${PGID:-1000}
+42
View File
@@ -0,0 +1,42 @@
name: vboxstock
services:
vboxstock:
image: ghcr.io/mfwadejr/vboxstock:latest
container_name: vboxstock
restart: unless-stopped
ports:
- target: 3000
published: "8269"
protocol: tcp
volumes:
- type: bind
source: /DATA/AppData/vboxstock
target: /data
environment:
TZ: America/New_York
PUID: "1000"
PGID: "1000"
x-casaos:
id: com.mfwadejr.vboxstock
main: vboxstock
index: /
port_map: "8269"
scheme: http
icon: https://raw.githubusercontent.com/mfwadejr/vboxstock/main/public/assets/vboxstock-icon-512.png
title:
en_US: vBoxStock
tagline:
en_US: Self-hosted vSeeBox inventory and sales tracking
description:
en_US: Track individually identifiable vSeeBox inventory from receipt through sale, including customers, payments, shipping, notes, users, and backups.
author: mfwadejr
developer: mfwadejr
category: Productivity
architectures:
- amd64
version: "3.3.1"
update_at: "2026-08-30"
release_notes:
en_US: Added administrator-managed product models with archival and historical preservation.
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vboxstock", "name": "vboxstock",
"version": "3.1.0", "version": "3.5.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "node": ">=22.13.0" }, "engines": { "node": ">=22.13.0" },
+63 -13
View File
@@ -1,5 +1,5 @@
const $ = (s) => document.querySelector(s); const $ = (s) => document.querySelector(s);
const state = { products: [], models: [], tab: "available", query: "", page: 1, user: null }; const state = { products: [], models: [], warranties: [], customerDirectory: [], tab: "available", query: "", page: 1, user: null, filters:{model:"",payment:"",fulfillment:"",warranty:"",from:"",to:""} };
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const today = () => new Date().toISOString().slice(0, 10); const today = () => new Date().toISOString().slice(0, 10);
@@ -27,6 +27,15 @@ function toast(message) { const el=$("#toast"); el.textContent=message; el.hidde
function openModal(html) { $("#modalBody").innerHTML=html; $("#modal").showModal(); } function openModal(html) { $("#modalBody").innerHTML=html; $("#modal").showModal(); }
function closeModal() { $("#modal").close(); } function closeModal() { $("#modal").close(); }
function address(p) { return [p.shipAddress1,p.shipAddress2,[p.shipCity,p.shipState].filter(Boolean).join(", "),p.shipZip].filter(Boolean).join(" · "); } function address(p) { return [p.shipAddress1,p.shipAddress2,[p.shipCity,p.shipState].filter(Boolean).join(", "),p.shipZip].filter(Boolean).join(" · "); }
function warrantyState(p){if(!p.warrantyName)return{label:"Not recorded",className:"neutral"};if(!p.warrantyEndDate)return{label:p.warrantyName==="No Warranty"?"No Warranty":p.warrantyName,className:"neutral"};const days=Math.round((new Date(`${p.warrantyEndDate}T12:00:00Z`)-new Date(`${today()}T12:00:00Z`))/86400000);return days>=0?{label:`In Warranty · ${days} day${days===1?"":"s"} remaining`,className:"in-warranty"}:{label:`Expired · ${Math.abs(days)} day${days===-1?"":"s"} ago`,className:"expired"};}
function warrantyBadge(p){const w=warrantyState(p);return `<span class="warranty-pill ${w.className}">${esc(w.label)}</span>`;}
function trackingUrl(p){if(!p.trackingNumber)return"";const n=encodeURIComponent(p.trackingNumber);return p.carrier==="UPS"?`https://www.ups.com/track?tracknum=${n}`:p.carrier==="FedEx"?`https://www.fedex.com/fedextrack/?trknbr=${n}`:p.carrier==="USPS"?`https://tools.usps.com/go/TrackConfirmAction?tLabels=${n}`:"";}
const fulfillmentTitle=method=>({Shipped:"Shipped to","Dropped Off":"Dropped off","Installed At":"Installed at",Meet:"Meet"}[method]||"Fulfillment");
function warrantyOptions(selected=""){return state.warranties.map(w=>`<option value="${w.id}" ${(selected?w.id===selected:w.isDefault)?"selected":""}>${esc(w.name)}</option>`).join("");}
function fulfillmentFields(method="",p={}){if(!method)return'<p class="conditional-hint">Select a delivery method to enter its details.</p>';const addressRequired=new Set(["Shipped","Installed At"]).has(method),casual=new Set(["Dropped Off","Meet"]).has(method),nameLabel=method==="Meet"?"Meeting place / venue":method==="Dropped Off"?"Drop-off location / venue":"Location name (optional)",noteLabel=method==="Meet"?"Meet details":method==="Dropped Off"?"Drop-off details":"Fulfillment notes";return `<div class="conditional-fields"><h3>${fulfillmentTitle(method)}</h3>${method!=="Shipped"?`<label>${nameLabel}<input name="fulfillmentName" value="${esc(p.fulfillmentName||"")}" placeholder="${method==="Meet"?"Example: Wawa on Main Street":method==="Dropped Off"?"Example: Exxon station":"Business or residence"}"></label>`:""}<label>Street address${addressRequired?"":" (optional)"}<input name="shipAddress1" value="${esc(p.shipAddress1||"")}" ${addressRequired?"required":""} autocomplete="shipping address-line1"></label><label>Apartment, suite, or unit<input name="shipAddress2" value="${esc(p.shipAddress2||"")}" autocomplete="shipping address-line2"></label><div class="address-grid"><label>City<input name="shipCity" value="${esc(p.shipCity||"")}" ${addressRequired?"required":""} autocomplete="shipping address-level2"></label><label>State<input name="shipState" value="${esc(p.shipState||"")}" ${addressRequired?"required":""} autocomplete="shipping address-level1"></label><label>ZIP code<input name="shipZip" value="${esc(p.shipZip||"")}" ${addressRequired?"required":""} autocomplete="shipping postal-code"></label></div>${method==="Shipped"?`<div class="form-row"><label>Carrier<select name="carrier" required><option value="">Choose carrier</option>${["UPS","FedEx","USPS","Other"].map(x=>`<option ${p.carrier===x?"selected":""}>${x}</option>`).join("")}</select></label><label>Tracking number (optional)<input name="trackingNumber" value="${esc(p.trackingNumber||"")}"></label></div><label>Delivery status<select name="deliveryStatus">${["Awaiting Tracking","Label Created","In Transit","Out for Delivery","Delivered","Delivery Exception","Returned","Unknown"].map(x=>`<option ${p.deliveryStatus===x?"selected":""}>${x}</option>`).join("")}</select></label>`:""}<label>${noteLabel}<textarea name="fulfillmentNotes" rows="2" placeholder="${casual?"Add a venue, address, or enough detail to identify where the handoff occurred":"Optional delivery or installation details"}">${esc(p.fulfillmentNotes||p.shippingNotes||"")}</textarea></label>${casual?'<small class="field-help">Enter at least a venue, address, or detail.</small>':""}</div>`;}
function bindFulfillment(form,p={}){const select=form.querySelector('[name="fulfillmentMethod"]'),target=form.querySelector(".fulfillment-fields");const draw=()=>target.innerHTML=fulfillmentFields(select.value,p);select.onchange=()=>{p={};draw();};draw();}
function customerFields(p={}){return `<input type="hidden" name="customerId" value="${esc(p.customerId||"")}"><div class="form-row"><label>Customer name<input name="customerName" list="customerChoices" value="${esc(p.customerName||"")}" autocomplete="off" required><datalist id="customerChoices">${state.customerDirectory.map(c=>`<option value="${esc(c.name)}">${esc(c.phone||"No phone")}</option>`).join("")}</datalist></label><label>Cell phone<input name="phone" type="tel" value="${esc(p.phone||"")}" autocomplete="tel"></label></div><p class="customer-match" aria-live="polite"></p>`}
function bindCustomer(form){const name=form.elements.customerName,phone=form.elements.phone,id=form.elements.customerId,status=form.querySelector(".customer-match");const match=()=>{const phoneDigits=phone.value.replace(/\D/g,"").slice(-10),found=state.customerDirectory.find(c=>phoneDigits&&c.phone.replace(/\D/g,"").slice(-10)===phoneDigits)||state.customerDirectory.find(c=>c.name.toLowerCase()===name.value.trim().toLowerCase());if(found){id.value=found.id;name.value=found.name;if(!phone.value)phone.value=found.phone;status.textContent=`Existing customer selected${found.phone?` · ${found.phone}`:""}.`;}else{id.value="";status.textContent=name.value?"A new customer will be created.":"";}};name.onchange=match;name.oninput=()=>{id.value="";status.textContent=""};phone.onchange=match;match();}
function idCell(p) { return ids(p).length ? ids(p).map(([k,v])=>`<code>${k}: ${esc(v)}</code>`).join("") : "<small>Not entered</small>"; } function idCell(p) { return ids(p).length ? ids(p).map(([k,v])=>`<code>${k}: ${esc(v)}</code>`).join("") : "<small>Not entered</small>"; }
function customers() { function customers() {
const grouped=new Map(); const grouped=new Map();
@@ -41,8 +50,9 @@ function filtered() {
const q=state.query.toLowerCase().trim(); const q=state.query.toLowerCase().trim();
if(state.tab==="customers") return customers().filter(c=>!q||[c.name,c.phone].some(v=>String(v||"").toLowerCase().includes(q))||c.purchases.some(p=>Object.values(p).some(v=>String(v??"").toLowerCase().includes(q)))); if(state.tab==="customers") return customers().filter(c=>!q||[c.name,c.phone].some(v=>String(v||"").toLowerCase().includes(q))||c.purchases.some(p=>Object.values(p).some(v=>String(v??"").toLowerCase().includes(q))));
const status=state.tab==="available"?"available":"sold"; const status=state.tab==="available"?"available":"sold";
return state.products.filter(p=>p.status===status && (!q || Object.values(p).some(v=>String(v??"").toLowerCase().includes(q)))); return state.products.filter(p=>p.status===status&&(!q||Object.values(p).some(v=>String(v??"").toLowerCase().includes(q)))&&(!state.filters.model||p.model===state.filters.model)&&(!state.filters.payment||p.paymentMethod===state.filters.payment)&&(!state.filters.fulfillment||p.fulfillmentMethod===state.filters.fulfillment)&&(!state.filters.from||(p.soldAt||p.receivedAt)>=state.filters.from)&&(!state.filters.to||(p.soldAt||p.receivedAt)<=state.filters.to)&&(!state.filters.warranty||(state.filters.warranty==="active"&&warrantyState(p).className==="in-warranty")||(state.filters.warranty==="expired"&&warrantyState(p).className==="expired")||(state.filters.warranty==="none"&&warrantyState(p).className==="neutral")));
} }
function renderFilters(){const bar=$("#filterBar");if(state.tab==="admin"){bar.innerHTML="";bar.hidden=true;return}if(state.tab==="customers"){bar.hidden=false;bar.innerHTML='<a class="button secondary export-button" href="/api/export/customers">Export customers CSV</a>';return}bar.hidden=false;const models=[...new Set(state.products.map(p=>p.model))].sort();bar.innerHTML=`<label>Model<select data-filter="model"><option value="">All models</option>${models.map(x=>`<option ${state.filters.model===x?"selected":""}>${esc(x)}</option>`).join("")}</select></label>${state.tab==="sold"?`<label>Payment<select data-filter="payment"><option value="">All payments</option>${["Cash","Venmo","PayPal"].map(x=>`<option ${state.filters.payment===x?"selected":""}>${x}</option>`).join("")}</select></label><label>Fulfillment<select data-filter="fulfillment"><option value="">All methods</option>${["Shipped","Dropped Off","Installed At","Meet"].map(x=>`<option ${state.filters.fulfillment===x?"selected":""}>${x}</option>`).join("")}</select></label><label>Warranty<select data-filter="warranty"><option value="">All warranties</option><option value="active" ${state.filters.warranty==="active"?"selected":""}>In Warranty</option><option value="expired" ${state.filters.warranty==="expired"?"selected":""}>Expired</option><option value="none" ${state.filters.warranty==="none"?"selected":""}>None / Not recorded</option></select></label>`:""}<label>From<input type="date" data-filter="from" value="${state.filters.from}"></label><label>To<input type="date" data-filter="to" value="${state.filters.to}"></label><button class="secondary" id="clearFilters">Clear</button><a class="button secondary export-button" href="/api/export/${state.tab==="sold"?"sales":"inventory"}">Export CSV</a>`;bar.querySelectorAll("[data-filter]").forEach(el=>el.onchange=()=>{state.filters[el.dataset.filter]=el.value;state.page=1;render()});$("#clearFilters").onclick=()=>{state.filters={model:"",payment:"",fulfillment:"",warranty:"",from:"",to:""};state.page=1;render()}}
function render() { function render() {
const available=state.products.filter(p=>p.status==="available"), sold=state.products.filter(p=>p.status==="sold"), month=today().slice(0,7), monthSales=sold.filter(p=>p.soldAt?.startsWith(month)); const available=state.products.filter(p=>p.status==="available"), sold=state.products.filter(p=>p.status==="sold"), month=today().slice(0,7), monthSales=sold.filter(p=>p.soldAt?.startsWith(month));
$("#availableCount").textContent=available.length; $("#soldCount").textContent=monthSales.length; $("#availableCount").textContent=available.length; $("#soldCount").textContent=monthSales.length;
@@ -50,42 +60,54 @@ function render() {
$("#revenue").textContent=monthSales.length?`${money.format(monthSales.reduce((n,p)=>n+Number(p.salePrice||0),0))} in sales`:"No sales recorded"; $("#revenue").textContent=monthSales.length?`${money.format(monthSales.reduce((n,p)=>n+Number(p.salePrice||0),0))} in sales`:"No sales recorded";
$("#availableBadge").textContent=available.length; $("#soldBadge").textContent=sold.length; $("#customerBadge").textContent=customers().length; $("#availableBadge").textContent=available.length; $("#soldBadge").textContent=sold.length; $("#customerBadge").textContent=customers().length;
document.querySelectorAll("[data-tab]").forEach(b=>b.classList.toggle("active",b.dataset.tab===state.tab)); document.querySelectorAll("[data-tab]").forEach(b=>b.classList.toggle("active",b.dataset.tab===state.tab));
renderFilters();
if(state.tab==="admin") { $(".table-wrap").hidden=true; $("#adminPanel").hidden=false; $("#pagination").innerHTML=""; renderAdmin(); return; } if(state.tab==="admin") { $(".table-wrap").hidden=true; $("#adminPanel").hidden=false; $("#pagination").innerHTML=""; renderAdmin(); return; }
$(".table-wrap").hidden=false; $("#adminPanel").hidden=true; $(".table-wrap").hidden=false; $("#adminPanel").hidden=true;
const all=filtered(), pages=Math.max(1,Math.ceil(all.length/PAGE_SIZE)); state.page=Math.min(state.page,pages); const all=filtered(), pages=Math.max(1,Math.ceil(all.length/PAGE_SIZE)); state.page=Math.min(state.page,pages);
const start=(state.page-1)*PAGE_SIZE, rows=all.slice(start,start+PAGE_SIZE); const start=(state.page-1)*PAGE_SIZE, rows=all.slice(start,start+PAGE_SIZE);
$("#thead").innerHTML=state.tab==="available"?"<tr><th>Product</th><th>UID / SN / MAC</th><th>Received</th><th>Cost</th><th>Status</th><th></th></tr>":state.tab==="customers"?"<tr><th>Customer</th><th>Phone</th><th>Purchases</th><th>Last purchase</th><th>Total spent</th><th></th></tr>":"<tr><th>Product</th><th>Customer</th><th>UID / SN / MAC</th><th>Sold</th><th>Payment</th><th>Sale price</th><th></th></tr>"; $("#thead").innerHTML=state.tab==="available"?"<tr><th>Product</th><th>UID / SN / MAC</th><th>Received</th><th>Cost</th><th>Status</th><th></th></tr>":state.tab==="customers"?"<tr><th>Customer</th><th>Phone</th><th>Purchases</th><th>Last purchase</th><th>Total spent</th><th></th></tr>":"<tr><th>Product</th><th>Customer</th><th>Sold</th><th>Payment</th><th>Sale price</th><th>Warranty</th><th></th></tr>";
$("#rows").innerHTML=rows.map(p=>state.tab==="available"?inventoryRow(p):state.tab==="customers"?customerRow(p):saleRow(p)).join(""); $("#rows").innerHTML=rows.map(p=>state.tab==="available"?inventoryRow(p):state.tab==="customers"?customerRow(p):saleRow(p)).join("");
decorateResponsiveTable(); decorateResponsiveTable();
$("#empty").hidden=Boolean(rows.length); $("#empty").textContent=state.query?"No records match your search.":state.tab==="available"?"No products are available.":state.tab==="customers"?"Customer records will appear after the first sale.":"No sales have been recorded."; $("#empty").hidden=Boolean(rows.length); $("#empty").textContent=state.query?"No records match your search.":state.tab==="available"?"No products are available.":state.tab==="customers"?"Customer records will appear after the first sale.":"No sales have been recorded.";
$("#pagination").innerHTML=all.length?`<span>Showing ${start+1}${Math.min(start+PAGE_SIZE,all.length)} of ${all.length}</span><div><button data-page="prev" ${state.page===1?"disabled":""}>Previous</button><strong>Page ${state.page} of ${pages}</strong><button data-page="next" ${state.page===pages?"disabled":""}>Next</button></div>`:""; $("#pagination").innerHTML=all.length?`<span>Showing ${start+1}${Math.min(start+PAGE_SIZE,all.length)} of ${all.length}</span><div><button data-page="prev" ${state.page===1?"disabled":""}>Previous</button><strong>Page ${state.page} of ${pages}</strong><button data-page="next" ${state.page===pages?"disabled":""}>Next</button></div>`:"";
} }
function inventoryRow(p) { return `<tr><td class="product"><strong>${esc(p.model)}</strong><small>${esc(p.manufacturer)} · ${esc(p.condition)}</small></td><td><div class="ids">${idCell(p)}</div></td><td>${fmtDate(p.receivedAt)}</td><td>${p.cost?money.format(p.cost):"—"}</td><td><span class="pill">Available</span></td><td><div class="row-actions">${state.user.role==="admin"?`<button data-sell="${p.id}">Sell</button><button class="danger" data-delete="${p.id}">Delete</button>`:""}</div></td></tr>`; } function inventoryRow(p) { return `<tr><td class="product"><strong>${esc(p.model)}</strong><small>${esc(p.manufacturer)} · ${esc(p.condition)}</small></td><td><div class="ids">${idCell(p)}</div></td><td>${fmtDate(p.receivedAt)}</td><td>${p.cost?money.format(p.cost):"—"}</td><td><span class="pill">Available</span></td><td><div class="row-actions">${state.user.role==="admin"?`<button data-sell="${p.id}">Sell</button><button class="danger" data-delete="${p.id}">Delete</button>`:""}</div></td></tr>`; }
function saleRow(p) { return `<tr><td class="product"><strong>${esc(p.model)}</strong><small>${esc(p.manufacturer)} · ${esc(p.condition)}</small></td><td class="customer"><button class="customer-link" data-customer="${p.customerId||""}" data-id="${p.id}">${esc(p.customerName||"Unknown")}</button>${p.phone?`<small>${esc(p.phone)}</small>`:""}</td><td><div class="ids">${idCell(p)}</div></td><td>${fmtDate(p.soldAt)}</td><td>${esc(p.paymentMethod||"—")}${p.paymentReference?`<small>${esc(p.paymentReference)}</small>`:""}</td><td>${p.salePrice?money.format(p.salePrice):"—"}</td><td><div class="row-actions"><button data-view="${p.id}">View</button>${state.user.role==="admin"?`<button data-restock="${p.id}">Void & restock</button><button class="danger" data-delete="${p.id}">Delete</button>`:""}</div></td></tr>`; } function saleRow(p) { return `<tr><td class="product"><strong>${esc(p.model)}</strong><small>${esc(p.manufacturer)} · ${esc(primaryId(p))}</small></td><td class="customer"><button class="customer-link" data-customer="${p.customerId||""}" data-id="${p.id}">${esc(p.customerName||"Unknown")}</button>${p.phone?`<small>${esc(p.phone)}</small>`:""}</td><td>${fmtDate(p.soldAt)}</td><td>${esc(p.paymentMethod||"—")}${p.paymentReference?`<small>${esc(p.paymentReference)}</small>`:""}</td><td>${p.salePrice?money.format(p.salePrice):"—"}</td><td>${warrantyBadge(p)}</td><td><div class="row-actions"><button data-view="${p.id}">View</button>${state.user.role==="admin"?`<button data-restock="${p.id}">Void & restock</button><button class="danger" data-delete="${p.id}">Delete</button>`:""}</div></td></tr>`; }
function customerRow(c) { return `<tr><td class="customer"><strong>${esc(c.name)}</strong></td><td>${esc(c.phone||"—")}</td><td>${c.count}</td><td>${fmtDate(c.lastPurchase)}</td><td>${c.total?money.format(c.total):"—"}</td><td><button class="customer-link" data-customer="${c.customerId||""}" data-id="${c.representative.id}">View customer</button></td></tr>`; } function customerRow(c) { return `<tr><td class="customer"><strong>${esc(c.name)}</strong></td><td>${esc(c.phone||"—")}</td><td>${c.count}</td><td>${fmtDate(c.lastPurchase)}</td><td>${c.total?money.format(c.total):"—"}</td><td><button class="customer-link" data-customer="${c.customerId||""}" data-id="${c.representative.id}">View customer</button></td></tr>`; }
function receiveForm() { function receiveForm() {
if(!state.models.length){openModal('<h2>No active models</h2><p>Add or reactivate a product model from the Admin page before receiving inventory.</p><div class="form-actions"><button class="primary" data-cancel>Close</button></div>');$("[data-cancel]").onclick=closeModal;return;} if(!state.models.length){openModal('<h2>No active models</h2><p>Add or reactivate a product model from the Admin page before receiving inventory.</p><div class="form-actions"><button class="primary" data-cancel>Close</button></div>');$("[data-cancel]").onclick=closeModal;return;}
openModal(`<h2>Receive product</h2><p>Add a vSeeBox unit to available inventory.</p><form id="receiveForm"><div class="scan"><label>UID<input name="uid" autofocus></label><label>Serial number<input name="sn"></label><label>MAC address<input name="mac"></label></div><div class="form-row"><label>Model<select name="model" required>${state.models.map(m=>`<option value="${esc(m.name)}">${esc(m.name)}</option>`).join("")}</select></label><label>Condition<select name="condition"><option>New</option><option>Used</option><option>Refurbished</option></select></label></div><div class="form-row"><label>Received date<input name="receivedAt" type="date" value="${today()}" required></label><label>Purchase cost<input name="cost" type="number" min="0" step=".01"></label></div><label>Notes<textarea name="notes" rows="2"></textarea></label><div class="form-actions"><button type="button" class="secondary" data-cancel>Cancel</button><button class="primary">Receive product</button></div></form>`); openModal(`<h2>Receive inventory</h2><p>Enter the shared batch details, then scan each device label. The three barcodes are added as one device at a time.</p><form id="receiveForm"><div class="form-row"><label>Quantity<input name="quantity" type="number" min="1" max="100" value="1" required></label><label>Model<select name="model" required>${state.models.map(m=>`<option value="${esc(m.name)}">${esc(m.name)}</option>`).join("")}</select></label></div><div class="form-row"><label>Condition<select name="condition"><option>New</option><option>Used</option><option>Refurbished</option></select></label><label>Received date<input name="receivedAt" type="date" value="${today()}" required></label></div><div class="form-row"><label>Purchase cost per device<input name="cost" type="number" min="0" step=".01"></label><label>Notes<textarea name="notes" rows="1"></textarea></label></div><section class="scan-panel"><div class="scan-panel-heading"><h3>Current device</h3><span data-scan-progress>0 of 1 added</span></div><div class="scan"><label>UID<input name="uid" autocomplete="off" autofocus></label><label>Serial number<input name="sn" autocomplete="off"></label><label>MAC address<input name="mac" autocomplete="off"></label></div><button type="button" class="secondary scan-camera">Scan label with camera</button><button type="button" class="secondary add-device">Add device to batch</button><p class="field-help scan-help">Scan the three barcodes on one label, or enter them manually. Duplicate identifiers are rejected.</p></section><div class="batch-list" data-batch-list><p class="empty-batch">No devices added yet.</p></div><div class="form-actions"><button type="button" class="secondary" data-cancel>Cancel</button><button class="primary">Save batch</button></div></form>`);
$("#receiveForm").onsubmit=e=>submitForm(e,"/api/products","Product received."); $("[data-cancel]").onclick=closeModal; const form=$("#receiveForm"),batch=[],quantity=form.elements.quantity,progress=form.querySelector("[data-scan-progress]"),list=form.querySelector("[data-batch-list]"),help=form.querySelector(".scan-help");
const duplicate=(row)=>[row.uid,row.sn,row.mac].filter(Boolean).some(value=>{const key=value.trim().toLowerCase();return batch.some(x=>[x.uid,x.sn,x.mac].some(y=>y.trim().toLowerCase()===key))||state.products.some(x=>[x.uid,x.sn,x.mac].some(y=>y&&y.trim().toLowerCase()===key))});
const renderBatch=()=>{progress.textContent=`${batch.length} of ${quantity.value||1} added`;list.innerHTML=batch.length?batch.map((x,i)=>`<div class="batch-row"><strong>#${i+1}</strong><span>UID ${esc(x.uid||"—")}</span><span>SN ${esc(x.sn||"—")}</span><span>MAC ${esc(x.mac||"—")}</span><button type="button" class="danger" data-remove-batch="${i}">Remove</button></div>`).join(""):'<p class="empty-batch">No devices added yet.</p>';form.querySelector("button.primary").disabled=batch.length!==Number(quantity.value||1)};
form.elements.quantity.oninput=()=>{if(Number(quantity.value)>100)quantity.value=100;renderBatch()};form.querySelector(".add-device").onclick=()=>{const row={uid:form.elements.uid.value.trim(),sn:form.elements.sn.value.trim(),mac:form.elements.mac.value.trim()};if(!row.uid&&!row.sn&&!row.mac){help.textContent="Enter or scan at least one identifier.";return}if(duplicate(row)){help.textContent="Duplicate identifier detected. Scan a different device.";return}if(batch.length>=Number(quantity.value)){help.textContent="The requested quantity is already filled.";return}batch.push(row);form.elements.uid.value=form.elements.sn.value=form.elements.mac.value="";help.textContent="Device added. Scan the next label.";renderBatch();form.elements.uid.focus()};list.onclick=e=>{const button=e.target.closest("[data-remove-batch]");if(button){batch.splice(Number(button.dataset.removeBatch),1);renderBatch()}};
form.querySelector(".scan-camera").onclick=()=>scanLabel(form,help);renderBatch();form.onsubmit=async e=>{e.preventDefault();if(batch.length!==Number(quantity.value)){help.textContent=`Add all ${quantity.value} devices before saving.`;return}const data=Object.fromEntries(new FormData(form));delete data.quantity;delete data.uid;delete data.sn;delete data.mac;data.items=batch;await change("/api/products/batch","POST",data,`${batch.length} ${batch.length===1?"product":"products"} received.`)};$("[data-cancel]").onclick=closeModal;
} }
async function scanLabel(form,help){if(!("BarcodeDetector" in window)){help.textContent="Camera barcode scanning is not supported by this browser. Enter the values manually or use a supported mobile browser.";return}if(!navigator.mediaDevices?.getUserMedia){help.textContent="Camera access is unavailable. Use manual entry or enable camera access for this site.";return}let stream;try{stream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:"environment"}}});const detector=new BarcodeDetector({formats:["code_128","code_39","ean_13","ean_8","itf"]}),video=document.createElement("video");video.autoplay=true;video.playsInline=true;video.srcObject=stream;const overlay=document.createElement("div");overlay.className="scanner-overlay";overlay.innerHTML='<div><strong>Point at the full label</strong><small>Hold steady while the three barcodes are read.</small><button type="button">Cancel</button></div>';overlay.prepend(video);document.body.append(overlay);let cancelled=false;const finish=()=>{cancelled=true;stream.getTracks().forEach(track=>track.stop());overlay.remove()};overlay.querySelector("button").onclick=finish;await video.play();let started=Date.now();while(Date.now()-started<15000&&!cancelled){const codes=await detector.detect(video),values=codes.map(x=>String(x.rawValue||"").trim()).filter(Boolean);for(const value of values){const clean=value.replace(/^\s*(UID|SN|MAC)\s*[:#]?\s*/i,"");if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(clean))form.elements.mac.value=clean;else if(/^V\w/i.test(clean))form.elements.sn.value=clean;else if(!form.elements.uid.value)form.elements.uid.value=clean;else if(!form.elements.sn.value)form.elements.sn.value=clean;else if(!form.elements.mac.value)form.elements.mac.value=clean}if(form.elements.uid.value&&form.elements.sn.value&&form.elements.mac.value){help.textContent="All three identifiers scanned. Add this device to the batch.";break}await new Promise(resolve=>setTimeout(resolve,120))}finish()}catch(error){if(stream)stream.getTracks().forEach(track=>track.stop());help.textContent="Unable to read that label. Try better lighting or enter the identifiers manually."}}
function sellForm(id="") { function sellForm(id="") {
const available=state.products.filter(p=>p.status==="available"); const available=state.products.filter(p=>p.status==="available");
openModal(`<h2>Record a sale</h2><p>Enter the customer, payment, and shipped-to details.</p><form id="sellForm"><label>Product<select name="productId" required>${available.map(p=>`<option value="${p.id}" ${p.id===id?"selected":""}>${esc(p.model)}${esc(primaryId(p))}</option>`).join("")}</select></label><div class="form-row"><label>Customer name<input name="customerName" required></label><label>Cell phone<input name="phone" type="tel"></label></div><div class="form-row"><label>Payment method<select name="paymentMethod" required><option value="">Choose a method</option><option>Cash</option><option>Venmo</option><option>PayPal</option></select></label><label>Payment reference (optional)<input name="paymentReference" placeholder="Transaction ID or note"></label></div><h3>Shipped to</h3><label>Street address<input name="shipAddress1" autocomplete="shipping address-line1"></label><label>Apartment, suite, or unit<input name="shipAddress2" autocomplete="shipping address-line2"></label><div class="address-grid"><label>City<input name="shipCity" autocomplete="shipping address-level2"></label><label>State<input name="shipState" autocomplete="shipping address-level1"></label><label>ZIP code<input name="shipZip" autocomplete="shipping postal-code"></label></div><label>Shipping notes<textarea name="shippingNotes" rows="2"></textarea></label><div class="form-row"><label>Sale price<input name="salePrice" type="number" min="0" step=".01"></label><label>Date sold<input name="soldAt" type="date" value="${today()}" required></label></div><label>Transaction notes (optional)<textarea name="saleNotes" rows="3" placeholder="Example: Met at Wawa for exchange"></textarea></label><div class="form-actions"><button type="button" class="secondary" data-cancel>Cancel</button><button class="primary">Complete sale</button></div></form>`); if(!available.length){openModal('<h2>No available inventory</h2><p>Receive a product before recording a sale.</p><div class="form-actions"><button class="primary" data-cancel>Close</button></div>');$("[data-cancel]").onclick=closeModal;return;}
$("#sellForm").onsubmit=async e=>{ e.preventDefault(); const data=Object.fromEntries(new FormData(e.currentTarget)), productId=data.productId; delete data.productId; await change(`/api/products/${encodeURIComponent(productId)}/sell`,"POST",data,"Sale recorded."); }; $("[data-cancel]").onclick=closeModal; openModal(`<h2>Record a sale</h2><p>Select one or more products. Customer, payment, fulfillment, warranty, and notes apply to every selected item.</p><form id="sellForm"><fieldset class="sale-items"><legend>Products</legend><div class="sale-item-list">${available.map(p=>`<label class="sale-item"><input type="checkbox" name="productIds" value="${p.id}" ${p.id===id?"checked":""}><span><strong>${esc(p.model)}</strong><small>${esc(primaryId(p))}</small></span><span class="sale-item-price">Sale price<input type="number" min="0" step=".01" inputmode="decimal" data-price="${p.id}" aria-label="Sale price for ${esc(p.model)} ${esc(primaryId(p))}" ${p.id===id?"":"disabled"}></span></label>`).join("")}</div><div class="sale-summary" aria-live="polite"><span><strong data-selected-count>0</strong> selected</span><strong data-sale-total>${money.format(0)}</strong></div></fieldset>${customerFields()}<div class="form-row"><label>Payment method<select name="paymentMethod" required><option value="">Choose a method</option><option>Cash</option><option>Venmo</option><option>PayPal</option></select></label><label>Payment reference (optional)<input name="paymentReference" placeholder="Transaction ID or note"></label></div><label>Date sold<input name="soldAt" type="date" value="${today()}" required></label><div class="form-row"><label>Delivery method<select name="fulfillmentMethod" required><option value="">Choose a method</option><option>Shipped</option><option>Dropped Off</option><option>Installed At</option><option>Meet</option></select></label><label>Warranty<select name="warrantyPresetId" required>${warrantyOptions()}</select></label></div><div class="fulfillment-fields"></div><label>Transaction notes (optional)<textarea name="saleNotes" rows="3" placeholder="Additional notes about this sale"></textarea></label><div class="form-actions"><button type="button" class="secondary" data-cancel>Cancel</button><button class="primary">Complete sale</button></div></form>`);
bindFulfillment($("#sellForm"));
const form=$("#sellForm"),checks=[...form.querySelectorAll('[name="productIds"]')],drawTotal=()=>{let count=0,total=0;checks.forEach(check=>{const price=form.querySelector(`[data-price="${check.value}"]`);price.disabled=!check.checked;if(check.checked){count++;total+=Number(price.value)||0}});form.querySelector("[data-selected-count]").textContent=count;form.querySelector("[data-sale-total]").textContent=money.format(total);form.querySelector('.form-actions .primary').textContent=count>1?`Complete sale (${count} items)`:"Complete sale";};checks.forEach(check=>check.onchange=drawTotal);form.querySelectorAll("[data-price]").forEach(input=>input.oninput=drawTotal);drawTotal();
bindCustomer(form);form.onsubmit=async e=>{e.preventDefault();const button=e.submitter,selected=checks.filter(x=>x.checked);if(!selected.length){toast("Select at least one product.");return;}button.disabled=true;const data=Object.fromEntries(new FormData(form));delete data.productIds;data.items=selected.map(check=>({productId:check.value,salePrice:Number(form.querySelector(`[data-price="${check.value}"]`).value)||0}));try{await change("/api/sales","POST",data,`${selected.length} ${selected.length===1?"item":"items"} sold.`);}finally{button.disabled=false}};$("[data-cancel]").onclick=closeModal;
} }
async function submitForm(e,path,message,method="POST"){ e.preventDefault(); await change(path,method,Object.fromEntries(new FormData(e.currentTarget)),message); } async function submitForm(e,path,message,method="POST"){ e.preventDefault(); await change(path,method,Object.fromEntries(new FormData(e.currentTarget)),message); }
// Re-declared here to keep camera access available on Safari, where BarcodeDetector may be absent.
async function scanLabelCompat(form,help){if(!navigator.mediaDevices?.getUserMedia){help.textContent="Camera access is unavailable. Use manual entry or enable camera access for this site.";return}let stream,overlay;try{stream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:"environment"}}});const video=document.createElement("video");video.autoplay=true;video.playsInline=true;video.srcObject=stream;overlay=document.createElement("div");overlay.className="scanner-overlay";overlay.innerHTML='<div><strong>Point at the full label</strong><small>This browser can show the camera but cannot decode barcodes automatically. Enter the three values below, then close this view.</small><button type="button">Close camera</button></div>';overlay.prepend(video);document.body.append(overlay);const finish=()=>{stream.getTracks().forEach(track=>track.stop());overlay.remove()};overlay.querySelector("button").onclick=finish;await video.play();help.textContent="Camera is open. Enter the identifiers below, then close this view."}catch(error){if(stream)stream.getTracks().forEach(track=>track.stop());if(overlay)overlay.remove();help.textContent="Unable to open the camera. Check browser permissions or enter the identifiers manually."}}
async function change(path,method,body,message){ try{ await api(path,{method,body:body?JSON.stringify(body):undefined}); closeModal(); await load(); toast(message); }catch(e){ toast(e.message); } } async function change(path,method,body,message){ try{ await api(path,{method,body:body?JSON.stringify(body):undefined}); closeModal(); await load(); toast(message); }catch(e){ toast(e.message); } }
function viewSale(p) { function viewSale(p) {
const notes=state.user.role==="admin"?`<form id="transactionNotesForm"><label>Transaction notes<textarea name="saleNotes" rows="4" placeholder="Delivery, pickup, exchange, or other sale details">${esc(p.saleNotes||"")}</textarea></label><div class="form-actions"><button type="button" class="secondary" data-cancel>Close</button><button class="primary">Save notes</button></div></form>`:`<section class="detail-card"><h3>Transaction notes</h3><p>${esc(p.saleNotes||"No transaction notes recorded")}</p></section><div class="form-actions"><button type="button" class="secondary" data-cancel>Close</button></div>`; const link=trackingUrl(p),fulfillment=p.fulfillmentMethod?`<section class="detail-card"><h3>${fulfillmentTitle(p.fulfillmentMethod)}</h3>${p.fulfillmentName?`<p><strong>${esc(p.fulfillmentName)}</strong></p>`:""}<p>${address(p)?esc(address(p)):"No street address recorded"}</p>${p.fulfillmentNotes?`<p>${esc(p.fulfillmentNotes)}</p>`:""}${p.fulfillmentMethod==="Shipped"?`<p>Carrier: <strong>${esc(p.carrier||"Not recorded")}</strong></p><p>Tracking: ${p.trackingNumber?(link?`<a href="${link}" target="_blank" rel="noopener">${esc(p.trackingNumber)}</a>`:esc(p.trackingNumber)):"Not entered"}</p><p>Delivery status: <strong>${esc(p.deliveryStatus||"Not recorded")}</strong></p>`:""}</section>`:'<section class="detail-card"><h3>Fulfillment</h3><p>Not recorded</p></section>';
openModal(`<h2>Sale record</h2><p>${esc(p.model)} · ${fmtDate(p.soldAt)}</p><section class="detail-card"><h3>Customer</h3><p><strong>${esc(p.customerName||"Unknown")}</strong></p><p>${esc(p.phone||"No phone recorded")}</p></section><section class="detail-card"><h3>Payment</h3><p><strong>${esc(p.paymentMethod||"Not recorded")}</strong></p>${p.paymentReference?`<p>Reference: ${esc(p.paymentReference)}</p>`:""}</section><section class="detail-card"><h3>Shipped to</h3><p>${address(p)?esc(address(p)):"No shipping address recorded"}</p>${p.shippingNotes?`<p><small>${esc(p.shippingNotes)}</small></p>`:""}</section><section class="detail-card"><h3>Product and sale</h3><p>${esc(p.manufacturer)} ${esc(p.model)} · ${esc(p.condition)}</p><div class="ids">${idCell(p)}</div><p>Received: ${fmtDate(p.receivedAt)} · Sold: ${fmtDate(p.soldAt)}</p><p>Cost: ${p.cost?money.format(p.cost):"—"} · Sale price: ${p.salePrice?money.format(p.salePrice):"—"}</p>${p.notes?`<p>Inventory notes: ${esc(p.notes)}</p>`:""}</section>${notes}`); $("[data-cancel]").onclick=closeModal; if($("#transactionNotesForm"))$("#transactionNotesForm").onsubmit=e=>submitForm(e,`/api/products/${encodeURIComponent(p.id)}`,"Transaction notes saved.","PATCH"); openModal(`<h2>Sale record</h2><p>${esc(p.model)} · ${fmtDate(p.soldAt)}</p><section class="detail-card"><h3>Customer</h3><p><strong>${esc(p.customerName||"Unknown")}</strong></p><p>${esc(p.phone||"No phone recorded")}</p></section><section class="detail-card"><h3>Payment and sale</h3><p><strong>${esc(p.paymentMethod||"Not recorded")}</strong>${p.paymentReference?` · Reference: ${esc(p.paymentReference)}`:""}</p><p>Sale price: ${p.salePrice?money.format(p.salePrice):""} · Sold: ${fmtDate(p.soldAt)}</p></section>${fulfillment}<section class="detail-card"><h3>Warranty</h3><p>${warrantyBadge(p)}</p><p>${p.warrantyName?`${esc(p.warrantyName)}${p.warrantyEndDate?` · Through ${fmtDate(p.warrantyEndDate)}`:""}`:"No warranty was recorded for this sale."}</p></section><section class="detail-card"><h3>Product</h3><p>${esc(p.manufacturer)} ${esc(p.model)} · ${esc(p.condition)}</p><div class="ids">${idCell(p)}</div><p>Received: ${fmtDate(p.receivedAt)} · Cost: ${p.cost?money.format(p.cost):"—"}</p>${p.notes?`<p>Inventory notes: ${esc(p.notes)}</p>`:""}</section><section class="detail-card"><h3>Transaction notes</h3><p>${esc(p.saleNotes||"No transaction notes recorded")}</p></section><div class="form-actions"><button type="button" class="secondary" data-cancel>Close</button>${state.user.role==="admin"?'<button class="primary" id="editSaleDetails">Edit sale details</button>':""}</div>`); $("[data-cancel]").onclick=closeModal;if($("#editSaleDetails"))$("#editSaleDetails").onclick=()=>editSaleForm(p);
} }
function editSaleForm(p){const historicalWarranty=p.warrantyPresetId&&!state.warranties.some(w=>w.id===p.warrantyPresetId)?`<option value="${p.warrantyPresetId}" selected>${esc(p.warrantyName)} (Archived)</option>`:"";openModal(`<h2>Edit sale details</h2><p>Correct customer, payment, fulfillment, tracking, warranty, or notes.</p><form id="editSaleForm">${customerFields(p)}<div class="form-row"><label>Payment method<select name="paymentMethod" required>${["Cash","Venmo","PayPal"].map(x=>`<option ${p.paymentMethod===x?"selected":""}>${x}</option>`).join("")}</select></label><label>Payment reference<input name="paymentReference" value="${esc(p.paymentReference||"")}"></label></div><div class="form-row"><label>Sale price<input name="salePrice" type="number" min="0" step=".01" value="${Number(p.salePrice||0)}"></label><label>Date sold<input name="soldAt" type="date" value="${esc(p.soldAt||today())}" required></label></div><div class="form-row"><label>Delivery method<select name="fulfillmentMethod" required><option value="">Choose a method</option>${["Shipped","Dropped Off","Installed At","Meet"].map(x=>`<option ${p.fulfillmentMethod===x?"selected":""}>${x}</option>`).join("")}</select></label><label>Warranty<select name="warrantyPresetId" required>${historicalWarranty}${warrantyOptions(p.warrantyPresetId)}</select></label></div><div class="fulfillment-fields"></div><label>Transaction notes<textarea name="saleNotes" rows="3">${esc(p.saleNotes||"")}</textarea></label><div class="form-actions"><button type="button" class="secondary" data-cancel>Cancel</button><button class="primary">Save changes</button></div></form>`);bindFulfillment($("#editSaleForm"),p);bindCustomer($("#editSaleForm"));$("[data-cancel]").onclick=()=>viewSale(p);$("#editSaleForm").onsubmit=async e=>{e.preventDefault();const button=e.submitter;button.disabled=true;try{const updated=await api(`/api/products/${encodeURIComponent(p.id)}`,{method:"PATCH",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});await load();toast("Sale details updated.");viewSale(updated);}catch(error){toast(error.message);}finally{button.disabled=false}};}
async function viewCustomer(p) { async function viewCustomer(p) {
if(!p.customerId) return viewSale(p); if(!p.customerId) return viewSale(p);
try { const c=await api(`/api/customers/${encodeURIComponent(p.customerId)}`), addr=[c.address1,c.address2,[c.city,c.state].filter(Boolean).join(", "),c.zip].filter(Boolean).join(" · "); try { const c=await api(`/api/customers/${encodeURIComponent(p.customerId)}`), addr=[c.address1,c.address2,[c.city,c.state].filter(Boolean).join(", "),c.zip].filter(Boolean).join(" · ");
const noteForm=state.user.role==="admin"?`<form id="customerNoteForm"><div class="form-row"><label>Category<select name="category"><option>General</option><option>Support</option><option>Follow-up</option></select></label><label>New note<textarea name="note" rows="3" required placeholder="Issue, support contact, or follow-up"></textarea></label></div><div class="form-actions"><button class="primary">Add note</button></div></form>`:""; const noteForm=state.user.role==="admin"?`<form id="customerNoteForm"><div class="form-row"><label>Category<select name="category"><option>General</option><option>Support</option><option>Follow-up</option></select></label><label>New note<textarea name="note" rows="3" required placeholder="Issue, support contact, or follow-up"></textarea></label></div><div class="form-actions"><button class="primary">Add note</button></div></form>`:"";
openModal(`<h2>${esc(c.name)}</h2><p>Customer record, support notes, and purchase history.</p><section class="detail-card"><p>${esc(c.phone||"No phone recorded")}</p><p>${addr?esc(addr):"No shipping address recorded"}</p>${c.shippingNotes?`<p><small>${esc(c.shippingNotes)}</small></p>`:""}</section><section class="customer-notes"><h3>Customer notes</h3>${noteForm}${c.notes.length?c.notes.map(n=>`<article><div><span class="note-category">${esc(n.category)}</span><small>${fmtDateTime(n.createdAt)}</small></div><p>${esc(n.note)}</p>${state.user.role==="admin"?`<button class="danger" data-delete-note="${n.id}">Delete</button>`:""}</article>`).join(""):"<p class=\"muted\">No customer notes yet.</p>"}</section><section class="customer-purchases"><h3>Purchases (${c.purchases.length})</h3>${c.purchases.map(s=>`<article><strong>${esc(s.model)}</strong> · ${esc(primaryId(s))}<p>${fmtDate(s.soldAt)} · ${s.salePrice?money.format(s.salePrice):"Price not recorded"}</p><button data-customer-sale="${s.id}">View sale</button></article>`).join("")}</section><div class="form-actions"><button class="primary" data-cancel>Close</button></div>`); openModal(`<h2>${esc(c.name)}</h2><p>Customer record, support notes, and purchase history.</p><section class="detail-card"><p>${esc(c.phone||"No phone recorded")}</p><p>${addr?esc(addr):"No permanent address recorded"}</p>${c.shippingNotes?`<p><small>${esc(c.shippingNotes)}</small></p>`:""}</section><section class="customer-notes"><h3>Customer notes</h3>${noteForm}${c.notes.length?c.notes.map(n=>`<article><div><span class="note-category">${esc(n.category)}</span><small>${fmtDateTime(n.createdAt)}</small></div><p>${esc(n.note)}</p>${state.user.role==="admin"?`<button class="danger" data-delete-note="${n.id}">Delete</button>`:""}</article>`).join(""):"<p class=\"muted\">No customer notes yet.</p>"}</section><section class="customer-purchases"><h3>Purchases (${c.purchases.length})</h3>${c.purchases.map(s=>`<article><strong>${esc(s.model)}</strong> · ${esc(primaryId(s))}<p>${fmtDate(s.soldAt)} · ${s.salePrice?money.format(s.salePrice):"Price not recorded"}</p><p>${warrantyBadge(s)}</p><button data-customer-sale="${s.id}">View sale</button></article>`).join("")}</section><div class="form-actions"><button class="primary" data-cancel>Close</button></div>`);
$("[data-cancel]").onclick=closeModal; document.querySelectorAll("[data-customer-sale]").forEach(b=>b.onclick=()=>viewSale(c.purchases.find(s=>s.id===b.dataset.customerSale))); $("[data-cancel]").onclick=closeModal; document.querySelectorAll("[data-customer-sale]").forEach(b=>b.onclick=()=>viewSale(c.purchases.find(s=>s.id===b.dataset.customerSale)));
if($("#customerNoteForm"))$("#customerNoteForm").onsubmit=async e=>{e.preventDefault();try{await api(`/api/customers/${encodeURIComponent(c.id)}/notes`,{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});await viewCustomer(p);toast("Customer note added.");}catch(error){toast(error.message);}}; if($("#customerNoteForm"))$("#customerNoteForm").onsubmit=async e=>{e.preventDefault();try{await api(`/api/customers/${encodeURIComponent(c.id)}/notes`,{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});await viewCustomer(p);toast("Customer note added.");}catch(error){toast(error.message);}};
document.querySelectorAll("[data-delete-note]").forEach(b=>b.onclick=async()=>{if(!confirm("Delete this customer note?"))return;try{await api(`/api/customers/${encodeURIComponent(c.id)}/notes/${encodeURIComponent(b.dataset.deleteNote)}`,{method:"DELETE"});await viewCustomer(p);toast("Customer note deleted.");}catch(error){toast(error.message);}}); document.querySelectorAll("[data-delete-note]").forEach(b=>b.onclick=async()=>{if(!confirm("Delete this customer note?"))return;try{await api(`/api/customers/${encodeURIComponent(c.id)}/notes/${encodeURIComponent(b.dataset.deleteNote)}`,{method:"DELETE"});await viewCustomer(p);toast("Customer note deleted.");}catch(error){toast(error.message);}});
@@ -93,17 +115,26 @@ async function viewCustomer(p) {
} }
function restockForm(p){ openModal(`<h2>Void sale & restock</h2><p>Return ${esc(p.model)} to inventory.</p><form id="restockForm"><label>Condition<select name="condition"><option>Used</option><option>Refurbished</option><option>New</option></select></label><label>Return date<input name="receivedAt" type="date" value="${today()}" required></label><div class="form-actions"><button type="button" class="secondary" data-cancel>Cancel</button><button class="primary">Restock product</button></div></form>`); $("[data-cancel]").onclick=closeModal; $("#restockForm").onsubmit=e=>submitForm(e,`/api/products/${encodeURIComponent(p.id)}/restock`,"Product restocked."); } function restockForm(p){ openModal(`<h2>Void sale & restock</h2><p>Return ${esc(p.model)} to inventory.</p><form id="restockForm"><label>Condition<select name="condition"><option>Used</option><option>Refurbished</option><option>New</option></select></label><label>Return date<input name="receivedAt" type="date" value="${today()}" required></label><div class="form-actions"><button type="button" class="secondary" data-cancel>Cancel</button><button class="primary">Restock product</button></div></form>`); $("[data-cancel]").onclick=closeModal; $("#restockForm").onsubmit=e=>submitForm(e,`/api/products/${encodeURIComponent(p.id)}/restock`,"Product restocked."); }
async function load(){ [state.products,state.models]=await Promise.all([api("/api/products"),api("/api/models")]); render(); } async function load(){ [state.products,state.models,state.warranties,state.customerDirectory]=await Promise.all([api("/api/products"),api("/api/models"),api("/api/warranties"),api("/api/customers")]); render(); }
async function renderAdmin(){ async function renderAdmin(){
const panel=$("#adminPanel"); panel.innerHTML='<div class="admin-loading">Loading administration…</div>'; const panel=$("#adminPanel"); panel.innerHTML='<div class="admin-loading">Loading administration…</div>';
try{const [backups,users,audit]=await Promise.all([api("/api/admin/backups"),api("/api/admin/users"),api("/api/admin/audit")]);panel.innerHTML=`<div class="admin-page"><section class="admin-section"><div><h2>User accounts</h2><p>Create administrators or read-only accounts. New and reset passwords must be changed at next login.</p></div><form id="createUserForm" class="inline-user-form"><label>Username<input name="username" required minlength="3" autocomplete="off"></label><label>Temporary password<input name="password" type="password" required minlength="8" autocomplete="new-password"></label><label>Role<select name="role"><option value="readonly">Read-Only</option><option value="admin">Admin</option></select></label><button class="primary">Create user</button></form><div class="user-list">${users.map(u=>`<article><div><strong>${esc(u.username)}</strong><small>${u.role==="admin"?"Admin":"Read-Only"} · ${u.enabled?"Enabled":"Disabled"}${u.mustChangePassword?" · Password change required":""}${u.lastLoginAt?` · Last login ${fmtDateTime(u.lastLoginAt)}`:""}</small></div><div><button class="secondary" data-role-user="${u.id}" data-role="${u.role}">${u.role==="admin"?"Make Read-Only":"Make Admin"}</button><button class="secondary" data-reset-user="${u.id}" data-name="${esc(u.username)}">Reset password</button><button class="secondary" data-toggle-user="${u.id}" data-enabled="${u.enabled}">${u.enabled?"Disable":"Enable"}</button><button class="delete-backup" data-delete-user="${u.id}" data-name="${esc(u.username)}">Delete</button></div></article>`).join("")}</div></section><section class="admin-section"><div><h2>Database backups</h2><p>Create snapshots inside <code>/data/backups</code>, download an off-server copy, or restore a previous database.</p></div><div class="admin-actions"><button class="primary" id="createBackup">Create backup now</button><label class="upload-backup">Restore uploaded backup<input id="restoreUpload" type="file" accept=".db,application/vnd.sqlite3"></label></div><div class="backup-warning"><strong>Restore replaces the active database and all user accounts.</strong> Your current password is required. Everyone will be signed out afterward.</div><div class="backup-list">${backups.length?backups.map(b=>`<article><div><strong>${esc(b.name)}</strong><small>${fmtDateTime(b.createdAt)} · ${(b.size/1024).toFixed(1)} KB</small></div><div><a class="button secondary" href="/api/admin/backups/${encodeURIComponent(b.name)}/download">Download</a><button class="secondary" data-restore-backup="${esc(b.name)}">Restore</button><button class="delete-backup" data-delete-backup="${esc(b.name)}">Delete</button></div></article>`).join(""):"<p>No local backups yet.</p>"}</div></section><section class="admin-section"><div><h2>Audit log</h2><p>The latest 250 security and data-changing events. Audit entries cannot be edited or deleted.</p></div><div class="audit-list">${audit.map(a=>`<article><strong>${esc(a.username)}</strong><span>${esc(a.action.replaceAll("_"," "))}</span><small>${fmtDateTime(a.createdAt)}${a.target?` · ${esc(a.target)}`:""}${a.details?` · ${esc(a.details)}`:""}${a.ipAddress?` · ${esc(a.ipAddress)}`:""}</small></article>`).join("")||"<p>No audit events yet.</p>"}</div></section></div>`; try{const [backups,users,audit,diagnostics]=await Promise.all([api("/api/admin/backups"),api("/api/admin/users"),api("/api/admin/audit"),api("/api/admin/diagnostics")]);panel.innerHTML=`<div class="admin-page"><section class="admin-section"><div><h2>User accounts</h2><p>Create administrators or read-only accounts. New and reset passwords must be changed at next login.</p></div><form id="createUserForm" class="inline-user-form"><label>Username<input name="username" required minlength="3" autocomplete="off"></label><label>Temporary password<input name="password" type="password" required minlength="8" autocomplete="new-password"></label><label>Role<select name="role"><option value="readonly">Read-Only</option><option value="admin">Admin</option></select></label><button class="primary">Create user</button></form><div class="user-list">${users.map(u=>`<article><div><strong>${esc(u.username)}</strong><small>${u.role==="admin"?"Admin":"Read-Only"} · ${u.enabled?"Enabled":"Disabled"}${u.mustChangePassword?" · Password change required":""}${u.lastLoginAt?` · Last login ${fmtDateTime(u.lastLoginAt)}`:""}</small></div><div><button class="secondary" data-role-user="${u.id}" data-role="${u.role}">${u.role==="admin"?"Make Read-Only":"Make Admin"}</button><button class="secondary" data-reset-user="${u.id}" data-name="${esc(u.username)}">Reset password</button><button class="secondary" data-toggle-user="${u.id}" data-enabled="${u.enabled}">${u.enabled?"Disable":"Enable"}</button><button class="delete-backup" data-delete-user="${u.id}" data-name="${esc(u.username)}">Delete</button></div></article>`).join("")}</div></section><section class="admin-section"><div><h2>Database backups</h2><p>Create snapshots inside <code>/data/backups</code>, download an off-server copy, or restore a previous database.</p></div><div class="admin-actions"><button class="primary" id="createBackup">Create backup now</button><label class="upload-backup">Restore uploaded backup<input id="restoreUpload" type="file" accept=".db,application/vnd.sqlite3"></label></div><div class="backup-warning"><strong>Restore replaces the active database and all user accounts.</strong> Your current password is required. Everyone will be signed out afterward.</div><div class="backup-list">${backups.length?backups.map(b=>`<article><div><strong>${esc(b.name)}</strong><small>${fmtDateTime(b.createdAt)} · ${(b.size/1024).toFixed(1)} KB</small></div><div><a class="button secondary" href="/api/admin/backups/${encodeURIComponent(b.name)}/download">Download</a><button class="secondary" data-restore-backup="${esc(b.name)}">Restore</button><button class="delete-backup" data-delete-backup="${esc(b.name)}">Delete</button></div></article>`).join(""):"<p>No local backups yet.</p>"}</div></section><section class="admin-section"><div><h2>Audit log</h2><p>The latest 250 security and data-changing events. Audit entries cannot be edited or deleted.</p></div><div class="audit-list">${audit.map(a=>`<article><strong>${esc(a.username)}</strong><span>${esc(a.action.replaceAll("_"," "))}</span><small>${fmtDateTime(a.createdAt)}${a.target?` · ${esc(a.target)}`:""}${a.details?` · ${esc(a.details)}`:""}${a.ipAddress?` · ${esc(a.ipAddress)}`:""}</small></article>`).join("")||"<p>No audit events yet.</p>"}</div></section></div>`;
panel.querySelector(".admin-page").insertAdjacentHTML("afterbegin",'<div class="admin-intro"><img src="/assets/vboxstock-icon-512.png" alt=""><div><span class="admin-kicker">vBoxStock</span><h2>Administration</h2><p>Manage product models, access, data protection, and account activity.</p></div></div>'); panel.querySelector(".admin-page").insertAdjacentHTML("afterbegin",'<div class="admin-intro"><img src="/assets/vboxstock-icon-512.png" alt=""><div><span class="admin-kicker">vBoxStock</span><h2>Administration</h2><p>Manage product models, access, data protection, and account activity.</p></div></div>');
const bytes=n=>n>1073741824?`${(n/1073741824).toFixed(1)} GB`:n>1048576?`${(n/1048576).toFixed(1)} MB`:`${(n/1024).toFixed(1)} KB`,systemSection=document.createElement("section");systemSection.className="admin-section system-section";systemSection.innerHTML=`<div><h2>System diagnostics</h2><p>vBoxStock ${esc(diagnostics.appVersion)} · Database schema ${diagnostics.schemaVersion} · ${esc(diagnostics.nodeVersion)}</p></div><div class="diagnostic-grid"><article><span>Database</span><strong>${bytes(diagnostics.databaseSize)}</strong><small>${diagnostics.dataWritable?"/data is writable":"/data is not writable"}</small></article><article><span>Storage available</span><strong>${bytes(diagnostics.diskFree)}</strong><small>of ${bytes(diagnostics.diskTotal)}</small></article><article><span>Backups</span><strong>${diagnostics.backupCount}</strong><small>${diagnostics.lastBackupAt?`Latest ${fmtDateTime(diagnostics.lastBackupAt)}`:"None created"}</small></article><article><span>Time zone</span><strong>${esc(diagnostics.timeZone)}</strong><small>${esc(diagnostics.dataDirectory)}</small></article></div><div class="export-links"><span>Download data:</span>${["inventory","sales","customers","warranties","audit"].map(x=>`<a class="button secondary" href="/api/export/${x}">${x[0].toUpperCase()+x.slice(1)} CSV</a>`).join("")}</div>`;panel.querySelector(".admin-intro").insertAdjacentElement("afterend",systemSection);
const backupSection=[...panel.querySelectorAll(".admin-section")].find(section=>section.querySelector("h2")?.textContent==="Database backups"),schedule=document.createElement("div"),weekdays=["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"];schedule.className="automatic-backups";schedule.innerHTML=`<div><h3>Automatic backups</h3><p>Run a retained local backup every day or once per week. Manual, pre-upgrade, and pre-restore backups are never removed automatically.</p></div><form id="backupScheduleForm" class="backup-schedule"><label class="check-label"><input name="enabled" type="checkbox" ${diagnostics.backupSettings.enabled?"checked":""}> Enable automatic backups</label><label>Frequency<select name="frequency"><option value="daily" ${diagnostics.backupSettings.frequency==="daily"?"selected":""}>Every day</option><option value="weekly" ${diagnostics.backupSettings.frequency==="weekly"?"selected":""}>Weekly</option></select></label><label class="weekly-field">Day of week<select name="weekday">${weekdays.map((day,index)=>`<option value="${index}" ${diagnostics.backupSettings.weekday===index?"selected":""}>${day}</option>`).join("")}</select></label><label>Backup time<select name="hour">${Array.from({length:24},(_,hour)=>`<option value="${hour}" ${diagnostics.backupSettings.hour===hour?"selected":""}>${String(hour).padStart(2,"0")}:00</option>`).join("")}</select></label><label>Automatic backups to retain<input name="retention" type="number" min="1" max="365" value="${diagnostics.backupSettings.retention}" required></label><button class="primary">Save schedule</button></form><div class="backup-schedule-status"><span><strong>Last automatic backup:</strong> ${diagnostics.backupSettings.lastScheduledBackup?fmtDate(diagnostics.backupSettings.lastScheduledBackup):"Not yet run"}</span><span><strong>Next automatic backup:</strong> ${diagnostics.backupSettings.nextScheduledBackup?fmtDateTime(diagnostics.backupSettings.nextScheduledBackup):"Disabled"}</span></div>`;backupSection.querySelector(".backup-warning").insertAdjacentElement("beforebegin",schedule);const frequency=$("#backupScheduleForm [name=frequency]"),weekly=$("#backupScheduleForm .weekly-field"),updateFrequency=()=>weekly.hidden=frequency.value!=="weekly";frequency.onchange=updateFrequency;updateFrequency();$("#backupScheduleForm").onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.currentTarget));try{await api("/api/admin/backup-settings",{method:"PATCH",body:JSON.stringify({enabled:Boolean(data.enabled),frequency:data.frequency,weekday:Number(data.weekday),hour:Number(data.hour),retention:Number(data.retention)})});toast("Automatic backup schedule saved.");renderAdmin()}catch(error){toast(error.message)}};
const models=await api("/api/admin/models"),modelSection=document.createElement("section");modelSection.className="admin-section model-section";modelSection.innerHTML=`<div><h2>Product models</h2><p>Active models are available when receiving products. Archiving removes a model from new receiving while preserving inventory and sales history.</p></div><form id="createModelForm" class="inline-model-form"><label>Model name<input name="name" required maxlength="60" placeholder="Example: V7 Ultra" autocomplete="off"></label><button class="primary">Add model</button></form><div class="model-list">${models.map(m=>`<article><div class="model-summary"><div><strong>${esc(m.name)}</strong><span class="model-status ${m.active?"active":"archived"}">${m.active?"Active":"Archived"}</span></div><small>${m.availableCount} available · ${m.soldCount} sold</small></div><div class="model-actions">${m.totalCount===0?`<button class="secondary" data-rename-model="${m.id}" data-name="${esc(m.name)}">Rename</button>`:""}<button class="secondary" data-toggle-model="${m.id}" data-active="${m.active}" data-name="${esc(m.name)}" data-available="${m.availableCount}" data-sold="${m.soldCount}">${m.active?"Archive":"Reactivate"}</button>${m.totalCount===0?`<button class="delete-backup" data-delete-model="${m.id}" data-name="${esc(m.name)}">Delete</button>`:""}</div></article>`).join("")||"<p>No models configured.</p>"}</div>`;panel.querySelector(".admin-intro").insertAdjacentElement("afterend",modelSection); const models=await api("/api/admin/models"),modelSection=document.createElement("section");modelSection.className="admin-section model-section";modelSection.innerHTML=`<div><h2>Product models</h2><p>Active models are available when receiving products. Archiving removes a model from new receiving while preserving inventory and sales history.</p></div><form id="createModelForm" class="inline-model-form"><label>Model name<input name="name" required maxlength="60" placeholder="Example: V7 Ultra" autocomplete="off"></label><button class="primary">Add model</button></form><div class="model-list">${models.map(m=>`<article><div class="model-summary"><div><strong>${esc(m.name)}</strong><span class="model-status ${m.active?"active":"archived"}">${m.active?"Active":"Archived"}</span></div><small>${m.availableCount} available · ${m.soldCount} sold</small></div><div class="model-actions">${m.totalCount===0?`<button class="secondary" data-rename-model="${m.id}" data-name="${esc(m.name)}">Rename</button>`:""}<button class="secondary" data-toggle-model="${m.id}" data-active="${m.active}" data-name="${esc(m.name)}" data-available="${m.availableCount}" data-sold="${m.soldCount}">${m.active?"Archive":"Reactivate"}</button>${m.totalCount===0?`<button class="delete-backup" data-delete-model="${m.id}" data-name="${esc(m.name)}">Delete</button>`:""}</div></article>`).join("")||"<p>No models configured.</p>"}</div>`;panel.querySelector(".admin-intro").insertAdjacentElement("afterend",modelSection);
const finishModelChange=async message=>{state.models=await api("/api/models");toast(message);renderAdmin();}; const finishModelChange=async message=>{state.models=await api("/api/models");toast(message);renderAdmin();};
$("#createModelForm").onsubmit=async e=>{e.preventDefault();try{await api("/api/admin/models",{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});await finishModelChange("Model added.");}catch(error){toast(error.message);}}; $("#createModelForm").onsubmit=async e=>{e.preventDefault();try{await api("/api/admin/models",{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});await finishModelChange("Model added.");}catch(error){toast(error.message);}};
document.querySelectorAll("[data-rename-model]").forEach(b=>b.onclick=async()=>{const name=prompt(`Rename ${b.dataset.name}:`,b.dataset.name);if(name===null||name.trim()===b.dataset.name)return;try{await api(`/api/admin/models/${encodeURIComponent(b.dataset.renameModel)}`,{method:"PATCH",body:JSON.stringify({name})});await finishModelChange("Model renamed.");}catch(error){toast(error.message);}}); document.querySelectorAll("[data-rename-model]").forEach(b=>b.onclick=async()=>{const name=prompt(`Rename ${b.dataset.name}:`,b.dataset.name);if(name===null||name.trim()===b.dataset.name)return;try{await api(`/api/admin/models/${encodeURIComponent(b.dataset.renameModel)}`,{method:"PATCH",body:JSON.stringify({name})});await finishModelChange("Model renamed.");}catch(error){toast(error.message);}});
document.querySelectorAll("[data-toggle-model]").forEach(b=>b.onclick=async()=>{const active=b.dataset.active==="true";if(active&&!confirm(`Archive ${b.dataset.name}? It will no longer appear for newly received products. Existing records remain available (${b.dataset.available} available, ${b.dataset.sold} sold).`))return;try{await api(`/api/admin/models/${encodeURIComponent(b.dataset.toggleModel)}`,{method:"PATCH",body:JSON.stringify({active:!active})});await finishModelChange(active?"Model archived.":"Model reactivated.");}catch(error){toast(error.message);}}); document.querySelectorAll("[data-toggle-model]").forEach(b=>b.onclick=async()=>{const active=b.dataset.active==="true";if(active&&!confirm(`Archive ${b.dataset.name}? It will no longer appear for newly received products. Existing records remain available (${b.dataset.available} available, ${b.dataset.sold} sold).`))return;try{await api(`/api/admin/models/${encodeURIComponent(b.dataset.toggleModel)}`,{method:"PATCH",body:JSON.stringify({active:!active})});await finishModelChange(active?"Model archived.":"Model reactivated.");}catch(error){toast(error.message);}});
document.querySelectorAll("[data-delete-model]").forEach(b=>b.onclick=async()=>{if(!confirm(`Permanently delete unused model ${b.dataset.name}?`))return;try{await api(`/api/admin/models/${encodeURIComponent(b.dataset.deleteModel)}`,{method:"DELETE"});await finishModelChange("Unused model deleted.");}catch(error){toast(error.message);}}); document.querySelectorAll("[data-delete-model]").forEach(b=>b.onclick=async()=>{if(!confirm(`Permanently delete unused model ${b.dataset.name}?`))return;try{await api(`/api/admin/models/${encodeURIComponent(b.dataset.deleteModel)}`,{method:"DELETE"});await finishModelChange("Unused model deleted.");}catch(error){toast(error.message);}});
const warranties=await api("/api/admin/warranties"),warrantySection=document.createElement("section");warrantySection.className="admin-section warranty-section";warrantySection.innerHTML=`<div><h2>Warranty periods</h2><p>Choose the default offered on new sales. Used periods are preserved as sale snapshots and can be archived, but not changed or deleted.</p></div><form id="createWarrantyForm" class="inline-warranty-form"><label>Name<input name="name" required maxlength="60" placeholder="Example: 6 Months"></label><label>Duration<input name="durationValue" type="number" min="0" max="3650" required></label><label>Unit<select name="durationUnit"><option value="days">Days</option><option value="months">Months</option><option value="years">Years</option></select></label><button class="primary">Add warranty</button></form><div class="warranty-list">${warranties.map(w=>`<article><div class="model-summary"><div><strong>${esc(w.name)}</strong>${w.isDefault?'<span class="model-status active">Default</span>':`<span class="model-status ${w.active?"active":"archived"}">${w.active?"Active":"Archived"}</span>`}</div><small>${w.durationValue} ${esc(w.durationUnit)} · ${w.usageCount} sale${w.usageCount===1?"":"s"}</small></div><div class="model-actions">${!w.isDefault&&w.active?`<button class="secondary" data-default-warranty="${w.id}" data-name="${esc(w.name)}">Make default</button>`:""}${w.usageCount===0?`<button class="secondary" data-edit-warranty="${w.id}" data-name="${esc(w.name)}" data-value="${w.durationValue}" data-unit="${w.durationUnit}">Edit</button>`:""}${!w.isDefault?`<button class="secondary" data-toggle-warranty="${w.id}" data-active="${w.active}" data-name="${esc(w.name)}">${w.active?"Archive":"Reactivate"}</button>`:""}${w.usageCount===0&&!w.isDefault?`<button class="delete-backup" data-delete-warranty="${w.id}" data-name="${esc(w.name)}">Delete</button>`:""}</div></article>`).join("")}</div>`;modelSection.insertAdjacentElement("afterend",warrantySection);
const finishWarrantyChange=async message=>{state.warranties=await api("/api/warranties");toast(message);renderAdmin();};
$("#createWarrantyForm").onsubmit=async e=>{e.preventDefault();try{await api("/api/admin/warranties",{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});await finishWarrantyChange("Warranty period added.");}catch(error){toast(error.message);}};
document.querySelectorAll("[data-default-warranty]").forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/warranties/${encodeURIComponent(b.dataset.defaultWarranty)}`,{method:"PATCH",body:JSON.stringify({isDefault:true})});await finishWarrantyChange(`${b.dataset.name} is now the default warranty.`);}catch(error){toast(error.message);}});
document.querySelectorAll("[data-edit-warranty]").forEach(b=>b.onclick=async()=>{const name=prompt("Warranty name:",b.dataset.name);if(name===null)return;const value=prompt("Duration value:",b.dataset.value);if(value===null)return;const unit=prompt("Duration unit (days, months, or years):",b.dataset.unit);if(unit===null)return;try{await api(`/api/admin/warranties/${encodeURIComponent(b.dataset.editWarranty)}`,{method:"PATCH",body:JSON.stringify({name,durationValue:Number(value),durationUnit:unit.toLowerCase()})});await finishWarrantyChange("Warranty period updated.");}catch(error){toast(error.message);}});
document.querySelectorAll("[data-toggle-warranty]").forEach(b=>b.onclick=async()=>{const active=b.dataset.active==="true";if(active&&!confirm(`Archive ${b.dataset.name}? Existing sales will retain it.`))return;try{await api(`/api/admin/warranties/${encodeURIComponent(b.dataset.toggleWarranty)}`,{method:"PATCH",body:JSON.stringify({active:!active})});await finishWarrantyChange(active?"Warranty period archived.":"Warranty period reactivated.");}catch(error){toast(error.message);}});
document.querySelectorAll("[data-delete-warranty]").forEach(b=>b.onclick=async()=>{if(!confirm(`Permanently delete unused warranty ${b.dataset.name}?`))return;try{await api(`/api/admin/warranties/${encodeURIComponent(b.dataset.deleteWarranty)}`,{method:"DELETE"});await finishWarrantyChange("Unused warranty period deleted.");}catch(error){toast(error.message);}});
$("#createUserForm").onsubmit=async e=>{e.preventDefault();try{await api("/api/admin/users",{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});toast("User created.");renderAdmin();}catch(error){toast(error.message);}}; $("#createUserForm").onsubmit=async e=>{e.preventDefault();try{await api("/api/admin/users",{method:"POST",body:JSON.stringify(Object.fromEntries(new FormData(e.currentTarget)))});toast("User created.");renderAdmin();}catch(error){toast(error.message);}};
document.querySelectorAll("[data-role-user]").forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/users/${b.dataset.roleUser}`,{method:"PATCH",body:JSON.stringify({role:b.dataset.role==="admin"?"readonly":"admin"})});toast("User role updated.");renderAdmin();}catch(e){toast(e.message);}}); document.querySelectorAll("[data-role-user]").forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/users/${b.dataset.roleUser}`,{method:"PATCH",body:JSON.stringify({role:b.dataset.role==="admin"?"readonly":"admin"})});toast("User role updated.");renderAdmin();}catch(e){toast(e.message);}});
document.querySelectorAll("[data-toggle-user]").forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/users/${b.dataset.toggleUser}`,{method:"PATCH",body:JSON.stringify({enabled:b.dataset.enabled!=="true"})});toast("User status updated.");renderAdmin();}catch(e){toast(e.message);}}); document.querySelectorAll("[data-toggle-user]").forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/users/${b.dataset.toggleUser}`,{method:"PATCH",body:JSON.stringify({enabled:b.dataset.enabled!=="true"})});toast("User status updated.");renderAdmin();}catch(e){toast(e.message);}});
@@ -137,5 +168,24 @@ $("#logout").onclick=logout;
$("#themeSelect").onchange=e=>{try{localStorage.setItem("vboxstock-theme",e.target.value)}catch{}applyTheme(e.target.value);}; $("#themeSelect").onchange=e=>{try{localStorage.setItem("vboxstock-theme",e.target.value)}catch{}applyTheme(e.target.value);};
systemTheme.addEventListener?.("change",()=>{if(savedTheme()==="system")applyTheme("system")}); systemTheme.addEventListener?.("change",()=>{if(savedTheme()==="system")applyTheme("system")});
$("#date").textContent=new Date().toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"}); $("#date").textContent=new Date().toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"});
async function scanLabelWithLibrary(form,help){if(!navigator.mediaDevices?.getUserMedia){help.textContent="Camera access is unavailable. Use manual entry or enable camera access for this site.";return}let stream,overlay,controls;try{const {BrowserMultiFormatReader}=await import("https://cdn.jsdelivr.net/npm/@zxing/browser@0.1.5/+esm"),reader=new BrowserMultiFormatReader();stream=await navigator.mediaDevices.getUserMedia({video:{facingMode:{ideal:"environment"}}});const video=document.createElement("video");video.autoplay=true;video.playsInline=true;video.srcObject=stream;overlay=document.createElement("div");overlay.className="scanner-overlay";overlay.innerHTML='<div><strong>Point at one barcode at a time</strong><small>Scan UID, serial number, and MAC on the same label.</small><button type="button">Close camera</button></div>';overlay.prepend(video);document.body.append(overlay);const finish=()=>{controls?.stop();stream.getTracks().forEach(track=>track.stop());overlay.remove()};overlay.querySelector("button").onclick=finish;await video.play();controls=await reader.decodeFromVideoElement(video,(result,error)=>{if(!result)return;const clean=String(result.getText()||"").trim().replace(/^\s*(UID|SN|MAC)\s*[:#]?\s*/i,"");if(!clean||[form.elements.uid.value,form.elements.sn.value,form.elements.mac.value].includes(clean))return;if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(clean)||/^[0-9a-f]{12}$/i.test(clean))form.elements.mac.value=clean;else if(/^V\w/i.test(clean))form.elements.sn.value=clean;else if(!form.elements.uid.value)form.elements.uid.value=clean;else if(!form.elements.sn.value)form.elements.sn.value=clean;else if(!form.elements.mac.value)form.elements.mac.value=clean;help.textContent=form.elements.uid.value&&form.elements.sn.value&&form.elements.mac.value?"All three identifiers scanned. Close the camera and add this device.":"Barcode captured. Aim at the next code."})}catch(error){if(controls)controls.stop();if(stream)stream.getTracks().forEach(track=>track.stop());if(overlay)overlay.remove();help.textContent="Automatic scanning could not load. Enter the three identifiers manually or try Chrome."}}
async function scanLabelPhoto(form,help,file){let url;try{const {BrowserMultiFormatReader}=await import("https://cdn.jsdelivr.net/npm/@zxing/browser@0.1.5/+esm"),reader=new BrowserMultiFormatReader();url=URL.createObjectURL(file);const image=new Image();image.src=url;await image.decode();const canvas=document.createElement("canvas"),ctx=canvas.getContext("2d"),found=[];canvas.width=image.naturalWidth;canvas.height=Math.floor(image.naturalHeight/3);for(let i=0;i<3;i++){ctx.clearRect(0,0,canvas.width,canvas.height);ctx.drawImage(image,0,i*canvas.height,canvas.width,canvas.height,0,0,canvas.width,canvas.height);try{const result=reader.decodeFromCanvas(canvas),value=String(result.getText()||"").trim().replace(/^\\s*(UID|SN|MAC)\\s*[:#]?\\s*/i,"");if(value&&!found.includes(value))found.push(value)}catch{}}for(const clean of found){if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(clean)||/^[0-9a-f]{12}$/i.test(clean))form.elements.mac.value=clean;else if(/^V\\w/i.test(clean))form.elements.sn.value=clean;else if(!form.elements.uid.value)form.elements.uid.value=clean;else if(!form.elements.sn.value)form.elements.sn.value=clean;else if(!form.elements.mac.value)form.elements.mac.value=clean}help.textContent=found.length?found.length+" barcode"+(found.length===1?"":"s")+" read from the photo. Review the fields before adding the device.":"No barcodes were detected. Retake the photo with the full label in focus."}catch(error){help.textContent="The photo could not be processed. Retake it in brighter light or enter the values manually."}finally{if(url)URL.revokeObjectURL(url);file=null}}
const receiveObserver=new MutationObserver(()=>{const form=$("#receiveForm");if(!form||form.querySelector(".photo-scan"))return;const button=document.createElement("button");button.type="button";button.className="secondary photo-scan";button.textContent="Take photo of label";const input=document.createElement("input");input.type="file";input.accept="image/*";input.capture="environment";input.className="photo-input";input.style.position="absolute";input.style.width="1px";input.style.height="1px";input.style.opacity="0";input.style.pointerEvents="none";form.querySelector(".scan-panel .scan-camera").after(button,input);button.onclick=()=>input.click();input.onchange=()=>{if(input.files?.[0])scanLabelPhoto(form,form.querySelector(".scan-help"),input.files[0]);input.value=""}});receiveObserver.observe($("#modalBody"),{childList:true});
async function scanLabelPhotoFixed(form,help,file){let url;try{const {BrowserMultiFormatReader}=await import("https://cdn.jsdelivr.net/npm/@zxing/browser@0.1.5/+esm"),reader=new BrowserMultiFormatReader();url=URL.createObjectURL(file);const image=new Image();image.src=url;await image.decode();const canvas=document.createElement("canvas"),ctx=canvas.getContext("2d"),found=[];canvas.width=image.naturalWidth;canvas.height=Math.floor(image.naturalHeight/3);for(let i=0;i<3;i++){ctx.clearRect(0,0,canvas.width,canvas.height);ctx.drawImage(image,0,i*canvas.height,canvas.width,canvas.height,0,0,canvas.width,canvas.height);try{const result=reader.decodeFromCanvas(canvas),value=String(result.getText()||"").trim().replace(/^\s*(UID|SN|MAC)\s*[:#]?\s*/i,"");if(value&&!found.includes(value))found.push(value)}catch{}}if(found.length===3){form.elements.uid.value=found[0];form.elements.sn.value=found[1];form.elements.mac.value=found[2]}else for(const value of found){const clean=value.replace(/^\s*(UID|SN|MAC)\s*[:#]?\s*/i,"");if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(clean)||/^[0-9a-f]{12}$/i.test(clean))form.elements.mac.value=clean;else if(/^V[0-9A-Z]/i.test(clean))form.elements.sn.value=clean;else if(!form.elements.uid.value)form.elements.uid.value=clean;else if(!form.elements.sn.value)form.elements.sn.value=clean;else if(!form.elements.mac.value)form.elements.mac.value=clean}help.textContent=found.length?found.length+" barcode"+(found.length===1?"":"s")+" read from the photo. Review the fields before adding the device.":"No barcodes were detected. Retake the photo with the full label in focus."}catch(error){help.textContent="The photo could not be processed. Retake it in brighter light or enter the values manually."}finally{if(url)URL.revokeObjectURL(url)}}
const photoFixObserver=new MutationObserver(()=>{const input=$("#receiveForm .photo-input");if(input&&!input.dataset.fixed){input.dataset.fixed="true";input.onchange=()=>{if(input.files?.[0])scanLabelPhotoFixed($("#receiveForm"),$("#receiveForm .scan-help"),input.files[0]);input.value=""}}});photoFixObserver.observe($("#modalBody"),{childList:true});
async function scanLabelPhotoBetter(form,help,file){let url;try{const {BrowserMultiFormatReader}=await import("https://cdn.jsdelivr.net/npm/@zxing/browser@0.1.5/+esm"),reader=new BrowserMultiFormatReader();url=URL.createObjectURL(file);const image=new Image();image.src=url;await image.decode();const canvas=document.createElement("canvas"),ctx=canvas.getContext("2d"),found=[];canvas.width=image.naturalWidth;canvas.height=image.naturalHeight;const regions=[{y:0,h:1},{y:.2,h:.65},{y:.33,h:.22},{y:.52,h:.22},{y:.7,h:.22}];for(const region of regions){const sy=Math.floor(image.naturalHeight*region.y),sh=Math.floor(image.naturalHeight*region.h);canvas.width=image.naturalWidth;canvas.height=sh;ctx.drawImage(image,0,sy,image.naturalWidth,sh,0,0,canvas.width,canvas.height);try{const result=await reader.decodeFromImageUrl(canvas.toDataURL("image/jpeg",.95)),value=String(result.getText()||"").trim().replace(/^\s*(UID|SN|MAC)\s*[:#]?\s*/i,"");if(value&&!found.includes(value))found.push(value)}catch{}}if(found.length===3){form.elements.uid.value=found[0];form.elements.sn.value=found[1];form.elements.mac.value=found[2]}else for(const value of found){if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(value)||/^[0-9a-f]{12}$/i.test(value))form.elements.mac.value=value;else if(/^V[0-9A-Z]/i.test(value))form.elements.sn.value=value;else if(!form.elements.uid.value)form.elements.uid.value=value;else if(!form.elements.sn.value)form.elements.sn.value=value;else if(!form.elements.mac.value)form.elements.mac.value=value}help.textContent=found.length?found.length+" barcode"+(found.length===1?"":"s")+" read from the photo. Review the fields before adding the device.":"No barcodes were detected. Retake the photo closer and in brighter light."}catch(error){help.textContent="The photo could not be decoded. Retake it with the label filling most of the frame, or enter the values manually."}finally{if(url)URL.revokeObjectURL(url)}}
const photoBetterObserver=new MutationObserver(()=>{const input=$("#receiveForm .photo-input");if(input&&!input.dataset.better){input.dataset.better="true";input.onchange=()=>{if(input.files?.[0])scanLabelPhotoBetter($("#receiveForm"),$("#receiveForm .scan-help"),input.files[0]);input.value=""}}});photoBetterObserver.observe($("#modalBody"),{childList:true});
async function scanLabelPhotoFinal(form,help,file){let url;try{const {BrowserMultiFormatReader}=await import("https://cdn.jsdelivr.net/npm/@zxing/browser@0.1.5/+esm"),reader=new BrowserMultiFormatReader();url=URL.createObjectURL(file);const image=new Image();image.src=url;await image.decode();const canvas=document.createElement("canvas"),ctx=canvas.getContext("2d"),found=[];const regions=[{y:0,h:1},{y:.18,h:.68},{y:.32,h:.2},{y:.52,h:.2},{y:.7,h:.2}];for(const region of regions){const sy=Math.floor(image.naturalHeight*region.y),sh=Math.floor(image.naturalHeight*region.h);canvas.width=image.naturalWidth;canvas.height=sh;ctx.drawImage(image,0,sy,image.naturalWidth,sh,0,0,canvas.width,canvas.height);const crop=new Image();crop.src=canvas.toDataURL("image/jpeg",.98);await crop.decode();try{const result=await reader.decodeFromImageElement(crop),value=String(result.getText()||"").trim().replace(/^\s*(UID|SN|MAC)\s*[:#]?\s*/i,"");if(value&&!found.includes(value))found.push(value)}catch{}}if(found.length===3){form.elements.uid.value=found[0];form.elements.sn.value=found[1];form.elements.mac.value=found[2]}else for(const value of found){if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(value)||/^[0-9a-f]{12}$/i.test(value))form.elements.mac.value=value;else if(/^V[0-9A-Z]/i.test(value))form.elements.sn.value=value;else if(!form.elements.uid.value)form.elements.uid.value=value;else if(!form.elements.sn.value)form.elements.sn.value=value;else if(!form.elements.mac.value)form.elements.mac.value=value}help.textContent=found.length?found.length+" barcode"+(found.length===1?"":"s")+" read. Review the fields before adding the device.":"No barcodes detected. Retake the photo with the label filling most of the frame."}catch(error){help.textContent="The photo could not be decoded. Retake it closer and in brighter light, or enter the values manually."}finally{if(url)URL.revokeObjectURL(url)}}
const photoFinalObserver=new MutationObserver(()=>{const input=$("#receiveForm .photo-input");if(input&&!input.dataset.final){input.dataset.final="true";input.onchange=()=>{if(input.files?.[0])scanLabelPhotoFinal($("#receiveForm"),$("#receiveForm .scan-help"),input.files[0]);input.value=""}}});photoFinalObserver.observe($("#modalBody"),{childList:true});
async function scanLabelPhotoPreprocessed(form,help,file){let url;try{const {BrowserMultiFormatReader}=await import("https://cdn.jsdelivr.net/npm/@zxing/browser@0.1.5/+esm"),reader=new BrowserMultiFormatReader();url=URL.createObjectURL(file);const image=new Image();image.src=url;await image.decode();const source=document.createElement("canvas"),sctx=source.getContext("2d");source.width=image.naturalWidth;source.height=image.naturalHeight;sctx.drawImage(image,0,0);const found=[],regions=[{y:0,h:1},{y:.15,h:.7},{y:.28,h:.24},{y:.48,h:.24},{y:.68,h:.24}];for(const region of regions){const sy=Math.floor(source.height*region.y),sh=Math.floor(source.height*region.h),canvas=document.createElement("canvas"),ctx=canvas.getContext("2d"),scale=2;canvas.width=source.width*scale;canvas.height=sh*scale;ctx.drawImage(source,0,sy,source.width,sh,0,0,canvas.width,canvas.height);const pixels=ctx.getImageData(0,0,canvas.width,canvas.height);for(let i=0;i<pixels.data.length;i+=4){const gray=(pixels.data[i]*.299+pixels.data[i+1]*.587+pixels.data[i+2]*.114);const value=gray>175?255:gray<80?0:gray;pixels.data[i]=pixels.data[i+1]=pixels.data[i+2]=value}ctx.putImageData(pixels,0,0);const crop=new Image();crop.src=canvas.toDataURL("image/png");await crop.decode();try{const result=await reader.decodeFromImageElement(crop),value=String(result.getText()||"").trim().replace(/^\s*(UID|SN|MAC)\s*[:#]?\s*/i,"");if(value&&!found.includes(value))found.push(value)}catch{}}if(found.length===3){form.elements.uid.value=found[0];form.elements.sn.value=found[1];form.elements.mac.value=found[2]}else for(const value of found){if(/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(value)||/^[0-9a-f]{12}$/i.test(value))form.elements.mac.value=value;else if(/^V[0-9A-Z]/i.test(value))form.elements.sn.value=value;else if(!form.elements.uid.value)form.elements.uid.value=value;else if(!form.elements.sn.value)form.elements.sn.value=value;else if(!form.elements.mac.value)form.elements.mac.value=value}help.textContent=found.length?found.length+" barcode"+(found.length===1?"":"s")+" read after image enhancement. Review the fields before adding the device.":"No barcodes detected after image enhancement. Try a closer, brighter photo."}catch(error){help.textContent="The enhanced photo could not be decoded. Enter the identifiers manually."}finally{if(url)URL.revokeObjectURL(url)}}
const photoPreprocessObserver=new MutationObserver(()=>{const input=$("#receiveForm .photo-input");if(input&&!input.dataset.preprocessed){input.dataset.preprocessed="true";input.onchange=()=>{if(input.files?.[0])scanLabelPhotoPreprocessed($("#receiveForm"),$("#receiveForm .scan-help"),input.files[0]);input.value=""}}});photoPreprocessObserver.observe($("#modalBody"),{childList:true});
async function scanLabelPhotoOcr(form,help,file){let url,worker;try{const Tesseract=await import("https://cdn.jsdelivr.net/npm/tesseract.js@5/+esm");url=URL.createObjectURL(file);worker=await Tesseract.createWorker("eng");const result=await worker.recognize(url),text=String(result.data.text||"").replace(/\r/g," ");const uid=text.match(/UID\s*[:#]?\s*([A-Z0-9]+)/i)?.[1],sn=text.match(/SN\s*[:#]?\s*([A-Z0-9]+)/i)?.[1],mac=text.match(/MAC\s*[:#]?\s*((?:[A-F0-9]{2}[:\-]?){6})/i)?.[1];if(uid)form.elements.uid.value=uid;if(sn)form.elements.sn.value=sn;if(mac)form.elements.mac.value=mac.replace(/-/g,":").match(/.{2}/g)?.join(":")||mac;const count=[uid,sn,mac].filter(Boolean).length;help.textContent=count?count+" labeled value"+(count===1?"":"s")+" read from the photo. Review the fields before adding the device.":"No UID, SN, or MAC text was recognized. Retake the photo with the label filling most of the frame."}catch(error){help.textContent="The printed label text could not be recognized. Retake it closer and in brighter light, or enter the values manually."}finally{await worker?.terminate().catch(()=>{});if(url)URL.revokeObjectURL(url)}}
const photoOcrObserver=new MutationObserver(()=>{const input=$("#receiveForm .photo-input");if(input&&!input.dataset.ocr){input.dataset.ocr="true";input.onchange=()=>{if(input.files?.[0])scanLabelPhotoOcr($("#receiveForm"),$("#receiveForm .scan-help"),input.files[0]);input.value=""}}});photoOcrObserver.observe($("#modalBody"),{childList:true});
const normalizeMacValue=value=>{const hex=String(value||"").replace(/[^0-9a-f]/gi,"").slice(0,12);return hex.length===12?(hex.match(/.{2}/g)||[]).join(":"):value};
const macFormatObserver=new MutationObserver(()=>{const input=$("#receiveForm .photo-input");if(input&&!input.dataset.macfix){input.dataset.macfix="true";input.onchange=async()=>{if(input.files?.[0]){await scanLabelPhotoOcr($("#receiveForm"),$("#receiveForm .scan-help"),input.files[0]);$("#receiveForm").elements.mac.value=normalizeMacValue($("#receiveForm").elements.mac.value)}input.value=""}}});macFormatObserver.observe($("#modalBody"),{childList:true});
async function scanLabelPhotoTargeted(form,help,file){let url,worker;try{const Tesseract=await import("https://cdn.jsdelivr.net/npm/tesseract.js@5/+esm");url=URL.createObjectURL(file);const image=new Image();image.src=url;await image.decode();const source=document.createElement("canvas"),sctx=source.getContext("2d");source.width=image.naturalWidth;source.height=image.naturalHeight;sctx.drawImage(image,0,0);const pixels=sctx.getImageData(0,0,source.width,source.height).data;let minX=source.width,minY=source.height,maxX=0,maxY=0;for(let y=0;y<source.height;y+=4)for(let x=0;x<source.width;x+=4){const i=(y*source.width+x)*4;if(pixels[i]>170&&pixels[i+1]>170&&pixels[i+2]>170){minX=Math.min(minX,x);minY=Math.min(minY,y);maxX=Math.max(maxX,x);maxY=Math.max(maxY,y)}}if(maxX<=minX||maxY<=minY)throw new Error("label not found");const pad=24,crop=document.createElement("canvas"),ctx=crop.getContext("2d"),x=Math.max(0,minX-pad),y=Math.max(0,minY-pad),w=Math.min(source.width-x,maxX-minX+pad*2),h=Math.min(source.height-y,maxY-minY+pad*2),scale=2;crop.width=w*scale;crop.height=h*scale;ctx.drawImage(source,x,y,w,h,0,0,crop.width,crop.height);const data=ctx.getImageData(0,0,crop.width,crop.height);for(let i=0;i<data.data.length;i+=4){const gray=data.data[i]*.299+data.data[i+1]*.587+data.data[i+2]*.114;const v=gray>185?255:gray<100?0:gray;data.data[i]=data.data[i+1]=data.data[i+2]=v}ctx.putImageData(data,0,0);worker=await Tesseract.createWorker("eng");await worker.setParameters({tessedit_pageseg_mode:"6",tessedit_char_whitelist:"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:- "});const result=await worker.recognize(crop.toDataURL("image/png")),text=String(result.data.text||"").replace(/\r/g," ");const uid=text.match(/UID\s*[:#]?\s*([A-Z0-9]+)/i)?.[1],sn=text.match(/SN\s*[:#]?\s*([A-Z0-9]+)/i)?.[1],raw=text.match(/MAC\s*[:#]?\s*([A-F0-9: -]{12,})/i)?.[1],mac=raw?normalizeMacValue(raw):null;if(uid)form.elements.uid.value=uid;if(sn)form.elements.sn.value=sn;if(mac)form.elements.mac.value=mac;const count=[uid,sn,mac].filter(Boolean).length;help.textContent=count?count+" labeled value"+(count===1?"":"s")+" read from the cropped label. Review before adding.":"No labeled values detected. Retake the photo closer and keep the white label fully visible."}catch(error){help.textContent="The label could not be read. Retake a close, well-lit photo with the white label filling the frame."}finally{await worker?.terminate().catch(()=>{});if(url)URL.revokeObjectURL(url)}}
const targetedOcrObserver=new MutationObserver(()=>{const input=$("#receiveForm .photo-input");if(input&&!input.dataset.targeted){input.dataset.targeted="true";input.onchange=()=>{if(input.files?.[0])scanLabelPhotoTargeted($("#receiveForm"),$("#receiveForm .scan-help"),input.files[0]);input.value=""}}});targetedOcrObserver.observe($("#modalBody"),{childList:true});
const autoAddScanObserver=new MutationObserver(()=>{const form=$("#receiveForm"),help=form?.querySelector(".scan-help"),marker=form?.querySelector("[data-scan-added]");if(form&&marker&&!form.elements.uid.value&&!form.elements.sn.value&&!form.elements.mac.value)marker.remove();if(form&&help?.textContent.includes("read")&&form.elements.uid.value&&form.elements.sn.value&&form.elements.mac.value&&!marker){const added=document.createElement("i");added.dataset.scanAdded="true";added.hidden=true;form.append(added);form.querySelector(".add-device").click()}});autoAddScanObserver.observe($("#modalBody"),{childList:true,subtree:true,characterData:true});
if(!("BarcodeDetector" in window))document.addEventListener("click",e=>{const button=e.target.closest(".scan-camera");if(button){e.stopPropagation();scanLabelWithLibrary(button.closest("form"),button.closest("form").querySelector(".scan-help"))}},true);
applyTheme(); applyTheme();
initialize(); initialize();
+4
View File
@@ -6,3 +6,7 @@
#adminPanel{padding:24px}.admin-page{display:grid;gap:20px}.admin-page h2{margin:0 0 5px}.admin-page p{color:#667085}.admin-actions{display:flex;gap:10px;flex-wrap:wrap}.upload-backup{display:inline-flex;margin:0;background:#fff;border:1px solid #d7deea;border-radius:9px;padding:10px 14px;color:#27344a;cursor:pointer}.upload-backup input{display:none}.backup-warning{padding:13px 15px;border-radius:9px;background:#fff7ed;color:#9a3412;border:1px solid #fed7aa}.backup-list{border:1px solid #e3e7ef;border-radius:10px}.backup-list article{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:14px;border-bottom:1px solid #edf0f5}.backup-list article:last-child{border-bottom:0}.backup-list article small{display:block;color:#7b8799;margin-top:4px}.backup-list article>div:last-child{display:flex;gap:8px}.backup-list a.button{display:inline-flex;align-items:center;background:#fff;border:1px solid #d7deea;border-radius:9px;padding:10px 14px;color:#27344a;font-weight:650;text-decoration:none}.backup-list .delete-backup{background:#fff0f0;color:#c62828}.restart-message{text-align:center;padding:60px 20px}@media(max-width:760px){.backup-list article{align-items:flex-start;flex-direction:column}} #adminPanel{padding:24px}.admin-page{display:grid;gap:20px}.admin-page h2{margin:0 0 5px}.admin-page p{color:#667085}.admin-actions{display:flex;gap:10px;flex-wrap:wrap}.upload-backup{display:inline-flex;margin:0;background:#fff;border:1px solid #d7deea;border-radius:9px;padding:10px 14px;color:#27344a;cursor:pointer}.upload-backup input{display:none}.backup-warning{padding:13px 15px;border-radius:9px;background:#fff7ed;color:#9a3412;border:1px solid #fed7aa}.backup-list{border:1px solid #e3e7ef;border-radius:10px}.backup-list article{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:14px;border-bottom:1px solid #edf0f5}.backup-list article:last-child{border-bottom:0}.backup-list article small{display:block;color:#7b8799;margin-top:4px}.backup-list article>div:last-child{display:flex;gap:8px}.backup-list a.button{display:inline-flex;align-items:center;background:#fff;border:1px solid #d7deea;border-radius:9px;padding:10px 14px;color:#27344a;font-weight:650;text-decoration:none}.backup-list .delete-backup{background:#fff0f0;color:#c62828}.restart-message{text-align:center;padding:60px 20px}@media(max-width:760px){.backup-list article{align-items:flex-start;flex-direction:column}}
.auth-pending #appShell,.auth-required #appShell{display:none}.role-admin #authScreen,.role-readonly #authScreen{display:none}.auth-screen{min-height:100vh;display:grid;place-items:center;padding:24px;background:linear-gradient(145deg,#eef4ff,#f8fafc)}.auth-card{width:min(430px,100%);background:#fff;border:1px solid #dfe5ef;border-radius:18px;box-shadow:0 22px 60px #17203320;padding:34px}.auth-brand{margin-bottom:28px}.auth-card h1{margin:0 0 7px}.auth-card>div>p{color:#68758b}.auth-submit{width:100%;margin-top:14px}.auth-message{background:#fff0f0;color:#a51d1d;border:1px solid #fecaca;border-radius:8px;padding:10px 12px;margin:14px 0}.auth-logout{width:100%;margin-top:10px}.user-menu{display:flex;align-items:center;gap:10px;border-left:1px solid #e3e7ef;padding-left:18px}.user-menu span{display:grid;font-size:13px}.user-menu small{color:#758197}.admin-section{display:grid;gap:16px;border-bottom:1px solid #e3e7ef;padding-bottom:28px}.admin-section:last-child{border-bottom:0}.inline-user-form{display:grid;grid-template-columns:1fr 1fr 180px auto;gap:10px;align-items:end}.inline-user-form label{margin:0}.user-list,.audit-list{border:1px solid #e3e7ef;border-radius:10px;overflow:hidden}.user-list article{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:14px;border-bottom:1px solid #edf0f5}.user-list article:last-child{border-bottom:0}.user-list small{display:block;color:#7b8799;margin-top:4px}.user-list article>div:last-child{display:flex;gap:7px;flex-wrap:wrap}.delete-backup{background:#fff0f0;color:#c62828}.audit-list{max-height:420px;overflow:auto}.audit-list article{display:grid;grid-template-columns:140px 180px 1fr;gap:12px;padding:10px 13px;border-bottom:1px solid #edf0f5;font-size:13px}.audit-list span{text-transform:capitalize}.audit-list small{color:#7b8799}@media(max-width:900px){.inline-user-form{grid-template-columns:1fr 1fr}.user-list article{align-items:flex-start;flex-direction:column}.audit-list article{grid-template-columns:1fr}.user-menu span{display:none}}@media(max-width:600px){.inline-user-form{grid-template-columns:1fr}.user-menu{padding-left:5px}.user-menu button{font-size:12px;padding:8px}.auth-card{padding:25px}} .auth-pending #appShell,.auth-required #appShell{display:none}.role-admin #authScreen,.role-readonly #authScreen{display:none}.auth-screen{min-height:100vh;display:grid;place-items:center;padding:24px;background:linear-gradient(145deg,#eef4ff,#f8fafc)}.auth-card{width:min(430px,100%);background:#fff;border:1px solid #dfe5ef;border-radius:18px;box-shadow:0 22px 60px #17203320;padding:34px}.auth-brand{margin-bottom:28px}.auth-card h1{margin:0 0 7px}.auth-card>div>p{color:#68758b}.auth-submit{width:100%;margin-top:14px}.auth-message{background:#fff0f0;color:#a51d1d;border:1px solid #fecaca;border-radius:8px;padding:10px 12px;margin:14px 0}.auth-logout{width:100%;margin-top:10px}.user-menu{display:flex;align-items:center;gap:10px;border-left:1px solid #e3e7ef;padding-left:18px}.user-menu span{display:grid;font-size:13px}.user-menu small{color:#758197}.admin-section{display:grid;gap:16px;border-bottom:1px solid #e3e7ef;padding-bottom:28px}.admin-section:last-child{border-bottom:0}.inline-user-form{display:grid;grid-template-columns:1fr 1fr 180px auto;gap:10px;align-items:end}.inline-user-form label{margin:0}.user-list,.audit-list{border:1px solid #e3e7ef;border-radius:10px;overflow:hidden}.user-list article{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:14px;border-bottom:1px solid #edf0f5}.user-list article:last-child{border-bottom:0}.user-list small{display:block;color:#7b8799;margin-top:4px}.user-list article>div:last-child{display:flex;gap:7px;flex-wrap:wrap}.delete-backup{background:#fff0f0;color:#c62828}.audit-list{max-height:420px;overflow:auto}.audit-list article{display:grid;grid-template-columns:140px 180px 1fr;gap:12px;padding:10px 13px;border-bottom:1px solid #edf0f5;font-size:13px}.audit-list span{text-transform:capitalize}.audit-list small{color:#7b8799}@media(max-width:900px){.inline-user-form{grid-template-columns:1fr 1fr}.user-list article{align-items:flex-start;flex-direction:column}.audit-list article{grid-template-columns:1fr}.user-menu span{display:none}}@media(max-width:600px){.inline-user-form{grid-template-columns:1fr}.user-menu{padding-left:5px}.user-menu button{font-size:12px;padding:8px}.auth-card{padding:25px}}
.inline-model-form{display:grid;grid-template-columns:minmax(220px,1fr) auto;gap:10px;align-items:end}.inline-model-form label{margin:0}.model-list{border:1px solid #e3e7ef;border-radius:10px;overflow:hidden}.model-list article{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:14px;border-bottom:1px solid #edf0f5}.model-list article:last-child{border-bottom:0}.model-summary>div{display:flex;align-items:center;gap:9px}.model-summary small{display:block;color:#7b8799;margin-top:5px}.model-status{display:inline-flex;border-radius:99px;padding:3px 8px;font-size:11px;font-weight:750}.model-status.active{background:#dcfce7;color:#166534}.model-status.archived{background:#eef1f5;color:#526071}.model-actions{display:flex;gap:7px;flex-wrap:wrap}@media(max-width:760px){.inline-model-form{grid-template-columns:1fr}.model-list article{align-items:stretch;flex-direction:column}.model-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.model-actions button{text-align:center}} .inline-model-form{display:grid;grid-template-columns:minmax(220px,1fr) auto;gap:10px;align-items:end}.inline-model-form label{margin:0}.model-list{border:1px solid #e3e7ef;border-radius:10px;overflow:hidden}.model-list article{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:14px;border-bottom:1px solid #edf0f5}.model-list article:last-child{border-bottom:0}.model-summary>div{display:flex;align-items:center;gap:9px}.model-summary small{display:block;color:#7b8799;margin-top:5px}.model-status{display:inline-flex;border-radius:99px;padding:3px 8px;font-size:11px;font-weight:750}.model-status.active{background:#dcfce7;color:#166534}.model-status.archived{background:#eef1f5;color:#526071}.model-actions{display:flex;gap:7px;flex-wrap:wrap}@media(max-width:760px){.inline-model-form{grid-template-columns:1fr}.model-list article{align-items:stretch;flex-direction:column}.model-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.model-actions button{text-align:center}}
.conditional-fields{margin:2px 0 14px;padding:16px;border:1px solid #e3e7ef;border-radius:11px;background:#f8fafc}.conditional-fields h3{margin:0 0 12px}.conditional-hint{margin:4px 0 16px;padding:13px;border:1px dashed #d7deea;border-radius:9px;color:#68758b}.field-help{display:block;margin-top:-8px;color:#68758b}.warranty-pill{display:inline-flex;align-items:center;border-radius:99px;padding:5px 9px;font-size:12px;font-weight:750;white-space:nowrap}.warranty-pill.in-warranty{background:#dcfce7;color:#166534}.warranty-pill.expired{background:#fee2e2;color:#b91c1c}.warranty-pill.neutral{background:#eef1f5;color:#526071}.inline-warranty-form{display:grid;grid-template-columns:minmax(180px,1fr) 130px 150px auto;gap:10px;align-items:end}.inline-warranty-form label{margin:0}.warranty-list{border:1px solid #e3e7ef;border-radius:10px;overflow:hidden}.warranty-list article{display:flex;justify-content:space-between;align-items:center;gap:15px;padding:14px;border-bottom:1px solid #edf0f5}.warranty-list article:last-child{border-bottom:0}@media(max-width:760px){.inline-warranty-form{grid-template-columns:1fr}.warranty-list article{align-items:stretch;flex-direction:column}}
.sale-items{margin:18px 0;padding:0;border:1px solid #d7deea;border-radius:11px;overflow:hidden}.sale-items legend{margin-left:12px;padding:0 6px;font-size:13px;font-weight:750}.sale-item-list{max-height:250px;overflow:auto}.sale-item{display:grid;grid-template-columns:auto minmax(0,1fr) 130px;align-items:center;gap:11px;margin:0;padding:11px 13px;border-bottom:1px solid #edf0f5}.sale-item>input{width:18px;height:18px}.sale-item small{display:block;margin-top:3px;color:#68758b}.sale-item-price{font-size:12px}.sale-item-price input{margin-top:4px;text-align:right}.sale-summary{display:flex;justify-content:space-between;align-items:center;padding:12px 14px;background:#f8fafc}.sale-summary span{color:#68758b}@media(max-width:520px){.sale-item{grid-template-columns:auto minmax(0,1fr)}.sale-item-price{grid-column:2}.sale-item-list{max-height:310px}}
.scan-panel{margin:16px 0;padding:14px;border:1px solid #d7deea;border-radius:11px;background:#f8fafc}.scan-panel-heading{display:flex;justify-content:space-between;align-items:center;gap:10px}.scan-panel-heading h3{margin:0}.scan-panel-heading span{font-size:12px;color:#68758b}.scan-panel .scan-camera{margin-right:8px}.batch-list{display:grid;gap:6px;margin:12px 0}.batch-row{display:grid;grid-template-columns:auto repeat(3,minmax(0,1fr)) auto;align-items:center;gap:8px;padding:9px;border:1px solid #e3e7ef;border-radius:8px;font-size:12px}.empty-batch{margin:0;padding:12px;border:1px dashed #d7deea;border-radius:8px;color:#68758b;text-align:center}.scanner-overlay{position:fixed;inset:0;z-index:20;display:grid;place-items:center;padding:20px;background:#000c}.scanner-overlay>video{width:min(94vw,620px);max-height:78vh;object-fit:contain;border-radius:14px}.scanner-overlay>div{position:absolute;top:20px;left:20px;right:20px;display:flex;align-items:center;gap:12px;padding:12px 14px;background:#172033eF;color:#fff;border-radius:10px}.scanner-overlay small{display:block;opacity:.8}.scanner-overlay button{margin-left:auto;background:#fff;color:#172033}@media(max-width:760px){.batch-row{grid-template-columns:auto 1fr auto}.batch-row span{grid-column:2}.scan-panel .scan-camera{margin:0 0 8px;width:100%}.scan-panel .add-device{width:100%}}
.filter-bar{display:flex;align-items:end;gap:9px;flex-wrap:wrap;padding:12px 16px;border-bottom:1px solid #e3e7ef}.filter-bar label{margin:0;min-width:125px;font-size:11px}.filter-bar select,.filter-bar input{padding:8px 9px;font-size:13px}.filter-bar .button,.filter-bar button{padding:9px 12px}.export-button{text-decoration:none}.customer-match{min-height:18px;margin:-7px 0 10px!important;color:#1d4ed8!important;font-size:12px}.diagnostic-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}.diagnostic-grid article{display:grid;gap:4px;padding:14px;border:1px solid #e3e7ef;border-radius:10px}.diagnostic-grid span,.diagnostic-grid small{color:#68758b}.diagnostic-grid strong{font-size:18px}.automatic-backups{display:grid;gap:12px;padding:16px;border:1px solid #e3e7ef;border-radius:10px}.automatic-backups h3,.automatic-backups p{margin:0}.backup-schedule{display:grid;grid-template-columns:minmax(190px,1.3fr) repeat(4,minmax(130px,1fr)) auto;gap:10px;align-items:end}.backup-schedule label{margin:0}.weekly-field[hidden]{display:none!important}.check-label{display:flex!important;align-items:center;gap:8px;min-height:42px}.check-label input{width:auto}.backup-schedule-status{display:flex;gap:18px;flex-wrap:wrap;font-size:13px}.export-links{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.export-links .button{padding:8px 11px;text-decoration:none}@media(max-width:1100px){.backup-schedule{grid-template-columns:1fr 1fr 1fr}}@media(max-width:900px){.diagnostic-grid{grid-template-columns:1fr 1fr}.backup-schedule{grid-template-columns:1fr 1fr}}@media(max-width:600px){.filter-bar{align-items:stretch}.filter-bar label,.filter-bar .button,.filter-bar button{width:100%}.diagnostic-grid,.backup-schedule{grid-template-columns:1fr}}
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f5f7fb"><meta name="application-name" content="vBoxStock"><meta name="apple-mobile-web-app-title" content="vBoxStock"><title>vBoxStock</title><script>try{const t=localStorage.getItem("vboxstock-theme")||"system",d=t==="dark"||(t==="system"&&matchMedia("(prefers-color-scheme:dark)").matches);document.documentElement.dataset.theme=d?"dark":"light"}catch{}</script><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png"><link rel="manifest" href="/site.webmanifest"><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/extras.css"><link rel="stylesheet" href="/theme.css"></head> <!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f5f7fb"><meta name="application-name" content="vBoxStock"><meta name="apple-mobile-web-app-title" content="vBoxStock"><title>vBoxStock</title><script>try{const t=localStorage.getItem("vboxstock-theme")||"system",d=t==="dark"||(t==="system"&&matchMedia("(prefers-color-scheme:dark)").matches);document.documentElement.dataset.theme=d?"dark":"light"}catch{}</script><link rel="icon" type="image/png" sizes="32x32" href="/assets/favicon-32.png"><link rel="apple-touch-icon" sizes="180x180" href="/assets/apple-touch-icon.png"><link rel="manifest" href="/site.webmanifest"><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/extras.css"><link rel="stylesheet" href="/theme.css"></head>
<body class="auth-pending"><section id="authScreen" class="auth-screen"><div class="auth-card"><div class="brand auth-brand"><img src="/assets/vboxstock-icon-512.png" alt=""><span>vBoxStock</span></div><div id="authBody"></div></div></section><div id="appShell"><header><div class="brand"><img src="/assets/vboxstock-icon-512.png" alt=""><span>vBoxStock</span></div><input id="search" type="search" aria-label="Search inventory, sales, and customers" placeholder="Search UID, SN, MAC, model, customer…"><div class="actions edit-only"><button class="secondary" data-open="receive"> Receive product</button><button class="primary" data-open="sell">Record a sale</button></div><div class="user-menu"><span id="currentUser"></span><label class="theme-control"><span>Theme</span><select id="themeSelect" aria-label="Color theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></label><button class="secondary" id="logout">Log out</button></div></header> <body class="auth-pending"><section id="authScreen" class="auth-screen"><div class="auth-card"><div class="brand auth-brand"><img src="/assets/vboxstock-icon-512.png" alt=""><span>vBoxStock</span></div><div id="authBody"></div></div></section><div id="appShell"><header><div class="brand"><img src="/assets/vboxstock-icon-512.png" alt=""><span>vBoxStock</span></div><input id="search" type="search" aria-label="Search inventory, sales, and customers" placeholder="Search UID, SN, MAC, model, customer…"><div class="actions edit-only"><button class="secondary" data-open="receive"> Receive product</button><button class="primary" data-open="sell">Record a sale</button></div><div class="user-menu"><span id="currentUser"></span><label class="theme-control"><span>Theme</span><select id="themeSelect" aria-label="Color theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></label><button class="secondary" id="logout">Log out</button></div></header>
<main><div class="intro"><h1>Inventory Overview</h1><span id="date"></span></div><section class="stats"><article><span>Available products</span><strong id="availableCount">0</strong><small>Ready to sell</small></article><article><span>Sold this month</span><strong id="soldCount">0</strong><small id="revenue">No sales recorded</small></article><article><span>Inventory value</span><strong id="value">$0</strong><small>Based on purchase cost</small></article></section> <main><div class="intro"><h1>Inventory Overview</h1><span id="date"></span></div><section class="stats"><article><span>Available products</span><strong id="availableCount">0</strong><small>Ready to sell</small></article><article><span>Sold this month</span><strong id="soldCount">0</strong><small id="revenue">No sales recorded</small></article><article><span>Inventory value</span><strong id="value">$0</strong><small>Based on purchase cost</small></article></section>
<section class="records"><nav><button class="tab active" data-tab="available">Available inventory <i id="availableBadge">0</i></button><button class="tab" data-tab="sold">Sales history <i id="soldBadge">0</i></button><button class="tab" data-tab="customers">Customers <i id="customerBadge">0</i></button><button class="tab" data-tab="admin">Admin</button><span id="storageStatus" class="storage-status connecting" role="status" aria-live="polite"><i></i><b>Connecting to database</b></span></nav><div class="table-wrap"><table><thead id="thead"></thead><tbody id="rows"></tbody></table><div id="empty" hidden></div></div><div id="adminPanel" hidden></div><footer id="pagination"></footer></section></main> <section class="records"><nav><button class="tab active" data-tab="available">Available inventory <i id="availableBadge">0</i></button><button class="tab" data-tab="sold">Sales history <i id="soldBadge">0</i></button><button class="tab" data-tab="customers">Customers <i id="customerBadge">0</i></button><button class="tab" data-tab="admin">Admin</button><span id="storageStatus" class="storage-status connecting" role="status" aria-live="polite"><i></i><b>Connecting to database</b></span></nav><div id="filterBar" class="filter-bar"></div><div class="table-wrap"><table><thead id="thead"></thead><tbody id="rows"></tbody></table><div id="empty" hidden></div></div><div id="adminPanel" hidden></div><footer id="pagination"></footer></section></main>
<dialog id="modal"><button class="close" aria-label="Close">×</button><div id="modalBody"></div></dialog><div id="toast" hidden></div></div><script type="module" src="/app.js"></script></body></html> <dialog id="modal"><button class="close" aria-label="Close">×</button><div id="modalBody"></div></dialog><div id="toast" hidden></div></div><script type="module" src="/app.js"></script></body></html>
+6 -2
View File
@@ -23,8 +23,11 @@ th{background:var(--surface-table);color:var(--muted-2)}th,td,.records nav,.back
.records nav .tab,.customer-link{color:var(--muted)}.records nav .tab.active{color:var(--blue);border-color:var(--blue)}.tab i{background:var(--surface-soft)} .records nav .tab,.customer-link{color:var(--muted)}.records nav .tab.active{color:var(--blue);border-color:var(--blue)}.tab i{background:var(--surface-soft)}
.row-actions button,.customer-purchases button{background:var(--blue-soft);color:var(--blue-text)}.row-actions .danger,.customer-notes article>button,.delete-backup,.backup-list .delete-backup{background:var(--danger-soft);color:var(--danger)} .row-actions button,.customer-purchases button{background:var(--blue-soft);color:var(--blue-text)}.row-actions .danger,.customer-notes article>button,.delete-backup,.backup-list .delete-backup{background:var(--danger-soft);color:var(--danger)}
.pill{background:var(--green-soft);color:var(--green)}.detail-card{background:var(--surface-soft);border-color:var(--border)} .pill{background:var(--green-soft);color:var(--green)}.detail-card{background:var(--surface-soft);border-color:var(--border)}
.customer-notes article,.backup-list,.user-list,.model-list,.audit-list{border-color:var(--border)} .customer-notes article,.backup-list,.user-list,.model-list,.warranty-list,.audit-list{border-color:var(--border)}
.model-list article{border-color:var(--border)}.model-summary small{color:var(--muted)}.model-status.active{background:var(--green-soft);color:var(--green)}.model-status.archived{background:var(--surface-soft);color:var(--muted)} .model-list article{border-color:var(--border)}.model-summary small{color:var(--muted)}.model-status.active{background:var(--green-soft);color:var(--green)}.model-status.archived{background:var(--surface-soft);color:var(--muted)}
.warranty-list article,.conditional-fields,.sale-items,.sale-item{border-color:var(--border)}.conditional-fields,.sale-summary{background:var(--surface-soft)}.sale-item small,.sale-summary span,.conditional-hint{color:var(--muted)}.conditional-hint{border-color:var(--border-strong)}.field-help{color:var(--muted)}.warranty-pill.in-warranty{background:var(--green-soft);color:var(--green)}.warranty-pill.expired{background:var(--danger-soft);color:var(--danger)}.warranty-pill.neutral{background:var(--surface-soft);color:var(--muted)}
.scan-panel,.scanner-overlay,.scanner-overlay input,.scanner-overlay select,.scanner-overlay textarea{color:var(--text)}.scan-panel{background:var(--surface-soft);border-color:var(--border)}.scan-panel input,.scan-panel select,.scan-panel textarea{background:var(--surface);border-color:var(--border-strong);color:var(--text)}.scanner-overlay{background:#000c}.scanner-overlay>div{background:var(--surface);color:var(--text);border:1px solid var(--border)}.scanner-overlay button{background:var(--blue);color:#fff}
.filter-bar{border-color:var(--border);background:var(--surface-soft)}.filter-bar .button{background:var(--surface);border:1px solid var(--border-strong);color:var(--text)}.customer-match{color:var(--blue-text)!important}.diagnostic-grid article,.automatic-backups{background:var(--surface-soft);border-color:var(--border)}.diagnostic-grid span,.diagnostic-grid small,.automatic-backups p,.backup-schedule-status{color:var(--muted)}.export-links .button{background:var(--surface-soft);border:1px solid var(--border-strong);color:var(--text)}
.note-category{background:var(--blue-soft);color:var(--blue-text)}.backup-warning{background:var(--warning-soft);border-color:color-mix(in srgb,var(--warning) 40%,transparent);color:var(--warning)} .note-category{background:var(--blue-soft);color:var(--blue-text)}.backup-warning{background:var(--warning-soft);border-color:color-mix(in srgb,var(--warning) 40%,transparent);color:var(--warning)}
.auth-screen{background:linear-gradient(145deg,color-mix(in srgb,var(--blue) 10%,var(--page)),var(--page))}.auth-card{box-shadow:0 22px 60px var(--shadow)} .auth-screen{background:linear-gradient(145deg,color-mix(in srgb,var(--blue) 10%,var(--page)),var(--page))}.auth-card{box-shadow:0 22px 60px var(--shadow)}
dialog{max-height:min(90dvh,900px);overflow:auto}dialog::backdrop{background:#050914aa}.close{color:var(--muted)} dialog{max-height:min(90dvh,900px);overflow:auto}dialog::backdrop{background:#050914aa}.close{color:var(--muted)}
@@ -40,6 +43,7 @@ button,.button,input,select,textarea{-webkit-tap-highlight-color:transparent}but
.inline-user-form{grid-template-columns:1fr 1fr}.inline-model-form{grid-template-columns:1fr auto}.audit-list article{grid-template-columns:120px 160px 1fr} .inline-user-form{grid-template-columns:1fr 1fr}.inline-model-form{grid-template-columns:1fr auto}.audit-list article{grid-template-columns:120px 160px 1fr}
} }
@media (max-width:820px){ @media (max-width:820px){
html,body{max-width:100%;overflow-x:hidden}input,select,textarea{font-size:16px!important}.auth-screen{min-height:100dvh;width:100%;overflow-x:hidden}.auth-card{max-width:100%}
header{position:relative;display:grid;grid-template-columns:auto 1fr auto;height:auto;padding:12px max(14px,env(safe-area-inset-right)) 12px max(14px,env(safe-area-inset-left));gap:10px} header{position:relative;display:grid;grid-template-columns:auto 1fr auto;height:auto;padding:12px max(14px,env(safe-area-inset-right)) 12px max(14px,env(safe-area-inset-left));gap:10px}
header .brand{grid-column:1}.user-menu{grid-column:2/4;grid-row:1;margin-left:auto!important;min-width:0!important;padding-left:0;border-left:0}.user-menu #currentUser{display:grid!important;width:auto!important;min-width:78px;padding:5px 7px}.theme-control{grid-template-columns:1fr;margin-left:0;padding-left:6px}.theme-control>span{font-size:10px;text-align:center} header .brand{grid-column:1}.user-menu{grid-column:2/4;grid-row:1;margin-left:auto!important;min-width:0!important;padding-left:0;border-left:0}.user-menu #currentUser{display:grid!important;width:auto!important;min-width:78px;padding:5px 7px}.theme-control{grid-template-columns:1fr;margin-left:0;padding-left:6px}.theme-control>span{font-size:10px;text-align:center}
header #search{grid-column:1/-1;grid-row:2;order:initial}.actions{grid-column:1/-1;grid-row:3;width:100%;margin:0}.actions button{font-size:14px!important;flex:1}.actions button::first-letter{font-size:inherit!important} header #search{grid-column:1/-1;grid-row:2;order:initial}.actions{grid-column:1/-1;grid-row:3;width:100%;margin:0}.actions button{font-size:14px!important;flex:1}.actions button::first-letter{font-size:inherit!important}
@@ -48,7 +52,7 @@ button,.button,input,select,textarea{-webkit-tap-highlight-color:transparent}but
.table-wrap{overflow:visible}table{display:block;min-width:0}thead{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}tbody{display:grid;gap:10px;padding:10px;background:var(--page)}tr{display:grid;background:var(--surface);border:1px solid var(--border);border-radius:12px;overflow:hidden}td{display:grid;grid-template-columns:minmax(92px,34%) 1fr;gap:10px;align-items:start;padding:11px 13px;border-bottom:1px solid var(--border);overflow-wrap:anywhere}td:last-child{border-bottom:0}td::before{content:attr(data-label);font-size:11px;font-weight:750;text-transform:uppercase;letter-spacing:.04em;color:var(--muted-2)}td:empty{display:none}.row-actions{flex-wrap:wrap}.row-actions button{flex:1;min-width:95px} .table-wrap{overflow:visible}table{display:block;min-width:0}thead{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}tbody{display:grid;gap:10px;padding:10px;background:var(--page)}tr{display:grid;background:var(--surface);border:1px solid var(--border);border-radius:12px;overflow:hidden}td{display:grid;grid-template-columns:minmax(92px,34%) 1fr;gap:10px;align-items:start;padding:11px 13px;border-bottom:1px solid var(--border);overflow-wrap:anywhere}td:last-child{border-bottom:0}td::before{content:attr(data-label);font-size:11px;font-weight:750;text-transform:uppercase;letter-spacing:.04em;color:var(--muted-2)}td:empty{display:none}.row-actions{flex-wrap:wrap}.row-actions button{flex:1;min-width:95px}
#empty{background:var(--surface);padding:45px 20px}#pagination{background:var(--surface);padding:14px 12px}.address-grid{grid-template-columns:1fr}.form-row,.scan{grid-template-columns:1fr} #empty{background:var(--surface);padding:45px 20px}#pagination{background:var(--surface);padding:14px 12px}.address-grid{grid-template-columns:1fr}.form-row,.scan{grid-template-columns:1fr}
dialog{width:calc(100vw - 20px);max-height:calc(100dvh - 20px);padding:22px 18px;margin:10px}.form-actions{flex-wrap:wrap}.form-actions button{min-width:110px} dialog{width:calc(100vw - 20px);max-height:calc(100dvh - 20px);padding:22px 18px;margin:10px}.form-actions{flex-wrap:wrap}.form-actions button{min-width:110px}
#adminPanel{padding:15px 12px}.admin-page{gap:26px}.admin-section{gap:13px}.inline-user-form,.inline-model-form{grid-template-columns:1fr}.user-list article,.model-list article,.backup-list article{align-items:stretch}.user-list article>div:last-child,.model-actions,.backup-list article>div:last-child{display:grid;grid-template-columns:1fr 1fr}.user-list button,.model-list button,.backup-list button,.backup-list a.button{justify-content:center;text-align:center}.audit-list article{grid-template-columns:1fr;gap:3px}.audit-list{max-height:55vh} #adminPanel{padding:15px 12px}.admin-page{gap:26px}.admin-section{gap:13px}.inline-user-form,.inline-model-form,.inline-warranty-form{grid-template-columns:1fr}.user-list article,.model-list article,.warranty-list article,.backup-list article{align-items:stretch}.user-list article>div:last-child,.model-actions,.backup-list article>div:last-child{display:grid;grid-template-columns:1fr 1fr}.user-list button,.model-list button,.warranty-list button,.backup-list button,.backup-list a.button{justify-content:center;text-align:center}.audit-list article{grid-template-columns:1fr;gap:3px}.audit-list{max-height:55vh}
.auth-screen{padding:16px}.auth-card{padding:25px 20px}.auth-brand span{display:inline} .auth-screen{padding:16px}.auth-card{padding:25px 20px}.auth-brand span{display:inline}
} }
@media (max-width:430px){ @media (max-width:430px){
+64 -12
View File
@@ -1,17 +1,18 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { readFile, stat, readdir, writeFile, copyFile, unlink } from "node:fs/promises"; import { readFile, stat, statfs, readdir, writeFile, copyFile, unlink } from "node:fs/promises";
import { mkdirSync } from "node:fs"; import { mkdirSync } from "node:fs";
import { extname, join, normalize } from "node:path"; import { extname, join, normalize } from "node:path";
import { backup, DatabaseSync } from "node:sqlite"; import { backup, DatabaseSync } from "node:sqlite";
import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto"; import { randomBytes, scryptSync, timingSafeEqual, createHash } from "node:crypto";
const port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data"; const APP_VERSION="3.5.0",SCHEMA_VERSION=3,port=Number(process.env.PORT||3000),dataDir=process.env.DATA_DIR||"/data";
const databasePath=join(dataDir,"vboxstock.db"),backupDir=join(dataDir,"backups"),restoreMarker=join(dataDir,".restore-audit.json"),publicDir=join(import.meta.dirname,"public"); const databasePath=join(dataDir,"vboxstock.db"),backupDir=join(dataDir,"backups"),restoreMarker=join(dataDir,".restore-audit.json"),publicDir=join(import.meta.dirname,"public");
const SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map(); const SESSION_IDLE_MS=12*60*60*1000,sessions=new Map(),loginFailures=new Map();
mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true}); mkdirSync(dataDir,{recursive:true});mkdirSync(backupDir,{recursive:true});
let db=new DatabaseSync(databasePath); let db=new DatabaseSync(databasePath);
const legacyProductSchema=()=>String(db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='products'").get()?.sql||"").includes("CHECK(model IN"); const legacyProductSchema=()=>String(db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='products'").get()?.sql||"").includes("CHECK(model IN");
if(legacyProductSchema())await backup(db,join(backupDir,`pre-model-catalog-${new Date().toISOString().replace(/[:.]/g,"-")}.db`)); const needsSaleMigration=()=>{const exists=db.prepare("SELECT 1 found FROM sqlite_master WHERE type='table' AND name='products'").get();return exists&&!db.prepare("PRAGMA table_info(products)").all().some(c=>c.name==="fulfillment_method")};
if(legacyProductSchema()||needsSaleMigration())await backup(db,join(backupDir,`pre-v3-2-upgrade-${new Date().toISOString().replace(/[:.]/g,"-")}.db`));
function initializeDatabase(){ function initializeDatabase(){
db.exec(` db.exec(`
@@ -21,20 +22,29 @@ function initializeDatabase(){
CREATE TABLE IF NOT EXISTS customer_notes (id TEXT PRIMARY KEY,customer_id TEXT NOT NULL,category TEXT NOT NULL DEFAULT 'General',note TEXT NOT NULL,created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,FOREIGN KEY(customer_id) REFERENCES customers(id) ON DELETE CASCADE); CREATE TABLE IF NOT EXISTS customer_notes (id TEXT PRIMARY KEY,customer_id TEXT NOT NULL,category TEXT NOT NULL DEFAULT 'General',note TEXT NOT NULL,created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,FOREIGN KEY(customer_id) REFERENCES customers(id) ON DELETE CASCADE);
CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY,username TEXT NOT NULL COLLATE NOCASE UNIQUE,password_hash TEXT NOT NULL,role TEXT NOT NULL CHECK(role IN ('admin','readonly')),enabled INTEGER NOT NULL DEFAULT 1,must_change_password INTEGER NOT NULL DEFAULT 0,created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,last_login_at TEXT); CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY,username TEXT NOT NULL COLLATE NOCASE UNIQUE,password_hash TEXT NOT NULL,role TEXT NOT NULL CHECK(role IN ('admin','readonly')),enabled INTEGER NOT NULL DEFAULT 1,must_change_password INTEGER NOT NULL DEFAULT 0,created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,last_login_at TEXT);
CREATE TABLE IF NOT EXISTS audit_log (id INTEGER PRIMARY KEY AUTOINCREMENT,user_id TEXT,username TEXT NOT NULL DEFAULT 'system',action TEXT NOT NULL,target TEXT NOT NULL DEFAULT '',details TEXT NOT NULL DEFAULT '',ip_address TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP); CREATE TABLE IF NOT EXISTS audit_log (id INTEGER PRIMARY KEY AUTOINCREMENT,user_id TEXT,username TEXT NOT NULL DEFAULT 'system',action TEXT NOT NULL,target TEXT NOT NULL DEFAULT '',details TEXT NOT NULL DEFAULT '',ip_address TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE IF NOT EXISTS app_settings (key TEXT PRIMARY KEY,value TEXT NOT NULL,updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);
CREATE INDEX IF NOT EXISTS idx_products_status ON products(status); CREATE UNIQUE INDEX IF NOT EXISTS idx_products_uid ON products(uid) WHERE uid!=''; CREATE UNIQUE INDEX IF NOT EXISTS idx_products_sn ON products(sn) WHERE sn!=''; CREATE UNIQUE INDEX IF NOT EXISTS idx_products_mac ON products(mac) WHERE mac!=''; CREATE INDEX IF NOT EXISTS idx_customer_notes_customer_id ON customer_notes(customer_id); CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_log(created_at DESC);`); CREATE INDEX IF NOT EXISTS idx_products_status ON products(status); CREATE UNIQUE INDEX IF NOT EXISTS idx_products_uid ON products(uid) WHERE uid!=''; CREATE UNIQUE INDEX IF NOT EXISTS idx_products_sn ON products(sn) WHERE sn!=''; CREATE UNIQUE INDEX IF NOT EXISTS idx_products_mac ON products(mac) WHERE mac!=''; CREATE INDEX IF NOT EXISTS idx_customer_notes_customer_id ON customer_notes(customer_id); CREATE INDEX IF NOT EXISTS idx_audit_created_at ON audit_log(created_at DESC);`);
const cols=new Set(db.prepare("PRAGMA table_info(products)").all().map(c=>c.name)); const cols=new Set(db.prepare("PRAGMA table_info(products)").all().map(c=>c.name));
for(const [name,definition] of [["customer_id","TEXT"],["ship_address1","TEXT NOT NULL DEFAULT ''"],["ship_address2","TEXT NOT NULL DEFAULT ''"],["ship_city","TEXT NOT NULL DEFAULT ''"],["ship_state","TEXT NOT NULL DEFAULT ''"],["ship_zip","TEXT NOT NULL DEFAULT ''"],["shipping_notes","TEXT NOT NULL DEFAULT ''"],["payment_method","TEXT NOT NULL DEFAULT ''"],["payment_reference","TEXT NOT NULL DEFAULT ''"],["sale_notes","TEXT NOT NULL DEFAULT ''"]])if(!cols.has(name))db.exec(`ALTER TABLE products ADD COLUMN ${name} ${definition}`); for(const [name,definition] of [["customer_id","TEXT"],["ship_address1","TEXT NOT NULL DEFAULT ''"],["ship_address2","TEXT NOT NULL DEFAULT ''"],["ship_city","TEXT NOT NULL DEFAULT ''"],["ship_state","TEXT NOT NULL DEFAULT ''"],["ship_zip","TEXT NOT NULL DEFAULT ''"],["shipping_notes","TEXT NOT NULL DEFAULT ''"],["payment_method","TEXT NOT NULL DEFAULT ''"],["payment_reference","TEXT NOT NULL DEFAULT ''"],["sale_notes","TEXT NOT NULL DEFAULT ''"],["fulfillment_method","TEXT NOT NULL DEFAULT ''"],["fulfillment_name","TEXT NOT NULL DEFAULT ''"],["carrier","TEXT NOT NULL DEFAULT ''"],["tracking_number","TEXT NOT NULL DEFAULT ''"],["delivery_status","TEXT NOT NULL DEFAULT ''"],["delivery_status_updated_at","TEXT"],["warranty_preset_id","TEXT"],["warranty_name","TEXT NOT NULL DEFAULT ''"],["warranty_end_date","TEXT"]])if(!cols.has(name))db.exec(`ALTER TABLE products ADD COLUMN ${name} ${definition}`);
if(legacyProductSchema()){ if(legacyProductSchema()){
db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE"); db.exec("PRAGMA foreign_keys=OFF; BEGIN IMMEDIATE");
try{db.exec(`CREATE TABLE products_model_migration (id TEXT PRIMARY KEY,uid TEXT NOT NULL DEFAULT '',sn TEXT NOT NULL DEFAULT '',mac TEXT NOT NULL DEFAULT '',manufacturer TEXT NOT NULL DEFAULT 'vSeeBox' CHECK(manufacturer='vSeeBox'),model TEXT NOT NULL,condition TEXT NOT NULL CHECK(condition IN ('New','Used','Refurbished')),received_at TEXT NOT NULL,cost REAL NOT NULL DEFAULT 0,notes TEXT NOT NULL DEFAULT '',status TEXT NOT NULL DEFAULT 'available' CHECK(status IN ('available','sold')),sold_at TEXT,customer_name TEXT,phone TEXT,sale_price REAL,customer_id TEXT,ship_address1 TEXT NOT NULL DEFAULT '',ship_address2 TEXT NOT NULL DEFAULT '',ship_city TEXT NOT NULL DEFAULT '',ship_state TEXT NOT NULL DEFAULT '',ship_zip TEXT NOT NULL DEFAULT '',shipping_notes TEXT NOT NULL DEFAULT '',payment_method TEXT NOT NULL DEFAULT '',payment_reference TEXT NOT NULL DEFAULT '',sale_notes TEXT NOT NULL DEFAULT ''); try{db.exec(`CREATE TABLE products_model_migration (id TEXT PRIMARY KEY,uid TEXT NOT NULL DEFAULT '',sn TEXT NOT NULL DEFAULT '',mac TEXT NOT NULL DEFAULT '',manufacturer TEXT NOT NULL DEFAULT 'vSeeBox' CHECK(manufacturer='vSeeBox'),model TEXT NOT NULL,condition TEXT NOT NULL CHECK(condition IN ('New','Used','Refurbished')),received_at TEXT NOT NULL,cost REAL NOT NULL DEFAULT 0,notes TEXT NOT NULL DEFAULT '',status TEXT NOT NULL DEFAULT 'available' CHECK(status IN ('available','sold')),sold_at TEXT,customer_name TEXT,phone TEXT,sale_price REAL,customer_id TEXT,ship_address1 TEXT NOT NULL DEFAULT '',ship_address2 TEXT NOT NULL DEFAULT '',ship_city TEXT NOT NULL DEFAULT '',ship_state TEXT NOT NULL DEFAULT '',ship_zip TEXT NOT NULL DEFAULT '',shipping_notes TEXT NOT NULL DEFAULT '',payment_method TEXT NOT NULL DEFAULT '',payment_reference TEXT NOT NULL DEFAULT '',sale_notes TEXT NOT NULL DEFAULT '');
INSERT INTO products_model_migration SELECT id,uid,sn,mac,manufacturer,model,condition,received_at,cost,notes,status,sold_at,customer_name,phone,sale_price,customer_id,ship_address1,ship_address2,ship_city,ship_state,ship_zip,shipping_notes,payment_method,payment_reference,sale_notes FROM products; INSERT INTO products_model_migration SELECT id,uid,sn,mac,manufacturer,model,condition,received_at,cost,notes,status,sold_at,customer_name,phone,sale_price,customer_id,ship_address1,ship_address2,ship_city,ship_state,ship_zip,shipping_notes,payment_method,payment_reference,sale_notes FROM products;
DROP TABLE products; ALTER TABLE products_model_migration RENAME TO products; COMMIT;`)}catch(error){db.exec("ROLLBACK");throw error}finally{db.exec("PRAGMA foreign_keys=ON")} DROP TABLE products; ALTER TABLE products_model_migration RENAME TO products; COMMIT;`)}catch(error){db.exec("ROLLBACK");throw error}finally{db.exec("PRAGMA foreign_keys=ON")}
} }
const finalCols=new Set(db.prepare("PRAGMA table_info(products)").all().map(c=>c.name));
for(const [name,definition] of [["fulfillment_method","TEXT NOT NULL DEFAULT ''"],["fulfillment_name","TEXT NOT NULL DEFAULT ''"],["carrier","TEXT NOT NULL DEFAULT ''"],["tracking_number","TEXT NOT NULL DEFAULT ''"],["delivery_status","TEXT NOT NULL DEFAULT ''"],["delivery_status_updated_at","TEXT"],["warranty_preset_id","TEXT"],["warranty_name","TEXT NOT NULL DEFAULT ''"],["warranty_end_date","TEXT"]])if(!finalCols.has(name))db.exec(`ALTER TABLE products ADD COLUMN ${name} ${definition}`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_products_status ON products(status); CREATE UNIQUE INDEX IF NOT EXISTS idx_products_uid ON products(uid) WHERE uid!=''; CREATE UNIQUE INDEX IF NOT EXISTS idx_products_sn ON products(sn) WHERE sn!=''; CREATE UNIQUE INDEX IF NOT EXISTS idx_products_mac ON products(mac) WHERE mac!=''; CREATE INDEX IF NOT EXISTS idx_products_customer_id ON products(customer_id); db.exec(`CREATE INDEX IF NOT EXISTS idx_products_status ON products(status); CREATE UNIQUE INDEX IF NOT EXISTS idx_products_uid ON products(uid) WHERE uid!=''; CREATE UNIQUE INDEX IF NOT EXISTS idx_products_sn ON products(sn) WHERE sn!=''; CREATE UNIQUE INDEX IF NOT EXISTS idx_products_mac ON products(mac) WHERE mac!=''; CREATE INDEX IF NOT EXISTS idx_products_customer_id ON products(customer_id);
CREATE TABLE IF NOT EXISTS product_models (id TEXT PRIMARY KEY,name TEXT NOT NULL COLLATE NOCASE UNIQUE,active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);`); CREATE TABLE IF NOT EXISTS product_models (id TEXT PRIMARY KEY,name TEXT NOT NULL COLLATE NOCASE UNIQUE,active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE IF NOT EXISTS warranty_presets (id TEXT PRIMARY KEY,name TEXT NOT NULL COLLATE NOCASE UNIQUE,duration_value INTEGER NOT NULL DEFAULT 0 CHECK(duration_value>=0),duration_unit TEXT NOT NULL DEFAULT 'days' CHECK(duration_unit IN ('days','months','years')),active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),is_default INTEGER NOT NULL DEFAULT 0 CHECK(is_default IN (0,1)),created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);`);
const addModel=db.prepare("INSERT OR IGNORE INTO product_models (id,name) VALUES (?,?)"); const addModel=db.prepare("INSERT OR IGNORE INTO product_models (id,name) VALUES (?,?)");
for(const row of db.prepare("SELECT DISTINCT model FROM products WHERE trim(model)!=''").all())addModel.run(crypto.randomUUID(),row.model); for(const row of db.prepare("SELECT DISTINCT model FROM products WHERE trim(model)!=''").all())addModel.run(crypto.randomUUID(),row.model);
for(const name of ["V3 Plus","V5 Pro","V6 Plus","V6 Pro"])addModel.run(crypto.randomUUID(),name); for(const name of ["V3 Plus","V5 Pro","V6 Plus","V6 Pro"])addModel.run(crypto.randomUUID(),name);
const addWarranty=db.prepare("INSERT OR IGNORE INTO warranty_presets (id,name,duration_value,duration_unit,is_default) VALUES (?,?,?,?,?)");
for(const [name,value,unit,isDefault] of [["No Warranty",0,"days",1],["30 Days",30,"days",0],["60 Days",60,"days",0],["90 Days",90,"days",0],["1 Year",1,"years",0]])addWarranty.run(crypto.randomUUID(),name,value,unit,isDefault);
db.prepare("UPDATE products SET fulfillment_method='Shipped' WHERE fulfillment_method='' AND status='sold' AND (ship_address1!='' OR ship_city!='' OR ship_zip!='')").run();
const setting=db.prepare("INSERT OR IGNORE INTO app_settings (key,value) VALUES (?,?)");for(const [key,value] of [["schema_version",String(SCHEMA_VERSION)],["backup_enabled","0"],["backup_frequency","daily"],["backup_weekday","0"],["backup_hour","2"],["backup_retention","14"],["last_scheduled_backup",""]])setting.run(key,value);
db.prepare("UPDATE app_settings SET value=? WHERE key='schema_version'").run(String(SCHEMA_VERSION));
} }
const hashPassword=password=>{const salt=randomBytes(16).toString("hex");return `scrypt$${salt}$${scryptSync(password,salt,64).toString("hex")}`}; const hashPassword=password=>{const salt=randomBytes(16).toString("hex");return `scrypt$${salt}$${scryptSync(password,salt,64).toString("hex")}`};
function verifyPassword(password,stored){try{const[kind,salt,hash]=stored.split("$");if(kind!=="scrypt")return false;const actual=scryptSync(password,salt,64),expected=Buffer.from(hash,"hex");return actual.length===expected.length&&timingSafeEqual(actual,expected)}catch{return false}} function verifyPassword(password,stored){try{const[kind,salt,hash]=stored.split("$");if(kind!=="scrypt")return false;const actual=scryptSync(password,salt,64),expected=Buffer.from(hash,"hex");return actual.length===expected.length&&timingSafeEqual(actual,expected)}catch{return false}}
@@ -43,12 +53,12 @@ function cleanUsername(value){const name=String(value||"").trim();if(!/^[a-zA-Z0
initializeDatabase(); initializeDatabase();
try{const event=JSON.parse(await readFile(restoreMarker,"utf8"));db.prepare("INSERT INTO audit_log (username,action,target,details,ip_address) VALUES (?,'database_restore',?,?,?)").run(event.username||"system",event.target||"",event.details||"Restored database",event.ipAddress||"");await unlink(restoreMarker)}catch(error){if(error.code!=="ENOENT")console.error("Unable to import restore audit event:",error.message)} try{const event=JSON.parse(await readFile(restoreMarker,"utf8"));db.prepare("INSERT INTO audit_log (username,action,target,details,ip_address) VALUES (?,'database_restore',?,?,?)").run(event.username||"system",event.target||"",event.details||"Restored database",event.ipAddress||"");await unlink(restoreMarker)}catch(error){if(error.code!=="ENOENT")console.error("Unable to import restore audit event:",error.message)}
if(db.prepare("SELECT COUNT(*) count FROM users").get().count===0){db.prepare("INSERT INTO users (id,username,password_hash,role,must_change_password) VALUES (?,?,?,?,1)").run(crypto.randomUUID(),"admin",hashPassword("admin"),"admin");db.prepare("INSERT INTO audit_log (action,target,details) VALUES ('bootstrap_admin','admin','Default administrator created; password change required')").run()} if(db.prepare("SELECT COUNT(*) count FROM users").get().count===0){db.prepare("INSERT INTO users (id,username,password_hash,role,must_change_password) VALUES (?,?,?,?,1)").run(crypto.randomUUID(),"admin",hashPassword("admin"),"admin");db.prepare("INSERT INTO audit_log (action,target,details) VALUES ('bootstrap_admin','admin','Default administrator created; password change required')").run()}
const findCustomerByName=db.prepare("SELECT id FROM customers WHERE lower(name)=lower(?) ORDER BY updated_at DESC LIMIT 1"),addCustomer=db.prepare("INSERT INTO customers (id,name,phone,address1,address2,city,state,zip,shipping_notes) VALUES (?,?,?,?,?,?,?,?,?)"); const normalizePhone=value=>String(value||"").replace(/\D/g,"").slice(-10),findCustomerByName=db.prepare("SELECT id,name,phone FROM customers WHERE lower(trim(name))=lower(trim(?)) ORDER BY updated_at DESC LIMIT 1"),addCustomer=db.prepare("INSERT INTO customers (id,name,phone,address1,address2,city,state,zip,shipping_notes) VALUES (?,?,?,?,?,?,?,?,?)");
for(const old of db.prepare("SELECT DISTINCT customer_name name,phone FROM products WHERE status='sold' AND customer_name IS NOT NULL AND customer_name!='' AND customer_id IS NULL").all()){let customer=findCustomerByName.get(old.name);if(!customer){const id=crypto.randomUUID();addCustomer.run(id,old.name,old.phone||"","","","","","","");customer={id}}db.prepare("UPDATE products SET customer_id=? WHERE status='sold' AND customer_id IS NULL AND lower(customer_name)=lower(?)").run(customer.id,old.name)} for(const old of db.prepare("SELECT DISTINCT customer_name name,phone FROM products WHERE status='sold' AND customer_name IS NOT NULL AND customer_name!='' AND customer_id IS NULL").all()){let customer=findCustomerByName.get(old.name);if(!customer){const id=crypto.randomUUID();addCustomer.run(id,old.name,old.phone||"","","","","","","");customer={id}}db.prepare("UPDATE products SET customer_id=? WHERE status='sold' AND customer_id IS NULL AND lower(customer_name)=lower(?)").run(customer.id,old.name)}
db.exec("PRAGMA optimize"); db.exec("PRAGMA optimize");
if(process.argv[2]==="reset-admin"){const username=cleanUsername(process.argv[3]||"admin"),password=validPassword(process.argv[4]||process.env.RESET_ADMIN_PASSWORD||""),existing=db.prepare("SELECT id FROM users WHERE username=? COLLATE NOCASE").get(username);if(existing)db.prepare("UPDATE users SET password_hash=?,role='admin',enabled=1,must_change_password=1,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(hashPassword(password),existing.id);else db.prepare("INSERT INTO users (id,username,password_hash,role,enabled,must_change_password) VALUES (?,?,?,'admin',1,1)").run(crypto.randomUUID(),username,hashPassword(password));db.prepare("INSERT INTO audit_log (username,action,target,details) VALUES ('system','emergency_admin_reset',?,'Console reset; password change required')").run(username);console.log(`Administrator ${username} reset. Password change required at next login.`);db.close();process.exit(0)} if(process.argv[2]==="reset-admin"){const username=cleanUsername(process.argv[3]||"admin"),password=validPassword(process.argv[4]||process.env.RESET_ADMIN_PASSWORD||""),existing=db.prepare("SELECT id FROM users WHERE username=? COLLATE NOCASE").get(username);if(existing)db.prepare("UPDATE users SET password_hash=?,role='admin',enabled=1,must_change_password=1,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(hashPassword(password),existing.id);else db.prepare("INSERT INTO users (id,username,password_hash,role,enabled,must_change_password) VALUES (?,?,?,'admin',1,1)").run(crypto.randomUUID(),username,hashPassword(password));db.prepare("INSERT INTO audit_log (username,action,target,details) VALUES ('system','emergency_admin_reset',?,'Console reset; password change required')").run(username);console.log(`Administrator ${username} reset. Password change required at next login.`);db.close();process.exit(0)}
const columns="id,uid,sn,mac,manufacturer,model,condition,received_at AS receivedAt,cost,notes,status,sold_at AS soldAt,customer_id AS customerId,customer_name AS customerName,phone,sale_price AS salePrice,ship_address1 AS shipAddress1,ship_address2 AS shipAddress2,ship_city AS shipCity,ship_state AS shipState,ship_zip AS shipZip,shipping_notes AS shippingNotes,payment_method AS paymentMethod,payment_reference AS paymentReference,sale_notes AS saleNotes"; const columns="id,uid,sn,mac,manufacturer,model,condition,received_at AS receivedAt,cost,notes,status,sold_at AS soldAt,customer_id AS customerId,customer_name AS customerName,phone,sale_price AS salePrice,ship_address1 AS shipAddress1,ship_address2 AS shipAddress2,ship_city AS shipCity,ship_state AS shipState,ship_zip AS shipZip,shipping_notes AS fulfillmentNotes,shipping_notes AS shippingNotes,payment_method AS paymentMethod,payment_reference AS paymentReference,sale_notes AS saleNotes,fulfillment_method AS fulfillmentMethod,fulfillment_name AS fulfillmentName,carrier,tracking_number AS trackingNumber,delivery_status AS deliveryStatus,delivery_status_updated_at AS deliveryStatusUpdatedAt,warranty_preset_id AS warrantyPresetId,warranty_name AS warrantyName,warranty_end_date AS warrantyEndDate";
const allowedConditions=new Set(["New","Used","Refurbished"]); const allowedConditions=new Set(["New","Used","Refurbished"]);
const getProduct=()=>db.prepare(`SELECT ${columns} FROM products WHERE id=?`),listProducts=()=>db.prepare(`SELECT ${columns} FROM products ORDER BY CASE WHEN status='available' THEN received_at ELSE sold_at END DESC,rowid DESC`); const getProduct=()=>db.prepare(`SELECT ${columns} FROM products WHERE id=?`),listProducts=()=>db.prepare(`SELECT ${columns} FROM products ORDER BY CASE WHEN status='available' THEN received_at ELSE sold_at END DESC,rowid DESC`);
function json(res,status,value,headers={}){const b=JSON.stringify(value);res.writeHead(status,{"content-type":"application/json","content-length":Buffer.byteLength(b),...headers});res.end(b)} function json(res,status,value,headers={}){const b=JSON.stringify(value);res.writeHead(status,{"content-type":"application/json","content-length":Buffer.byteLength(b),...headers});res.end(b)}
@@ -65,14 +75,43 @@ function requireOrigin(req){if(["GET","HEAD","OPTIONS"].includes(req.method))ret
function authorize(req,url){if(url.pathname==="/api/health"||url.pathname==="/api/auth/login")return null;const session=currentSession(req);if(!session)throw Object.assign(new Error("Authentication required."),{status:401,code:"AUTH_REQUIRED"});if(session.user.mustChangePassword&&!new Set(["/api/auth/me","/api/auth/change-password","/api/auth/logout"]).has(url.pathname))throw Object.assign(new Error("Password change required."),{status:403,code:"PASSWORD_CHANGE_REQUIRED"});if(url.pathname.startsWith("/api/admin/")&&session.user.role!=="admin")throw Object.assign(new Error("Administrator access required."),{status:403});if(req.method!=="GET"&&session.user.role!=="admin"&&!url.pathname.startsWith("/api/auth/"))throw Object.assign(new Error("This account is read-only."),{status:403});return session} function authorize(req,url){if(url.pathname==="/api/health"||url.pathname==="/api/auth/login")return null;const session=currentSession(req);if(!session)throw Object.assign(new Error("Authentication required."),{status:401,code:"AUTH_REQUIRED"});if(session.user.mustChangePassword&&!new Set(["/api/auth/me","/api/auth/change-password","/api/auth/logout"]).has(url.pathname))throw Object.assign(new Error("Password change required."),{status:403,code:"PASSWORD_CHANGE_REQUIRED"});if(url.pathname.startsWith("/api/admin/")&&session.user.role!=="admin")throw Object.assign(new Error("Administrator access required."),{status:403});if(req.method!=="GET"&&session.user.role!=="admin"&&!url.pathname.startsWith("/api/auth/"))throw Object.assign(new Error("This account is read-only."),{status:403});return session}
const backupName=(prefix="vboxstock")=>`${prefix}-${new Date().toISOString().replace(/[:.]/g,"-")}.db`; const backupName=(prefix="vboxstock")=>`${prefix}-${new Date().toISOString().replace(/[:.]/g,"-")}.db`;
async function createBackup(prefix){const name=backupName(prefix),path=join(backupDir,name);await backup(db,path);return name} async function createBackup(prefix){const name=backupName(prefix),path=join(backupDir,name);await backup(db,path);return name}
const setting=key=>db.prepare("SELECT value FROM app_settings WHERE key=?").get(key)?.value||"";
function saveSetting(key,value){db.prepare("INSERT INTO app_settings (key,value,updated_at) VALUES (?,?,CURRENT_TIMESTAMP) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_at=CURRENT_TIMESTAMP").run(key,String(value))}
function nextAutomaticBackup(){if(setting("backup_enabled")!=="1")return null;const now=new Date(),next=new Date(now),hour=Math.max(0,Math.min(23,Number(setting("backup_hour"))||0)),frequency=setting("backup_frequency")==="weekly"?"weekly":"daily",weekday=Math.max(0,Math.min(6,Number(setting("backup_weekday"))||0));next.setHours(hour,0,0,0);if(frequency==="daily"){if(next<=now)next.setDate(next.getDate()+1)}else{let days=(weekday-next.getDay()+7)%7;if(days===0&&next<=now)days=7;next.setDate(next.getDate()+days)}return next.toISOString()}
async function backupFiles(){const files=await readdir(backupDir,{withFileTypes:true}),result=[];for(const file of files)if(file.isFile()&&file.name.endsWith(".db")){const info=await stat(join(backupDir,file.name));result.push({name:file.name,size:info.size,createdAt:info.mtime.toISOString()})}return result.sort((a,b)=>b.createdAt.localeCompare(a.createdAt))}
async function pruneScheduledBackups(){const keep=Math.max(1,Math.min(365,Number(setting("backup_retention"))||14)),files=(await backupFiles()).filter(x=>x.name.startsWith("automatic-")||x.name.startsWith("scheduled-"));for(const file of files.slice(keep))await unlink(join(backupDir,file.name)).catch(()=>{})}
async function runScheduledBackup(){if(setting("backup_enabled")!=="1")return;const now=new Date(),day=now.toLocaleDateString("en-CA"),hour=Math.max(0,Math.min(23,Number(setting("backup_hour"))||0)),frequency=setting("backup_frequency")==="weekly"?"weekly":"daily",weekday=Math.max(0,Math.min(6,Number(setting("backup_weekday"))||0));if(now.getHours()<hour||setting("last_scheduled_backup")===day||(frequency==="weekly"&&now.getDay()!==weekday))return;const name=await createBackup("automatic");saveSetting("last_scheduled_backup",day);db.prepare("INSERT INTO audit_log (username,action,target,details) VALUES ('system','scheduled_backup_created',?,'Automatic scheduled backup')").run(name);await pruneScheduledBackups()}
setInterval(()=>runScheduledBackup().catch(error=>console.error("Scheduled backup failed:",error.message)),15*60*1000).unref();setTimeout(()=>runScheduledBackup().catch(error=>console.error("Scheduled backup failed:",error.message)),1000).unref();
function safeBackup(name){if(!/^[a-zA-Z0-9._-]+\.db$/.test(name))throw new Error("Invalid backup name");return join(backupDir,name)} function safeBackup(name){if(!/^[a-zA-Z0-9._-]+\.db$/.test(name))throw new Error("Invalid backup name");return join(backupDir,name)}
const csvCell=value=>`"${String(value??"").replaceAll('"','""')}"`,csv=(headers,rows)=>[headers.map(csvCell).join(","),...rows.map(row=>row.map(csvCell).join(","))].join("\r\n")+"\r\n";
function csvResponse(res,name,headers,rows){const content=csv(headers,rows);res.writeHead(200,{"content-type":"text/csv; charset=utf-8","content-disposition":`attachment; filename="${name}"`});res.end(content)}
function validateBackup(path){const candidate=new DatabaseSync(path,{readOnly:true});try{const tables=new Set(candidate.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(x=>x.name));if(!tables.has("products")||!tables.has("customers"))throw new Error("This is not a valid vBoxStock database.");const integrity=candidate.prepare("PRAGMA integrity_check").get();if(Object.values(integrity)[0]!=="ok")throw new Error("The backup failed its integrity check.")}finally{candidate.close()}} function validateBackup(path){const candidate=new DatabaseSync(path,{readOnly:true});try{const tables=new Set(candidate.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(x=>x.name));if(!tables.has("products")||!tables.has("customers"))throw new Error("This is not a valid vBoxStock database.");const integrity=candidate.prepare("PRAGMA integrity_check").get();if(Object.values(integrity)[0]!=="ok")throw new Error("The backup failed its integrity check.")}finally{candidate.close()}}
async function restoreFrom(path,res,req,user){validateBackup(path);const target=path.split("/").pop();await createBackup("pre-restore");audit(req,user,"database_restore",target);db.exec("PRAGMA wal_checkpoint(TRUNCATE)");db.close();await copyFile(path,databasePath);await unlink(`${databasePath}-wal`).catch(()=>{});await unlink(`${databasePath}-shm`).catch(()=>{});await writeFile(restoreMarker,JSON.stringify({username:user.username,target,details:"Database restored; all sessions invalidated",ipAddress:clientIp(req)}));sessions.clear();json(res,200,{ok:true,restarting:true});setTimeout(()=>process.exit(0),250)} async function restoreFrom(path,res,req,user){validateBackup(path);const target=path.split("/").pop();await createBackup("pre-restore");audit(req,user,"database_restore",target);db.exec("PRAGMA wal_checkpoint(TRUNCATE)");db.close();await copyFile(path,databasePath);await unlink(`${databasePath}-wal`).catch(()=>{});await unlink(`${databasePath}-shm`).catch(()=>{});await writeFile(restoreMarker,JSON.stringify({username:user.username,target,details:"Database restored; all sessions invalidated",ipAddress:clientIp(req)}));sessions.clear();json(res,200,{ok:true,restarting:true});setTimeout(()=>process.exit(0),250)}
function confirmPassword(user,password){const record=db.prepare("SELECT password_hash FROM users WHERE id=?").get(user.id);if(!record||!verifyPassword(String(password||""),record.password_hash))throw Object.assign(new Error("Current password is incorrect."),{status:403})} function confirmPassword(user,password){const record=db.prepare("SELECT password_hash FROM users WHERE id=?").get(user.id);if(!record||!verifyPassword(String(password||""),record.password_hash))throw Object.assign(new Error("Current password is incorrect."),{status:403})}
function cleanModelName(value){const name=String(value||"").trim().replace(/\s+/g," ");if(!name||name.length>60||/[\u0000-\u001f\u007f]/.test(name))throw new Error("Model name must contain 160 characters.");return name} function cleanModelName(value){const name=String(value||"").trim().replace(/\s+/g," ");if(!name||name.length>60||/[\u0000-\u001f\u007f]/.test(name))throw new Error("Model name must contain 160 characters.");return name}
function productInput(v){const requested=String(v.model||"").trim(),modelRecord=db.prepare("SELECT name FROM product_models WHERE name=? COLLATE NOCASE AND active=1").get(requested),condition=String(v.condition||"");if(!modelRecord||!allowedConditions.has(condition)||!v.receivedAt)throw new Error("An active model, condition, and received date are required.");return{uid:String(v.uid||"").trim(),sn:String(v.sn||"").trim(),mac:String(v.mac||"").trim(),model:modelRecord.name,condition,receivedAt:String(v.receivedAt),cost:Number(v.cost)||0,notes:String(v.notes||"").trim()}} function productInput(v){const requested=String(v.model||"").trim(),modelRecord=db.prepare("SELECT name FROM product_models WHERE name=? COLLATE NOCASE AND active=1").get(requested),condition=String(v.condition||"");if(!modelRecord||!allowedConditions.has(condition)||!v.receivedAt)throw new Error("An active model, condition, and received date are required.");return{uid:String(v.uid||"").trim(),sn:String(v.sn||"").trim(),mac:String(v.mac||"").trim(),model:modelRecord.name,condition,receivedAt:String(v.receivedAt),cost:Number(v.cost)||0,notes:String(v.notes||"").trim()}}
function receiveBatch(req,user,v){const rows=Array.isArray(v.items)?v.items:[],shared={...v};delete shared.items;if(!rows.length)throw new Error("Add at least one scanned device.");if(rows.length>100)throw new Error("A batch can contain no more than 100 devices.");const products=rows.map(row=>productInput({...shared,...row}));const seen=new Set();for(const p of products){for(const key of [p.uid,p.sn,p.mac].filter(Boolean)){const normalized=key.toLowerCase();if(seen.has(normalized)||db.prepare("SELECT 1 FROM products WHERE lower(uid)=? OR lower(sn)=? OR lower(mac)=? LIMIT 1").get(normalized,normalized,normalized))throw new Error(`Duplicate identifier detected: ${key}`);seen.add(normalized)}}const ids=[];db.exec("BEGIN IMMEDIATE");try{const insert=db.prepare("INSERT INTO products (id,uid,sn,mac,model,condition,received_at,cost,notes) VALUES (?,?,?,?,?,?,?,?,?)");for(const p of products){const id=crypto.randomUUID();insert.run(id,p.uid,p.sn,p.mac,p.model,p.condition,p.receivedAt,p.cost,p.notes);audit(req,user,"product_received",id,`${p.model}; batch of ${products.length}`);ids.push(id)}db.exec("COMMIT")}catch(error){db.exec("ROLLBACK");throw error}return{count:ids.length,products:ids.map(id=>getProduct().get(id))}}
function modelUsage(id){return db.prepare("SELECT m.id,m.name,m.active,COUNT(p.id) totalCount,COALESCE(SUM(CASE WHEN p.status='available' THEN 1 ELSE 0 END),0) availableCount,COALESCE(SUM(CASE WHEN p.status='sold' THEN 1 ELSE 0 END),0) soldCount FROM product_models m LEFT JOIN products p ON p.model=m.name COLLATE NOCASE WHERE m.id=? GROUP BY m.id").get(id)} function modelUsage(id){return db.prepare("SELECT m.id,m.name,m.active,COUNT(p.id) totalCount,COALESCE(SUM(CASE WHEN p.status='available' THEN 1 ELSE 0 END),0) availableCount,COALESCE(SUM(CASE WHEN p.status='sold' THEN 1 ELSE 0 END),0) soldCount FROM product_models m LEFT JOIN products p ON p.model=m.name COLLATE NOCASE WHERE m.id=? GROUP BY m.id").get(id)}
function allModels(){return db.prepare("SELECT m.id,m.name,m.active,m.created_at AS createdAt,m.updated_at AS updatedAt,COUNT(p.id) totalCount,COALESCE(SUM(CASE WHEN p.status='available' THEN 1 ELSE 0 END),0) availableCount,COALESCE(SUM(CASE WHEN p.status='sold' THEN 1 ELSE 0 END),0) soldCount FROM product_models m LEFT JOIN products p ON p.model=m.name COLLATE NOCASE GROUP BY m.id ORDER BY lower(m.name)").all().map(x=>({...x,active:Boolean(x.active)}))} function allModels(){return db.prepare("SELECT m.id,m.name,m.active,m.created_at AS createdAt,m.updated_at AS updatedAt,COUNT(p.id) totalCount,COALESCE(SUM(CASE WHEN p.status='available' THEN 1 ELSE 0 END),0) availableCount,COALESCE(SUM(CASE WHEN p.status='sold' THEN 1 ELSE 0 END),0) soldCount FROM product_models m LEFT JOIN products p ON p.model=m.name COLLATE NOCASE GROUP BY m.id ORDER BY lower(m.name)").all().map(x=>({...x,active:Boolean(x.active)}))}
function cleanWarrantyName(value){const name=String(value||"").trim().replace(/\s+/g," ");if(!name||name.length>60)throw new Error("Warranty name must contain 160 characters.");return name}
function warrantyUsage(id){return db.prepare("SELECT w.*,COUNT(p.id) usageCount FROM warranty_presets w LEFT JOIN products p ON p.warranty_preset_id=w.id WHERE w.id=? GROUP BY w.id").get(id)}
function allWarranties(){return db.prepare("SELECT w.id,w.name,w.duration_value AS durationValue,w.duration_unit AS durationUnit,w.active,w.is_default AS isDefault,COUNT(p.id) usageCount FROM warranty_presets w LEFT JOIN products p ON p.warranty_preset_id=w.id GROUP BY w.id ORDER BY w.duration_value,w.name").all().map(x=>({...x,active:Boolean(x.active),isDefault:Boolean(x.isDefault)}))}
function warrantyEnd(start,preset){if(!preset||!preset.duration_value)return null;const [y,m,d]=String(start).split("-").map(Number),date=new Date(Date.UTC(y,m-1,d));if(preset.duration_unit==="years")date.setUTCFullYear(date.getUTCFullYear()+preset.duration_value);else if(preset.duration_unit==="months")date.setUTCMonth(date.getUTCMonth()+preset.duration_value);else date.setUTCDate(date.getUTCDate()+preset.duration_value);return date.toISOString().slice(0,10)}
const fulfillmentMethods=new Set(["Shipped","Dropped Off","Installed At","Meet"]),carriers=new Set(["UPS","FedEx","USPS","Other"]),deliveryStatuses=new Set(["Awaiting Tracking","Label Created","In Transit","Out for Delivery","Delivered","Delivery Exception","Returned","Unknown"]);
function saleDetails(v,current={}){const method=String(v.fulfillmentMethod??current.fulfillmentMethod??"").trim();if(!fulfillmentMethods.has(method))throw new Error("Delivery method is required.");const address1=String(v.shipAddress1??current.shipAddress1??"").trim(),address2=String(v.shipAddress2??current.shipAddress2??"").trim(),city=String(v.shipCity??current.shipCity??"").trim(),state=String(v.shipState??current.shipState??"").trim(),zip=String(v.shipZip??current.shipZip??"").trim(),name=String(v.fulfillmentName??current.fulfillmentName??"").trim(),notes=String(v.fulfillmentNotes??current.fulfillmentNotes??"").trim();if(new Set(["Shipped","Installed At"]).has(method)&&(!address1||!city||!state||!zip))throw new Error(`${method} requires a street address, city, state, and ZIP code.`);if(new Set(["Dropped Off","Meet"]).has(method)&&!name&&!address1&&!notes)throw new Error(`${method} requires a venue, address, or fulfillment detail.`);let carrier="",trackingNumber="",deliveryStatus="";if(method==="Shipped"){carrier=String(v.carrier??current.carrier??"").trim();if(!carriers.has(carrier))throw new Error("Carrier is required for shipped products.");trackingNumber=String(v.trackingNumber??current.trackingNumber??"").trim();deliveryStatus=String(v.deliveryStatus??(current.deliveryStatus||(trackingNumber?"Label Created":"Awaiting Tracking")));if(!deliveryStatuses.has(deliveryStatus))throw new Error("Invalid delivery status.")}return{method,name,address1,address2,city,state,zip,notes,carrier,trackingNumber,deliveryStatus}}
function resolveCustomer(v){const id=String(v.customerId||"").trim();if(id){const customer=db.prepare("SELECT id,name,phone FROM customers WHERE id=?").get(id);if(!customer)throw new Error("Selected customer no longer exists.");return customer}const phone=normalizePhone(v.phone);if(phone){const match=db.prepare("SELECT id,name,phone FROM customers").all().find(x=>normalizePhone(x.phone)===phone);if(match)return match}return findCustomerByName.get(String(v.customerName||"").trim())}
function recordSales(req,user,v,rawItems){
const items=Array.isArray(rawItems)?rawItems.map(x=>({productId:String(x?.productId||"").trim(),salePrice:x?.salePrice===""||x?.salePrice==null?0:Number(x.salePrice)})):[];
if(!items.length)throw new Error("Select at least one product.");if(items.length>100)throw new Error("A sale can contain no more than 100 products.");
if(items.some(x=>!x.productId)||new Set(items.map(x=>x.productId)).size!==items.length)throw new Error("Each selected product must be unique.");if(items.some(x=>!Number.isFinite(x.salePrice)||x.salePrice<0))throw new Error("Sale prices must be zero or greater.");
const name=String(v.customerName||"").trim(),phone=String(v.phone||"").trim(),soldAt=String(v.soldAt||"");if(!name||!soldAt)throw new Error("Customer name and sale date are required.");
const paymentMethod=String(v.paymentMethod||"").trim();if(!new Set(["Cash","Venmo","PayPal"]).has(paymentMethod))throw new Error("Payment method must be Cash, Venmo, or PayPal.");
const f=saleDetails(v),preset=v.warrantyPresetId?db.prepare("SELECT id,name,duration_value,duration_unit FROM warranty_presets WHERE id=? AND active=1").get(String(v.warrantyPresetId)):db.prepare("SELECT id,name,duration_value,duration_unit FROM warranty_presets WHERE active=1 AND is_default=1").get();if(!preset)throw new Error("Select an active warranty period.");
for(const item of items){const product=getProduct().get(item.productId);if(!product)throw new Error("One of the selected products no longer exists.");if(product.status!=="available")throw new Error(`${product.model} (${product.uid||product.sn||product.mac||product.id}) is no longer available.`)}
let customer=resolveCustomer(v);db.exec("BEGIN IMMEDIATE");try{if(!customer){customer={id:crypto.randomUUID()};addCustomer.run(customer.id,name,phone,"","","","","","")}saveCustomer(customer.id,name,phone,f);const update=db.prepare("UPDATE products SET status='sold',sold_at=?,customer_id=?,customer_name=?,phone=?,sale_price=?,ship_address1=?,ship_address2=?,ship_city=?,ship_state=?,ship_zip=?,shipping_notes=?,payment_method=?,payment_reference=?,sale_notes=?,fulfillment_method=?,fulfillment_name=?,carrier=?,tracking_number=?,delivery_status=?,delivery_status_updated_at=CURRENT_TIMESTAMP,warranty_preset_id=?,warranty_name=?,warranty_end_date=? WHERE id=? AND status='available'");for(const item of items){const result=update.run(soldAt,customer.id,name,phone,item.salePrice,f.address1,f.address2,f.city,f.state,f.zip,f.notes,paymentMethod,String(v.paymentReference||"").trim(),String(v.saleNotes||"").trim(),f.method,f.name,f.carrier,f.trackingNumber,f.deliveryStatus,preset.id,preset.name,warrantyEnd(soldAt,preset),item.productId);if(result.changes!==1)throw new Error("Inventory changed while the sale was being recorded. No products were sold.");audit(req,user,"sale_recorded",item.productId,`${name}; ${f.method}; ${preset.name}; ${items.length}-item transaction`)}db.exec("COMMIT")}catch(error){db.exec("ROLLBACK");throw error}
const products=items.map(x=>getProduct().get(x.productId));return{products,count:products.length,total:items.reduce((sum,x)=>sum+x.salePrice,0)};
}
function saveCustomer(customerId,name,phone,f){const storeAddress=new Set(["Shipped","Installed At"]).has(f.method);if(storeAddress)db.prepare("UPDATE customers SET name=?,phone=?,address1=?,address2=?,city=?,state=?,zip=?,shipping_notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(name,phone,f.address1,f.address2,f.city,f.state,f.zip,f.notes,customerId);else db.prepare("UPDATE customers SET name=?,phone=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(name,phone,customerId)}
async function authApi(req,res,url,session){ async function authApi(req,res,url,session){
if(url.pathname==="/api/auth/login"&&req.method==="POST"){const v=await body(req),username=String(v.username||"").trim(),key=`${clientIp(req)}|${username.toLowerCase()}`,failure=loginFailures.get(key);if(failure&&failure.count>=5&&failure.until>Date.now())throw Object.assign(new Error("Too many failed attempts. Try again in 15 minutes."),{status:429});const user=db.prepare("SELECT id,username,password_hash,role,enabled,must_change_password AS mustChangePassword FROM users WHERE username=? COLLATE NOCASE").get(username);if(!user?.enabled||!verifyPassword(String(v.password||""),user.password_hash)){loginFailures.set(key,{count:(failure?.count||0)+1,until:Date.now()+15*60*1000});audit(req,user,"login_failed",username);throw Object.assign(new Error("Invalid username or password."),{status:401})}loginFailures.delete(key);const token=randomBytes(32).toString("base64url");sessions.set(tokenKey(token),{userId:user.id,lastSeen:Date.now()});db.prepare("UPDATE users SET last_login_at=CURRENT_TIMESTAMP WHERE id=?").run(user.id);audit(req,user,"login_success");return json(res,200,{username:user.username,role:user.role,mustChangePassword:Boolean(user.mustChangePassword)},{"set-cookie":sessionCookie(req,token)})} if(url.pathname==="/api/auth/login"&&req.method==="POST"){const v=await body(req),username=String(v.username||"").trim(),key=`${clientIp(req)}|${username.toLowerCase()}`,failure=loginFailures.get(key);if(failure&&failure.count>=5&&failure.until>Date.now())throw Object.assign(new Error("Too many failed attempts. Try again in 15 minutes."),{status:429});const user=db.prepare("SELECT id,username,password_hash,role,enabled,must_change_password AS mustChangePassword FROM users WHERE username=? COLLATE NOCASE").get(username);if(!user?.enabled||!verifyPassword(String(v.password||""),user.password_hash)){loginFailures.set(key,{count:(failure?.count||0)+1,until:Date.now()+15*60*1000});audit(req,user,"login_failed",username);throw Object.assign(new Error("Invalid username or password."),{status:401})}loginFailures.delete(key);const token=randomBytes(32).toString("base64url");sessions.set(tokenKey(token),{userId:user.id,lastSeen:Date.now()});db.prepare("UPDATE users SET last_login_at=CURRENT_TIMESTAMP WHERE id=?").run(user.id);audit(req,user,"login_success");return json(res,200,{username:user.username,role:user.role,mustChangePassword:Boolean(user.mustChangePassword)},{"set-cookie":sessionCookie(req,token)})}
@@ -84,6 +123,12 @@ async function authApi(req,res,url,session){
async function adminApi(req,res,url,session){ async function adminApi(req,res,url,session){
const user=session.user; const user=session.user;
if(url.pathname==="/api/admin/diagnostics"&&req.method==="GET"){const databaseInfo=await stat(databasePath),disk=await statfs(dataDir),backups=await backupFiles();let writable=true;try{const probe=join(dataDir,`.write-test-${crypto.randomUUID()}`);await writeFile(probe,"ok");await unlink(probe)}catch{writable=false}return json(res,200,{appVersion:APP_VERSION,schemaVersion:Number(setting("schema_version"))||SCHEMA_VERSION,nodeVersion:process.version,timeZone:Intl.DateTimeFormat().resolvedOptions().timeZone,dataDirectory:dataDir,dataWritable:writable,databaseSize:databaseInfo.size,backupCount:backups.length,lastBackupAt:backups[0]?.createdAt||null,diskFree:disk.bavail*disk.bsize,diskTotal:disk.blocks*disk.bsize,backupSettings:{enabled:setting("backup_enabled")==="1",frequency:setting("backup_frequency")==="weekly"?"weekly":"daily",weekday:Number(setting("backup_weekday"))||0,hour:Number(setting("backup_hour"))||0,retention:Number(setting("backup_retention"))||14,lastScheduledBackup:setting("last_scheduled_backup")||null,nextScheduledBackup:nextAutomaticBackup()}})}
if(url.pathname==="/api/admin/backup-settings"&&req.method==="PATCH"){const v=await body(req),enabled=Boolean(v.enabled),frequency=String(v.frequency||"daily"),weekday=Number(v.weekday),hour=Number(v.hour),retention=Number(v.retention);if(!new Set(["daily","weekly"]).has(frequency)||!Number.isInteger(weekday)||weekday<0||weekday>6||!Number.isInteger(hour)||hour<0||hour>23||!Number.isInteger(retention)||retention<1||retention>365)throw new Error("Choose a valid frequency, weekday, backup hour, and retention period.");saveSetting("backup_enabled",enabled?"1":"0");saveSetting("backup_frequency",frequency);saveSetting("backup_weekday",weekday);saveSetting("backup_hour",hour);saveSetting("backup_retention",retention);await pruneScheduledBackups();audit(req,user,"backup_schedule_updated","",`${enabled?"enabled":"disabled"}; ${frequency}${frequency==="weekly"?` day ${weekday}`:""}; ${hour}:00; retain ${retention}`);return json(res,200,{ok:true,nextScheduledBackup:nextAutomaticBackup()})}
if(url.pathname==="/api/admin/warranties"&&req.method==="GET")return json(res,200,allWarranties());
if(url.pathname==="/api/admin/warranties"&&req.method==="POST"){const v=await body(req),name=cleanWarrantyName(v.name),durationValue=Number(v.durationValue),durationUnit=String(v.durationUnit||"days");if(!Number.isInteger(durationValue)||durationValue<0||durationValue>3650||!new Set(["days","months","years"]).has(durationUnit))throw new Error("Enter a valid warranty duration.");const id=crypto.randomUUID();db.prepare("INSERT INTO warranty_presets (id,name,duration_value,duration_unit) VALUES (?,?,?,?)").run(id,name,durationValue,durationUnit);audit(req,user,"warranty_created",name,`${durationValue} ${durationUnit}`);return json(res,201,{id,name,durationValue,durationUnit,active:true,isDefault:false,usageCount:0})}
const warrantyMatch=url.pathname.match(/^\/api\/admin\/warranties\/([^/]+)$/);
if(warrantyMatch){const id=decodeURIComponent(warrantyMatch[1]),target=warrantyUsage(id);if(!target)return json(res,404,{error:"Warranty period not found"});if(req.method==="PATCH"){const v=await body(req);if(Object.hasOwn(v,"isDefault")&&v.isDefault){db.exec("UPDATE warranty_presets SET is_default=0");db.prepare("UPDATE warranty_presets SET is_default=1,active=1,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(id);audit(req,user,"warranty_default_changed",target.name);return json(res,200,{ok:true})}if(Object.hasOwn(v,"active")){const active=Boolean(v.active);if(target.is_default&&!active)throw new Error("Choose another default warranty before archiving this one.");db.prepare("UPDATE warranty_presets SET active=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(active?1:0,id);audit(req,user,active?"warranty_reactivated":"warranty_archived",target.name);return json(res,200,{ok:true})}if(target.usageCount)throw new Error("A warranty period used by a sale cannot be changed. Archive it and create a new period instead.");const name=cleanWarrantyName(v.name??target.name),durationValue=Number(v.durationValue??target.duration_value),durationUnit=String(v.durationUnit??target.duration_unit);if(!Number.isInteger(durationValue)||durationValue<0||durationValue>3650||!new Set(["days","months","years"]).has(durationUnit))throw new Error("Enter a valid warranty duration.");db.prepare("UPDATE warranty_presets SET name=?,duration_value=?,duration_unit=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(name,durationValue,durationUnit,id);audit(req,user,"warranty_updated",target.name,`${name}; ${durationValue} ${durationUnit}`);return json(res,200,{ok:true})}if(req.method==="DELETE"){if(target.usageCount)throw new Error("A warranty period used by a sale cannot be deleted. Archive it instead.");if(target.is_default)throw new Error("The default warranty cannot be deleted.");db.prepare("DELETE FROM warranty_presets WHERE id=?").run(id);audit(req,user,"warranty_deleted",target.name);return json(res,204,null)}}
if(url.pathname==="/api/admin/models"&&req.method==="GET")return json(res,200,allModels()); if(url.pathname==="/api/admin/models"&&req.method==="GET")return json(res,200,allModels());
if(url.pathname==="/api/admin/models"&&req.method==="POST"){const v=await body(req),name=cleanModelName(v.name),id=crypto.randomUUID();db.prepare("INSERT INTO product_models (id,name) VALUES (?,?)").run(id,name);audit(req,user,"model_created",name);return json(res,201,{id,name,active:true,totalCount:0,availableCount:0,soldCount:0})} if(url.pathname==="/api/admin/models"&&req.method==="POST"){const v=await body(req),name=cleanModelName(v.name),id=crypto.randomUUID();db.prepare("INSERT INTO product_models (id,name) VALUES (?,?)").run(id,name);audit(req,user,"model_created",name);return json(res,201,{id,name,active:true,totalCount:0,availableCount:0,soldCount:0})}
const modelMatch=url.pathname.match(/^\/api\/admin\/models\/([^/]+)$/); const modelMatch=url.pathname.match(/^\/api\/admin\/models\/([^/]+)$/);
@@ -95,7 +140,7 @@ async function adminApi(req,res,url,session){
const resetMatch=url.pathname.match(/^\/api\/admin\/users\/([^/]+)\/reset-password$/); const resetMatch=url.pathname.match(/^\/api\/admin\/users\/([^/]+)\/reset-password$/);
if(resetMatch&&req.method==="POST"){const id=decodeURIComponent(resetMatch[1]),target=db.prepare("SELECT username FROM users WHERE id=?").get(id);if(!target)return json(res,404,{error:"User not found"});const v=await body(req);db.prepare("UPDATE users SET password_hash=?,must_change_password=1,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(hashPassword(validPassword(v.password)),id);invalidateUserSessions(id);audit(req,user,"password_reset",target.username);return json(res,200,{ok:true})} if(resetMatch&&req.method==="POST"){const id=decodeURIComponent(resetMatch[1]),target=db.prepare("SELECT username FROM users WHERE id=?").get(id);if(!target)return json(res,404,{error:"User not found"});const v=await body(req);db.prepare("UPDATE users SET password_hash=?,must_change_password=1,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(hashPassword(validPassword(v.password)),id);invalidateUserSessions(id);audit(req,user,"password_reset",target.username);return json(res,200,{ok:true})}
if(url.pathname==="/api/admin/audit"&&req.method==="GET")return json(res,200,db.prepare("SELECT id,username,action,target,details,ip_address AS ipAddress,created_at AS createdAt FROM audit_log ORDER BY id DESC LIMIT 250").all()); if(url.pathname==="/api/admin/audit"&&req.method==="GET")return json(res,200,db.prepare("SELECT id,username,action,target,details,ip_address AS ipAddress,created_at AS createdAt FROM audit_log ORDER BY id DESC LIMIT 250").all());
if(url.pathname==="/api/admin/backups"&&req.method==="GET"){const files=await readdir(backupDir,{withFileTypes:true}),result=[];for(const file of files)if(file.isFile()&&file.name.endsWith(".db")){const info=await stat(join(backupDir,file.name));result.push({name:file.name,size:info.size,createdAt:info.mtime.toISOString()})}return json(res,200,result.sort((a,b)=>b.createdAt.localeCompare(a.createdAt)))} if(url.pathname==="/api/admin/backups"&&req.method==="GET")return json(res,200,await backupFiles());
if(url.pathname==="/api/admin/backups"&&req.method==="POST"){const name=await createBackup("vboxstock");audit(req,user,"backup_created",name);return json(res,201,{name})} if(url.pathname==="/api/admin/backups"&&req.method==="POST"){const name=await createBackup("vboxstock");audit(req,user,"backup_created",name);return json(res,201,{name})}
if(url.pathname==="/api/admin/restore-upload"&&req.method==="POST"){confirmPassword(user,req.headers["x-confirm-password"]);const path=join(backupDir,`upload-${crypto.randomUUID()}.db`);await writeFile(path,await rawBody(req));return restoreFrom(path,res,req,user)} if(url.pathname==="/api/admin/restore-upload"&&req.method==="POST"){confirmPassword(user,req.headers["x-confirm-password"]);const path=join(backupDir,`upload-${crypto.randomUUID()}.db`);await writeFile(path,await rawBody(req));return restoreFrom(path,res,req,user)}
const backupMatch=url.pathname.match(/^\/api\/admin\/backups\/([^/]+)\/(download|restore)$/); const backupMatch=url.pathname.match(/^\/api\/admin\/backups\/([^/]+)\/(download|restore)$/);
@@ -110,17 +155,24 @@ async function api(req,res,url){
if(url.pathname.startsWith("/api/admin/")){const handled=await adminApi(req,res,url,session);if(handled!==false)return handled} if(url.pathname.startsWith("/api/admin/")){const handled=await adminApi(req,res,url,session);if(handled!==false)return handled}
const user=session.user; const user=session.user;
if(url.pathname==="/api/models"&&req.method==="GET")return json(res,200,db.prepare("SELECT id,name FROM product_models WHERE active=1 ORDER BY lower(name)").all()); if(url.pathname==="/api/models"&&req.method==="GET")return json(res,200,db.prepare("SELECT id,name FROM product_models WHERE active=1 ORDER BY lower(name)").all());
if(url.pathname==="/api/warranties"&&req.method==="GET")return json(res,200,db.prepare("SELECT id,name,duration_value AS durationValue,duration_unit AS durationUnit,is_default AS isDefault FROM warranty_presets WHERE active=1 ORDER BY duration_value,name").all().map(x=>({...x,isDefault:Boolean(x.isDefault)})));
if(url.pathname==="/api/customers"&&req.method==="GET")return json(res,200,db.prepare("SELECT id,name,phone,address1,address2,city,state,zip,shipping_notes AS shippingNotes,updated_at AS updatedAt FROM customers ORDER BY lower(name)").all());
const exportMatch=url.pathname.match(/^\/api\/export\/(inventory|sales|customers|warranties|audit)$/);if(exportMatch&&req.method==="GET"){const type=exportMatch[1];if(type==="inventory"){const rows=listProducts().all().filter(x=>x.status==="available");return csvResponse(res,"vboxstock-inventory.csv",["Model","UID","Serial Number","MAC","Condition","Received","Cost","Notes"],rows.map(x=>[x.model,x.uid,x.sn,x.mac,x.condition,x.receivedAt,x.cost,x.notes]))}if(type==="sales"){const rows=listProducts().all().filter(x=>x.status==="sold");return csvResponse(res,"vboxstock-sales.csv",["Model","UID","Serial Number","MAC","Customer","Phone","Date Sold","Sale Price","Payment Method","Payment Reference","Fulfillment","Carrier","Tracking Number","Delivery Status","Warranty","Warranty End","Sale Notes"],rows.map(x=>[x.model,x.uid,x.sn,x.mac,x.customerName,x.phone,x.soldAt,x.salePrice,x.paymentMethod,x.paymentReference,x.fulfillmentMethod,x.carrier,x.trackingNumber,x.deliveryStatus,x.warrantyName,x.warrantyEndDate,x.saleNotes]))}if(type==="customers"){const rows=db.prepare("SELECT name,phone,address1,address2,city,state,zip,shipping_notes FROM customers ORDER BY lower(name)").all();return csvResponse(res,"vboxstock-customers.csv",["Name","Phone","Address 1","Address 2","City","State","ZIP","Notes"],rows.map(Object.values))}if(type==="warranties"){const rows=allWarranties();return csvResponse(res,"vboxstock-warranties.csv",["Name","Duration","Unit","Active","Default","Sales"],rows.map(x=>[x.name,x.durationValue,x.durationUnit,x.active,x.isDefault,x.usageCount]))}if(type==="audit"){if(user.role!=="admin")throw Object.assign(new Error("Administrator access required."),{status:403});const rows=db.prepare("SELECT username,action,target,details,ip_address,created_at FROM audit_log ORDER BY id DESC").all();return csvResponse(res,"vboxstock-audit.csv",["Username","Action","Target","Details","IP Address","Created"],rows.map(Object.values))}}
if(url.pathname==="/api/products"&&req.method==="GET")return json(res,200,listProducts().all()); if(url.pathname==="/api/products"&&req.method==="GET")return json(res,200,listProducts().all());
if(url.pathname==="/api/products"&&req.method==="POST"){const p=productInput(await body(req)),id=crypto.randomUUID();db.prepare("INSERT INTO products (id,uid,sn,mac,model,condition,received_at,cost,notes) VALUES (?,?,?,?,?,?,?,?,?)").run(id,p.uid,p.sn,p.mac,p.model,p.condition,p.receivedAt,p.cost,p.notes);audit(req,user,"product_received",id,p.model);return json(res,201,getProduct().get(id))} if(url.pathname==="/api/products"&&req.method==="POST"){const p=productInput(await body(req)),id=crypto.randomUUID();db.prepare("INSERT INTO products (id,uid,sn,mac,model,condition,received_at,cost,notes) VALUES (?,?,?,?,?,?,?,?,?)").run(id,p.uid,p.sn,p.mac,p.model,p.condition,p.receivedAt,p.cost,p.notes);audit(req,user,"product_received",id,p.model);return json(res,201,getProduct().get(id))}
if(url.pathname==="/api/products/batch"&&req.method==="POST"){return json(res,201,receiveBatch(req,user,await body(req)))}
if(url.pathname==="/api/sales"&&req.method==="POST"){const v=await body(req);return json(res,201,recordSales(req,user,v,v.items))}
const customerMatch=url.pathname.match(/^\/api\/customers\/([^/]+)$/); const customerMatch=url.pathname.match(/^\/api\/customers\/([^/]+)$/);
if(customerMatch&&req.method==="GET"){const id=decodeURIComponent(customerMatch[1]),customer=db.prepare("SELECT id,name,phone,address1,address2,city,state,zip,shipping_notes AS shippingNotes FROM customers WHERE id=?").get(id);if(!customer)return json(res,404,{error:"Customer not found"});const purchases=db.prepare(`SELECT ${columns} FROM products WHERE status='sold' AND customer_id=? ORDER BY sold_at DESC,rowid DESC`).all(id),notes=db.prepare("SELECT id,category,note,created_at AS createdAt,updated_at AS updatedAt FROM customer_notes WHERE customer_id=? ORDER BY created_at DESC,rowid DESC").all(id);return json(res,200,{...customer,purchases,notes})} if(customerMatch&&req.method==="GET"){const id=decodeURIComponent(customerMatch[1]),customer=db.prepare("SELECT id,name,phone,address1,address2,city,state,zip,shipping_notes AS shippingNotes FROM customers WHERE id=?").get(id);if(!customer)return json(res,404,{error:"Customer not found"});const purchases=db.prepare(`SELECT ${columns} FROM products WHERE status='sold' AND customer_id=? ORDER BY sold_at DESC,rowid DESC`).all(id),notes=db.prepare("SELECT id,category,note,created_at AS createdAt,updated_at AS updatedAt FROM customer_notes WHERE customer_id=? ORDER BY created_at DESC,rowid DESC").all(id);return json(res,200,{...customer,purchases,notes})}
const notesMatch=url.pathname.match(/^\/api\/customers\/([^/]+)\/notes(?:\/([^/]+))?$/); const notesMatch=url.pathname.match(/^\/api\/customers\/([^/]+)\/notes(?:\/([^/]+))?$/);
if(notesMatch){const customerId=decodeURIComponent(notesMatch[1]),noteId=notesMatch[2]?decodeURIComponent(notesMatch[2]):null;if(!db.prepare("SELECT id FROM customers WHERE id=?").get(customerId))return json(res,404,{error:"Customer not found"});if(req.method==="POST"&&!noteId){const v=await body(req),note=String(v.note||"").trim(),category=String(v.category||"General");if(!note)throw new Error("Note text is required.");if(!new Set(["General","Support","Follow-up"]).has(category))throw new Error("Invalid note category.");const id=crypto.randomUUID();db.prepare("INSERT INTO customer_notes (id,customer_id,category,note) VALUES (?,?,?,?)").run(id,customerId,category,note);audit(req,user,"customer_note_added",customerId,category);return json(res,201,{id,category,note})}if(req.method==="DELETE"&&noteId){db.prepare("DELETE FROM customer_notes WHERE id=? AND customer_id=?").run(noteId,customerId);audit(req,user,"customer_note_deleted",customerId,noteId);return json(res,204,null)}} if(notesMatch){const customerId=decodeURIComponent(notesMatch[1]),noteId=notesMatch[2]?decodeURIComponent(notesMatch[2]):null;if(!db.prepare("SELECT id FROM customers WHERE id=?").get(customerId))return json(res,404,{error:"Customer not found"});if(req.method==="POST"&&!noteId){const v=await body(req),note=String(v.note||"").trim(),category=String(v.category||"General");if(!note)throw new Error("Note text is required.");if(!new Set(["General","Support","Follow-up"]).has(category))throw new Error("Invalid note category.");const id=crypto.randomUUID();db.prepare("INSERT INTO customer_notes (id,customer_id,category,note) VALUES (?,?,?,?)").run(id,customerId,category,note);audit(req,user,"customer_note_added",customerId,category);return json(res,201,{id,category,note})}if(req.method==="DELETE"&&noteId){db.prepare("DELETE FROM customer_notes WHERE id=? AND customer_id=?").run(noteId,customerId);audit(req,user,"customer_note_deleted",customerId,noteId);return json(res,204,null)}}
const match=url.pathname.match(/^\/api\/products\/([^/]+)(?:\/(sell|restock))?$/);if(!match)return json(res,404,{error:"Not found"});const id=decodeURIComponent(match[1]),action=match[2],current=getProduct().get(id);if(!current)return json(res,404,{error:"Product not found"}); const match=url.pathname.match(/^\/api\/products\/([^/]+)(?:\/(sell|restock))?$/);if(!match)return json(res,404,{error:"Not found"});const id=decodeURIComponent(match[1]),action=match[2],current=getProduct().get(id);if(!current)return json(res,404,{error:"Product not found"});
if(req.method==="DELETE"&&!action){db.prepare("DELETE FROM products WHERE id=?").run(id);audit(req,user,"record_deleted",id,current.status);return json(res,204,null)} if(req.method==="DELETE"&&!action){db.prepare("DELETE FROM products WHERE id=?").run(id);audit(req,user,"record_deleted",id,current.status);return json(res,204,null)}
if(req.method==="PATCH"&&!action){if(current.status!=="sold")return json(res,400,{error:"Transaction notes can only be added to sales."});const v=await body(req);db.prepare("UPDATE products SET sale_notes=? WHERE id=?").run(String(v.saleNotes||"").trim(),id);audit(req,user,"sale_notes_updated",id);return json(res,200,getProduct().get(id))} if(req.method==="PATCH"&&!action){
if(req.method==="POST"&&action==="sell"){const v=await body(req);if(!String(v.customerName||"").trim()||!v.soldAt)throw new Error("Customer name and sale date are required.");const paymentMethod=String(v.paymentMethod||"").trim();if(!new Set(["Cash","Venmo","PayPal"]).has(paymentMethod))throw new Error("Payment method must be Cash, Venmo, or PayPal.");const name=String(v.customerName).trim(),phone=String(v.phone||"").trim(),address1=String(v.shipAddress1||"").trim(),address2=String(v.shipAddress2||"").trim(),city=String(v.shipCity||"").trim(),state=String(v.shipState||"").trim(),zip=String(v.shipZip||"").trim(),shippingNotes=String(v.shippingNotes||"").trim();let customer=v.customerId?db.prepare("SELECT id FROM customers WHERE id=?").get(String(v.customerId)):findCustomerByName.get(name);if(!customer){customer={id:crypto.randomUUID()};addCustomer.run(customer.id,name,phone,address1,address2,city,state,zip,shippingNotes)}else db.prepare("UPDATE customers SET name=?,phone=?,address1=?,address2=?,city=?,state=?,zip=?,shipping_notes=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(name,phone,address1,address2,city,state,zip,shippingNotes,customer.id);db.prepare("UPDATE products SET status='sold',sold_at=?,customer_id=?,customer_name=?,phone=?,sale_price=?,ship_address1=?,ship_address2=?,ship_city=?,ship_state=?,ship_zip=?,shipping_notes=?,payment_method=?,payment_reference=?,sale_notes=? WHERE id=? AND status='available'").run(String(v.soldAt),customer.id,name,phone,Number(v.salePrice)||0,address1,address2,city,state,zip,shippingNotes,paymentMethod,String(v.paymentReference||"").trim(),String(v.saleNotes||"").trim(),id);audit(req,user,"sale_recorded",id,name);return json(res,200,getProduct().get(id))} if(current.status!=="sold")return json(res,400,{error:"Only sale records can be updated."});const v=await body(req),f=saleDetails(v,current),name=String(v.customerName??current.customerName??"").trim(),phone=String(v.phone??current.phone??"").trim(),soldAt=String(v.soldAt??current.soldAt??""),paymentMethod=String(v.paymentMethod??current.paymentMethod??"").trim(),salePrice=Number(v.salePrice??current.salePrice)||0;if(!name||!soldAt)throw new Error("Customer name and sale date are required.");if(!new Set(["Cash","Venmo","PayPal"]).has(paymentMethod))throw new Error("Payment method must be Cash, Venmo, or PayPal.");let customer=resolveCustomer({...v,customerName:name,phone});if(!customer){customer={id:crypto.randomUUID()};addCustomer.run(customer.id,name,phone,"","","","","","")}saveCustomer(customer.id,name,phone,f);const selectedId=String(v.warrantyPresetId??current.warrantyPresetId??""),preset=db.prepare("SELECT id,name,duration_value,duration_unit,active FROM warranty_presets WHERE id=?").get(selectedId);if(!preset||(!preset.active&&selectedId!==current.warrantyPresetId))throw new Error("Select an active warranty period.");db.prepare("UPDATE products SET sold_at=?,customer_id=?,customer_name=?,phone=?,sale_price=?,payment_method=?,payment_reference=?,sale_notes=?,fulfillment_method=?,fulfillment_name=?,ship_address1=?,ship_address2=?,ship_city=?,ship_state=?,ship_zip=?,shipping_notes=?,carrier=?,tracking_number=?,delivery_status=?,delivery_status_updated_at=CURRENT_TIMESTAMP,warranty_preset_id=?,warranty_name=?,warranty_end_date=? WHERE id=?").run(soldAt,customer.id,name,phone,salePrice,paymentMethod,String(v.paymentReference??current.paymentReference??"").trim(),String(v.saleNotes??current.saleNotes??"").trim(),f.method,f.name,f.address1,f.address2,f.city,f.state,f.zip,f.notes,f.carrier,f.trackingNumber,f.deliveryStatus,preset.id,preset.name,warrantyEnd(soldAt,preset),id);audit(req,user,"sale_details_updated",id,`${name}; ${paymentMethod}; ${f.method}; ${preset.name}`);return json(res,200,getProduct().get(id))
if(req.method==="POST"&&action==="restock"){const v=await body(req);if(!allowedConditions.has(v.condition)||!v.receivedAt)throw new Error("Condition and return date are required.");db.prepare("UPDATE products SET status='available',condition=?,received_at=?,sold_at=NULL,customer_id=NULL,customer_name=NULL,phone=NULL,sale_price=NULL,ship_address1='',ship_address2='',ship_city='',ship_state='',ship_zip='',shipping_notes='',payment_method='',payment_reference='',sale_notes='' WHERE id=? AND status='sold'").run(v.condition,String(v.receivedAt),id);audit(req,user,"sale_voided_restocked",id);return json(res,200,getProduct().get(id))} }
if(req.method==="POST"&&action==="sell"){const v=await body(req);if(!String(v.customerName||"").trim()||!v.soldAt)throw new Error("Customer name and sale date are required.");const paymentMethod=String(v.paymentMethod||"").trim();if(!new Set(["Cash","Venmo","PayPal"]).has(paymentMethod))throw new Error("Payment method must be Cash, Venmo, or PayPal.");const f=saleDetails(v),preset=v.warrantyPresetId?db.prepare("SELECT id,name,duration_value,duration_unit FROM warranty_presets WHERE id=? AND active=1").get(String(v.warrantyPresetId)):db.prepare("SELECT id,name,duration_value,duration_unit FROM warranty_presets WHERE active=1 AND is_default=1").get();if(!preset)throw new Error("Select an active warranty period.");const name=String(v.customerName).trim(),phone=String(v.phone||"").trim();let customer=resolveCustomer(v);if(!customer){customer={id:crypto.randomUUID()};addCustomer.run(customer.id,name,phone,"","","","","","")}saveCustomer(customer.id,name,phone,f);db.prepare("UPDATE products SET status='sold',sold_at=?,customer_id=?,customer_name=?,phone=?,sale_price=?,ship_address1=?,ship_address2=?,ship_city=?,ship_state=?,ship_zip=?,shipping_notes=?,payment_method=?,payment_reference=?,sale_notes=?,fulfillment_method=?,fulfillment_name=?,carrier=?,tracking_number=?,delivery_status=?,delivery_status_updated_at=CURRENT_TIMESTAMP,warranty_preset_id=?,warranty_name=?,warranty_end_date=? WHERE id=? AND status='available'").run(String(v.soldAt),customer.id,name,phone,Number(v.salePrice)||0,f.address1,f.address2,f.city,f.state,f.zip,f.notes,paymentMethod,String(v.paymentReference||"").trim(),String(v.saleNotes||"").trim(),f.method,f.name,f.carrier,f.trackingNumber,f.deliveryStatus,preset.id,preset.name,warrantyEnd(String(v.soldAt),preset),id);audit(req,user,"sale_recorded",id,`${name}; ${f.method}; ${preset.name}`);return json(res,200,getProduct().get(id))}
if(req.method==="POST"&&action==="restock"){const v=await body(req);if(!allowedConditions.has(v.condition)||!v.receivedAt)throw new Error("Condition and return date are required.");db.prepare("UPDATE products SET status='available',condition=?,received_at=?,sold_at=NULL,customer_id=NULL,customer_name=NULL,phone=NULL,sale_price=NULL,ship_address1='',ship_address2='',ship_city='',ship_state='',ship_zip='',shipping_notes='',payment_method='',payment_reference='',sale_notes='',fulfillment_method='',fulfillment_name='',carrier='',tracking_number='',delivery_status='',delivery_status_updated_at=NULL,warranty_preset_id=NULL,warranty_name='',warranty_end_date=NULL WHERE id=? AND status='sold'").run(v.condition,String(v.receivedAt),id);audit(req,user,"sale_voided_restocked",id);return json(res,200,getProduct().get(id))}
return json(res,405,{error:"Method not allowed"}); return json(res,405,{error:"Method not allowed"});
} }
const mime={".html":"text/html; charset=utf-8",".css":"text/css; charset=utf-8",".js":"text/javascript; charset=utf-8",".svg":"image/svg+xml",".png":"image/png",".webmanifest":"application/manifest+json; charset=utf-8"}; const mime={".html":"text/html; charset=utf-8",".css":"text/css; charset=utf-8",".js":"text/javascript; charset=utf-8",".svg":"image/svg+xml",".png":"image/png",".webmanifest":"application/manifest+json; charset=utf-8"};
+27 -3
View File
@@ -34,19 +34,43 @@ test("authentication, roles, inventory, sale, and restock", async t => {
response=await request("/api/products",{method:"POST",headers:jsonHeaders(viewerCookie),body:"{}"});assert.equal(response.status,403); response=await request("/api/products",{method:"POST",headers:jsonHeaders(viewerCookie),body:"{}"});assert.equal(response.status,403);
response=await request("/api/admin/users",{headers:{cookie:viewerCookie}});assert.equal(response.status,403); response=await request("/api/admin/users",{headers:{cookie:viewerCookie}});assert.equal(response.status,403);
response=await request("/api/admin/models",{method:"POST",headers:jsonHeaders(viewerCookie),body:JSON.stringify({name:"Denied"})});assert.equal(response.status,403); response=await request("/api/admin/models",{method:"POST",headers:jsonHeaders(viewerCookie),body:JSON.stringify({name:"Denied"})});assert.equal(response.status,403);
response=await request("/api/admin/warranties",{headers:{cookie:viewerCookie}});assert.equal(response.status,403);
response=await request("/api/warranties",{headers:{cookie:adminCookie}});const warrantyOptions=await response.json(),ninetyDays=warrantyOptions.find(x=>x.name==="90 Days");assert.ok(ninetyDays);assert.equal(warrantyOptions.find(x=>x.name==="No Warranty").isDefault,true);
response=await request("/api/admin/warranties",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({name:"45 Days",durationValue:45,durationUnit:"days"})});assert.equal(response.status,201);const customWarranty=await response.json();
response=await request("/api/admin/warranties",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({name:"45 days",durationValue:45,durationUnit:"days"})});assert.equal(response.status,409,"warranty names are unique regardless of case");
response=await request("/api/admin/models",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({name:"V7 Ultra"})});assert.equal(response.status,201);const customModel=await response.json(); response=await request("/api/admin/models",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({name:"V7 Ultra"})});assert.equal(response.status,201);const customModel=await response.json();
response=await request("/api/admin/models",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({name:"v7 ultra"})});assert.equal(response.status,409,"model names are unique regardless of case"); response=await request("/api/admin/models",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({name:"v7 ultra"})});assert.equal(response.status,409,"model names are unique regardless of case");
response=await request(`/api/admin/models/${customModel.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({name:"V7 Ultra Plus"})});assert.equal(response.status,200,"unused models may be renamed"); response=await request(`/api/admin/models/${customModel.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({name:"V7 Ultra Plus"})});assert.equal(response.status,200,"unused models may be renamed");
response=await request("/api/products",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({uid:"U1",model:"v7 ultra plus",condition:"New",receivedAt:"2026-08-29"})});assert.equal(response.status,201);const item=await response.json();assert.equal(item.model,"V7 Ultra Plus","stored product uses the canonical catalog name"); response=await request("/api/products",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({uid:"U1",model:"v7 ultra plus",condition:"New",receivedAt:"2026-08-29"})});assert.equal(response.status,201);const item=await response.json();assert.equal(item.model,"V7 Ultra Plus","stored product uses the canonical catalog name");
response=await request("/api/products/batch",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({model:"V3 Plus",condition:"New",receivedAt:"2026-08-29",cost:50,items:[{uid:"BATCH-UID-1",sn:"BATCH-SN-1",mac:"A0:BB:3E:0A:E4:01"},{uid:"BATCH-UID-2",sn:"BATCH-SN-2",mac:"A0:BB:3E:0A:E4:02"}]})});assert.equal(response.status,201);const receivedBatch=await response.json();assert.equal(receivedBatch.count,2);assert.equal(receivedBatch.products[0].receivedAt,"2026-08-29");
response=await request("/api/products/batch",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({model:"V3 Plus",condition:"New",receivedAt:"2026-08-29",items:[{uid:"BATCH-UID-1",sn:"BATCH-SN-3",mac:"A0:BB:3E:0A:E4:03"}]})});assert.equal(response.status,400,"batch receiving rejects identifiers already in inventory");
response=await request(`/api/admin/models/${customModel.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({active:false})});assert.equal(response.status,200); response=await request(`/api/admin/models/${customModel.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({active:false})});assert.equal(response.status,200);
response=await request("/api/products",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({uid:"U2",model:"V7 Ultra Plus",condition:"New",receivedAt:"2026-08-29"})});assert.equal(response.status,400,"archived models cannot be newly received"); response=await request("/api/products",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({uid:"U2",model:"V7 Ultra Plus",condition:"New",receivedAt:"2026-08-29"})});assert.equal(response.status,400,"archived models cannot be newly received");
response=await request(`/api/products/${item.id}/sell`,{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({customerName:"Test Customer",soldAt:"2026-08-29",paymentMethod:"Venmo",paymentReference:"TX-123"})});const sale=await response.json();assert.equal(sale.status,"sold");assert.equal(sale.paymentMethod,"Venmo");assert.equal(sale.paymentReference,"TX-123"); response=await request(`/api/products/${item.id}/sell`,{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({customerName:"Test Customer",soldAt:"2026-08-29",paymentMethod:"Venmo",paymentReference:"TX-123",fulfillmentMethod:"Shipped",shipAddress1:"1 Main St",shipCity:"Philadelphia",shipState:"PA",shipZip:"19103",carrier:"UPS",trackingNumber:"1ZTEST",warrantyPresetId:ninetyDays.id})});const sale=await response.json();assert.equal(sale.status,"sold");assert.equal(sale.paymentMethod,"Venmo");assert.equal(sale.paymentReference,"TX-123");assert.equal(sale.fulfillmentMethod,"Shipped");assert.equal(sale.deliveryStatus,"Label Created");assert.equal(sale.warrantyName,"90 Days");assert.equal(sale.warrantyEndDate,"2026-11-27");
response=await request("/api/products",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({uid:"BATCH-1",model:"V3 Plus",condition:"New",receivedAt:"2026-08-29"})});const batchItem1=await response.json();
response=await request("/api/products",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({uid:"BATCH-2",model:"V5 Pro",condition:"New",receivedAt:"2026-08-29"})});const batchItem2=await response.json();
response=await request("/api/sales",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({items:[{productId:batchItem1.id,salePrice:200},{productId:batchItem2.id,salePrice:275.5}],customerId:sale.customerId,customerName:"Test Customer",soldAt:"2026-08-29",paymentMethod:"Cash",fulfillmentMethod:"Meet",fulfillmentName:"Wawa",warrantyPresetId:ninetyDays.id})});assert.equal(response.status,201);const batchSale=await response.json();assert.equal(batchSale.count,2);assert.equal(batchSale.total,475.5);assert.ok(batchSale.products.every(x=>x.status==="sold"));assert.deepEqual(batchSale.products.map(x=>x.salePrice),[200,275.5]);assert.ok(batchSale.products.every(x=>x.customerId===sale.customerId));
response=await request("/api/products",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({uid:"ATOMIC-1",model:"V3 Plus",condition:"New",receivedAt:"2026-08-29"})});const atomicItem=await response.json();
response=await request("/api/sales",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({items:[{productId:atomicItem.id,salePrice:100},{productId:batchItem1.id,salePrice:100}],customerId:sale.customerId,customerName:"Test Customer",soldAt:"2026-08-29",paymentMethod:"Cash",fulfillmentMethod:"Meet",fulfillmentName:"Wawa",warrantyPresetId:ninetyDays.id})});assert.equal(response.status,400,"a batch containing an unavailable product is rejected");
response=await request("/api/products",{headers:{cookie:adminCookie}});assert.equal((await response.json()).find(x=>x.id===atomicItem.id).status,"available","a failed batch leaves every product unchanged");
response=await request(`/api/products/${item.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({saleNotes:"Left at front desk",fulfillmentMethod:"Shipped",shipAddress1:"1 Main St",shipCity:"Philadelphia",shipState:"PA",shipZip:"19103",carrier:"UPS",trackingNumber:"1ZTEST",deliveryStatus:"Delivered",warrantyPresetId:customWarranty.id})});const updatedSale=await response.json();assert.equal(updatedSale.deliveryStatus,"Delivered");assert.equal(updatedSale.warrantyName,"45 Days");assert.equal(updatedSale.warrantyEndDate,"2026-10-13");
response=await request(`/api/products/${item.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({customerId:sale.customerId,customerName:"Test Customer",phone:"(215) 555-0100",soldAt:"2026-08-30",salePrice:325,paymentMethod:"PayPal",paymentReference:"PP-456",saleNotes:"Corrected sale",fulfillmentMethod:"Meet",fulfillmentName:"Wawa",fulfillmentNotes:"Met at front entrance",warrantyPresetId:customWarranty.id})});const corrected=await response.json();assert.equal(corrected.salePrice,325);assert.equal(corrected.paymentMethod,"PayPal");assert.equal(corrected.fulfillmentMethod,"Meet");assert.equal(corrected.warrantyEndDate,"2026-10-14","changing sale date recalculates warranty end");
response=await request("/api/customers",{headers:{cookie:adminCookie}});const customerDirectory=await response.json();assert.equal(customerDirectory.length,1);assert.equal(customerDirectory[0].phone,"(215) 555-0100");
response=await request("/api/export/sales",{headers:{cookie:viewerCookie}});assert.equal(response.status,200);assert.match(response.headers.get("content-type"),/text\/csv/);assert.match(await response.text(),/PP-456/);
response=await request("/api/export/audit",{headers:{cookie:viewerCookie}});assert.equal(response.status,403,"read-only users cannot export the security audit");
response=await request("/api/admin/diagnostics",{headers:{cookie:adminCookie}});const diagnostics=await response.json();assert.equal(diagnostics.appVersion,"3.5.0");assert.equal(diagnostics.dataWritable,true);assert.equal(diagnostics.backupSettings.enabled,false);assert.equal(diagnostics.backupSettings.frequency,"daily");assert.equal(diagnostics.backupSettings.nextScheduledBackup,null);
response=await request("/api/admin/backup-settings",{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({enabled:true,frequency:"weekly",weekday:1,hour:3,retention:7})});assert.equal(response.status,200);
response=await request("/api/admin/diagnostics",{headers:{cookie:adminCookie}});const scheduled=(await response.json()).backupSettings;assert.equal(scheduled.enabled,true);assert.equal(scheduled.frequency,"weekly");assert.equal(scheduled.weekday,1);assert.equal(scheduled.hour,3);assert.equal(scheduled.retention,7);assert.equal(scheduled.lastScheduledBackup,null);assert.ok(scheduled.nextScheduledBackup);
response=await request(`/api/admin/warranties/${customWarranty.id}`,{method:"DELETE",headers:{cookie:adminCookie}});assert.equal(response.status,400,"a used warranty cannot be deleted");
response=await request(`/api/admin/warranties/${customWarranty.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({name:"Changed",durationValue:10,durationUnit:"days"})});assert.equal(response.status,400,"a used warranty cannot be edited");
response=await request(`/api/admin/warranties/${customWarranty.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({active:false})});assert.equal(response.status,200,"a used warranty can be archived");
response=await request(`/api/admin/models/${customModel.id}`,{method:"DELETE",headers:{cookie:adminCookie}});assert.equal(response.status,400,"a historically used model cannot be deleted"); response=await request(`/api/admin/models/${customModel.id}`,{method:"DELETE",headers:{cookie:adminCookie}});assert.equal(response.status,400,"a historically used model cannot be deleted");
response=await request(`/api/admin/models/${customModel.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({name:"Changed"})});assert.equal(response.status,400,"a historically used model cannot be renamed"); response=await request(`/api/admin/models/${customModel.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({name:"Changed"})});assert.equal(response.status,400,"a historically used model cannot be renamed");
response=await request(`/api/admin/models/${customModel.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({active:true})});assert.equal(response.status,200); response=await request(`/api/admin/models/${customModel.id}`,{method:"PATCH",headers:jsonHeaders(adminCookie),body:JSON.stringify({active:true})});assert.equal(response.status,200);
response=await request("/api/admin/models",{headers:{cookie:adminCookie}});const catalog=await response.json(),usage=catalog.find(x=>x.id===customModel.id);assert.equal(usage.soldCount,1);assert.equal(usage.availableCount,0); response=await request("/api/admin/models",{headers:{cookie:adminCookie}});const catalog=await response.json(),usage=catalog.find(x=>x.id===customModel.id);assert.equal(usage.soldCount,1);assert.equal(usage.availableCount,0);
response=await request(`/api/products/${item.id}/restock`,{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({condition:"Used",receivedAt:"2026-08-29"})});assert.equal((await response.json()).status,"available"); response=await request(`/api/products/${item.id}/restock`,{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({condition:"Used",receivedAt:"2026-08-29"})});const restocked=await response.json();assert.equal(restocked.status,"available");assert.equal(restocked.fulfillmentMethod,"");assert.equal(restocked.warrantyName,"");
response=await request("/api/admin/models",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({name:"Temporary Model"})});const unused=await response.json(); response=await request("/api/admin/models",{method:"POST",headers:jsonHeaders(adminCookie),body:JSON.stringify({name:"Temporary Model"})});const unused=await response.json();
response=await request(`/api/admin/models/${unused.id}`,{method:"DELETE",headers:{cookie:adminCookie}});assert.equal(response.status,204,"unused models may be deleted"); response=await request(`/api/admin/models/${unused.id}`,{method:"DELETE",headers:{cookie:adminCookie}});assert.equal(response.status,204,"unused models may be deleted");
}); });
@@ -60,5 +84,5 @@ test("legacy model constraint migrates without losing records", async t => {
let response=await fetch(`http://127.0.0.1:${port}/api/auth/change-password`,{method:"POST",headers:{"content-type":"application/json",cookie},body:JSON.stringify({currentPassword:"admin",newPassword:"password8",confirmPassword:"password8"})}),adminCookie=response.headers.get("set-cookie").split(";")[0]; let response=await fetch(`http://127.0.0.1:${port}/api/auth/change-password`,{method:"POST",headers:{"content-type":"application/json",cookie},body:JSON.stringify({currentPassword:"admin",newPassword:"password8",confirmPassword:"password8"})}),adminCookie=response.headers.get("set-cookie").split(";")[0];
response=await fetch(`http://127.0.0.1:${port}/api/products`,{headers:{cookie:adminCookie}});const products=await response.json();assert.equal(products.length,1);assert.equal(products[0].uid,"LEGACY-1"); response=await fetch(`http://127.0.0.1:${port}/api/products`,{headers:{cookie:adminCookie}});const products=await response.json();assert.equal(products.length,1);assert.equal(products[0].uid,"LEGACY-1");
response=await fetch(`http://127.0.0.1:${port}/api/models`,{headers:{cookie:adminCookie}});assert.ok((await response.json()).some(x=>x.name==="V3 Plus")); response=await fetch(`http://127.0.0.1:${port}/api/models`,{headers:{cookie:adminCookie}});assert.ok((await response.json()).some(x=>x.name==="V3 Plus"));
assert.ok((await readdir(join(data,"backups"))).some(name=>name.startsWith("pre-model-catalog-")),"migration creates a safety backup"); assert.ok((await readdir(join(data,"backups"))).some(name=>name.startsWith("pre-v3-2-upgrade-")),"migration creates a safety backup");
}); });
+3 -3
View File
@@ -2,9 +2,9 @@
<Container version="2"> <Container version="2">
<Name>vBoxStock</Name><Repository>ghcr.io/mfwadejr/vboxstock:latest</Repository><Registry>https://github.com/mfwadejr/vboxstock/pkgs/container/vboxstock</Registry> <Name>vBoxStock</Name><Repository>ghcr.io/mfwadejr/vboxstock:latest</Repository><Registry>https://github.com/mfwadejr/vboxstock/pkgs/container/vboxstock</Registry>
<Network>bridge</Network><Shell>sh</Shell><Privileged>false</Privileged> <Network>bridge</Network><Shell>sh</Shell><Privileged>false</Privileged>
<Support></Support><Project></Project><Overview>Self-contained vSeeBox inventory and sales tracker with an embedded SQLite database.</Overview> <Support>https://github.com/mfwadejr/vboxstock/issues</Support><Project>https://github.com/mfwadejr/vboxstock</Project><Overview>Self-contained vSeeBox inventory and sales tracker with an embedded SQLite database.</Overview>
<Category>Tools:</Category><WebUI>http://[IP]:[PORT:3000]/</WebUI><TemplateURL></TemplateURL><Icon></Icon> <Category>Tools:</Category><WebUI>http://[IP]:[PORT:3000]/</WebUI><TemplateURL>https://raw.githubusercontent.com/mfwadejr/vboxstock/main/vboxstock-unraid.xml</TemplateURL><Icon>https://raw.githubusercontent.com/mfwadejr/vboxstock/main/public/assets/vboxstock-icon-512.png</Icon>
<Config Name="Web UI Port" Target="3000" Default="3000" Mode="tcp" Description="vBoxStock web interface" Type="Port" Display="always" Required="true" Mask="false">3000</Config> <Config Name="Web UI Port" Target="3000" Default="8269" Mode="tcp" Description="Recommended vBoxStock host port; maps to container port 3000" Type="Port" Display="always" Required="true" Mask="false">8269</Config>
<Config Name="App Data" Target="/data" Default="/mnt/user/appdata/vboxstock" Mode="rw" Description="SQLite database and durable application data" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/vboxstock</Config> <Config Name="App Data" Target="/data" Default="/mnt/user/appdata/vboxstock" Mode="rw" Description="SQLite database and durable application data" Type="Path" Display="always" Required="true" Mask="false">/mnt/user/appdata/vboxstock</Config>
<Config Name="User ID" Target="PUID" Default="99" Mode="" Description="Host user ID used to own and write application data. Unraid default is 99 (nobody)." Type="Variable" Display="advanced" Required="true" Mask="false">99</Config> <Config Name="User ID" Target="PUID" Default="99" Mode="" Description="Host user ID used to own and write application data. Unraid default is 99 (nobody)." Type="Variable" Display="advanced" Required="true" Mask="false">99</Config>
<Config Name="Group ID" Target="PGID" Default="100" Mode="" Description="Host group ID used to own and write application data. Unraid default is 100 (users)." Type="Variable" Display="advanced" Required="true" Mask="false">100</Config> <Config Name="Group ID" Target="PGID" Default="100" Mode="" Description="Host group ID used to own and write application data. Unraid default is 100 (users)." Type="Variable" Display="advanced" Required="true" Mask="false">100</Config>