36 Commits

Author SHA1 Message Date
ryan
924a28e201 feat: forced PIN setup modal on all authenticated pages
All checks were successful
CI / ci (pull_request) Successful in 29s
CI / e2e (pull_request) Successful in 1m7s
Replaces the alert banner with a blocking modal dialog that requires
users to set a PIN before they can use the app. Cannot be dismissed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 20:01:14 +00:00
ryan
2cd646ddea fix: remove unused Link import from profile page
All checks were successful
CI / ci (pull_request) Successful in 27s
CI / e2e (pull_request) Successful in 1m6s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:57:31 +00:00
ryan
3f9e125412 fix: move PIN warning banner to authenticated layout for all pages
Some checks failed
CI / ci (pull_request) Failing after 26s
CI / e2e (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:51:39 +00:00
ryan
96d2a966d7 feat: tabbed profile page with PIN setup and auto employee numbers
All checks were successful
CI / ci (pull_request) Successful in 25s
CI / e2e (pull_request) Successful in 55s
- Profile page split into Account, Security, Appearance tabs
- Security tab: change password + set/change/remove POS PIN
- Warning banner with link to Security tab when no PIN is set
- /auth/me returns employeeNumber and hasPin
- Migration 0046: Postgres trigger auto-assigns sequential employee
  numbers starting at 1001, backfills existing users
- Added shadcn Alert component

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 19:41:36 +00:00
ryan
666ae8d59b fix: assign Admin RBAC role to initial user on seed
All checks were successful
Build & Release / build (push) Successful in 22s
Without this, the initial user has no permissions and sees no modules.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:38:53 +00:00
67f1e4a26a Merge pull request 'feat: set browser tab title and favicon from customer branding' (#10) from feature/password-reset into main
All checks were successful
Build & Release / build (push) Successful in 1m20s
Reviewed-on: #10
2026-04-05 17:19:53 +00:00
ryan
613784a1cc feat: set browser tab title and favicon from customer branding
All checks were successful
CI / ci (pull_request) Successful in 27s
CI / e2e (pull_request) Successful in 1m15s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:15:32 +00:00
ea9aceec46 Merge pull request 'feat: password reset flow with welcome emails' (#9) from feature/password-reset into main
All checks were successful
Build & Release / build (push) Successful in 1m16s
Reviewed-on: #9
2026-04-05 17:12:06 +00:00
ryan
bc8613bbbc feat: password reset flow with welcome emails
All checks were successful
CI / ci (pull_request) Successful in 27s
CI / e2e (pull_request) Successful in 1m0s
- POST /auth/forgot-password with welcome/reset email templates
- POST /auth/reset-password with Zod validation, 4-hour tokens
- Per-email rate limiting (3/hr) via Valkey, no user enumeration
- Login page "Forgot password?" toggle with inline form
- /reset-password page for setting new password from email link
- Initial user seed sends welcome email instead of requiring password
- CLI script for force-resetting passwords via kubectl exec
- APP_URL env var in chart, removed INITIAL_USER_PASSWORD

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 17:09:23 +00:00
Ryan Moon
a1dc4b0e47 feat: seed company record from BUSINESS_NAME env var on first startup
All checks were successful
Build & Release / build (push) Successful in 19s
2026-04-05 11:49:51 -05:00
81de80abb9 Merge pull request 'fix: regenerate route tree to include reports/daily route' (#8) from feature/login-branding into main
All checks were successful
Build & Release / build (push) Successful in 16s
Reviewed-on: #8
2026-04-05 16:25:46 +00:00
ryan
75c7c28f73 fix: generate route tree in CI and Docker build
All checks were successful
CI / ci (pull_request) Successful in 23s
CI / e2e (pull_request) Successful in 1m1s
Ensures routeTree.gen.ts is always fresh so stale checked-in copies
don't break the frontend build or lint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:24:08 +00:00
ryan
99fdaaa05a fix: regenerate route tree to include reports/daily route
All checks were successful
CI / ci (pull_request) Successful in 21s
CI / e2e (pull_request) Successful in 55s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:22:20 +00:00
6870aea1a5 Merge pull request 'feat: show customer branding on login page' (#7) from feature/login-branding into main
Some checks failed
Build & Release / build (push) Failing after 37s
Reviewed-on: #7
2026-04-05 16:19:32 +00:00
ryan
e589ff02f0 fix: remove unused imports to pass lint
All checks were successful
CI / ci (pull_request) Successful in 22s
CI / e2e (pull_request) Successful in 59s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:17:42 +00:00
ryan
da4b765b14 feat: show customer branding on login page
Some checks failed
CI / ci (pull_request) Failing after 20s
CI / e2e (pull_request) Has been skipped
Adds public /v1/store/branding and /v1/store/logo endpoints so the
login page can display the customer's name and logo without auth,
with "Powered by LunarFront" underneath — matching the sidebar style.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:14:07 +00:00
ryan
326b30161b fix: correct _transaction prop name to transaction in POSItemPanel
Some checks failed
Build & Release / build (push) Failing after 35s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:10:15 +00:00
ryan
ac9b615470 fix: renumber migrations 0042-0045 after rebase onto main
Some checks failed
Build & Release / build (push) Failing after 1m53s
Main added 0041_app_settings, so branch migrations shift up by one.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:06:12 +00:00
ryan
785071e5fd fix: add line items to repair tickets in music store seed
Tickets with work in progress or ready for pickup now have realistic
line items (labor, parts, flat rates, consumables). The ready ticket
(David Smith — Violin) has billable items for POS checkout testing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:20 +00:00
ryan
0ca6ae7632 fix: make music store seed self-contained, remove non-string instruments
- Seed now bootstraps company, location, RBAC, admin user, modules, and
  default register — no dev-seed dependency
- Admin: admin@harmonymusic.com / admin1234 (POS: 10011234)
- Added 10 music-focused accounts and 16 members (families, individuals,
  schools, orchestra)
- Removed all guitar, brass, and woodwind templates and repair tickets
- Added string-specific templates (fingerboard planing, varnish touch-up,
  neck reset, bass bar replacement, tailgut replacement)
- School batch changed from band instruments to string orchestra instruments
- All repair tickets now reference string instruments only

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
ryan
7d9aeaf188 feat: named registers, X/Z reports, daily rollup, fix drawerSessionId
Registers:
- New register table with location association
- CRUD service + API routes (POST/GET/PATCH/DELETE /registers)
- Drawer sessions now link to a register via registerId
- Register ID persisted in localStorage per device

X/Z Reports:
- ReportService with getDrawerReport() (X or Z depending on session state)
- Z report auto-displayed on drawer close in the drawer dialog
- X report (Current Shift Report) button on open drawer view
- Report shows: sales summary, payment breakdown, discounts, cash accountability, adjustments

Daily Rollup:
- ReportService.getDailyReport() aggregates all sessions at a location for a date
- New /reports/daily endpoint with locationId + date params
- Frontend daily report page with date picker, location selector, session breakdown

Critical Fix:
- drawerSessionId is now populated on transactions when completing (was never set before)
- This enables accurate per-drawer reporting and cash accountability

Migration 0044: register table, drawer_session.register_id column

Tests: 14 new (register CRUD, drawer report X/Z, drawerSessionId population, daily rollup, register-drawer link)
Full suite: 367 passed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
ryan
be8cc0ad8b fix: code review fixes + unit/API tests for repair-POS integration
Code review fixes:
- Wrap createFromRepairTicket() in DB transaction for atomicity
- Wrap complete() inventory + status updates in DB transaction
- Repair ticket status update now atomic with transaction completion
- Add Zod validation on from-repair route body
- Fix requiresDiscountOverride: threshold and manual_discount are independent checks
- Order discount distributes proportionally across line items (not first-only)
- Extract shared receipt calculations into useReceiptData/useBarcode hooks
- Add error handling for barcode generation

Tests:
- Unit: consumable tax category mapping, exempt rate short-circuit
- API: ready-for-pickup listing + search, from-repair transaction creation,
  consumable exclusion from line items, tax rate verification (labor=service,
  part=goods), duplicate prevention, ticket auto-pickup on payment completion,
  isConsumable product filter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
ryan
95cf017b4b feat: repair-POS integration, receipt formats, manager overrides, price adjustments
- Add thermal/full-page receipt format toggle (per-device, localStorage)
- Full-page receipt uses clean invoice layout matching repair PDF style
- Settings page reorganized into tabbed sections (Store, Locations, Modules, Receipt, POS Security, Advanced)
- Manager override system: configurable PIN prompt for void, refund, discount, cash in/out
- Discount threshold setting: require manager approval above X%
- Consumable product type: tracked for internal job costing, excluded from POS search, receipts, and customer-facing totals
- Repair line item dialog: product picker dropdown for parts/consumables from inventory
- Repair → POS checkout: load ready-for-pickup tickets into repair_payment transactions with proper tax categories (labor=service, parts=goods)
- Transaction completion auto-updates repair ticket status to picked_up
- POS Repairs dialog with Pickup and New Intake tabs, customer account lookup
- Inline price adjustment on cart items: % off, $ off, or set price with live preview
- Order-level discount button with same three input modes
- Backend: migration 0043 (consumable enum + is_consumable flag), createFromRepairTicket service, ready-for-pickup endpoint
- Fix: backend dev script uses --env-file for turbo compatibility

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
ryan
a48da03289 feat: orders lookup with receipt reprint, refresh stock after sale
- "Orders" button in POS quick actions shows recent transactions
- Search by transaction number, tap to view receipt, print or save PDF
- Product stock counts refresh after completing a sale
- Invalidate product search queries on payment completion

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
ryan
e19cdc76e0 fix: dynamic PDF height based on receipt content length
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
ryan
fe40b563d5 fix: receipt uses inline styles for PDF/print compatibility, thermal width
- Replace all Tailwind classes with inline styles (fixes oklch color error in html2pdf)
- Narrow receipt to 260px / 10px font for 72mm thermal paper
- Print uses hidden iframe instead of window.open (fixes Safari about:blank)
- PDF canvas width matches thermal format

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
ryan
49db60e31f feat: receipt PDF save and print via html2pdf.js
- Save PDF button downloads receipt directly
- Print button opens PDF in new window and triggers print dialog
- Replaces previous window.print() approach that lost styles
- Receipt generated on demand from transaction data, no file storage needed

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
ryan
8820a56a51 feat: receipt customization settings tab with header, footer, policy, social
- New Receipt tab in Settings page with editable fields
- receipt_header: text below logo (e.g. tagline)
- receipt_footer: thank you message
- receipt_return_policy: return policy text
- receipt_social: website/social media
- All stored in app_config, rendered on printed receipts
- Seeded in migration with empty defaults

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
ryan
0aa9345c27 feat: show company logo on receipt if uploaded, fall back to name
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
ryan
3519db9bd9 feat: printable receipts with barcode on payment complete
- Receipt component with thermal (80mm) and full-page layout support
- Code 128 barcode from transaction number via JsBarcode
- Store name, address, line items, totals, payment info, barcode
- Print button on sale complete screen (browser print dialog)
- Email button placeholder (disabled, ready for SMTP integration)
- @media print CSS hides everything except receipt content
- Receipt data fetched from GET /transactions/:id/receipt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
ryan
0fd73015f7 fix: customer history query, seed transactions tied to accounts
- Fix customerHistoryOptions closure bug (historySearch was inaccessible)
- Pass itemSearch as parameter instead of capturing from outer scope
- Seed 5 completed transactions tied to accounts (Smith, Johnson, Garcia, Chen)
- Seed admin user with employee number 1001 and PIN 1234

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
ryan
d21972212b feat: customer lookup from POS with order history and item search
- Customer dialog in cart panel: search accounts by name, phone, email, account #
- Selected customer shown with name, phone, email in cart header
- accountId passed when creating transactions
- Order history view: tap a transaction to expand and see line items
- Item search in history (e.g. "strings") — filters orders containing that item
- Backend: add accountId and itemSearch filters to transaction list endpoint
- itemSearch uses EXISTS subquery on line item descriptions (ILIKE)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
ryan
cf299ac1d2 feat: POS PIN unlock with employee number + PIN auth
- Add employeeNumber and pinHash fields to users table
- POST /auth/pin-login: takes combined code (4-digit employee# + 4-digit PIN)
- POST /auth/set-pin: employee sets their own PIN (requires full auth)
- DELETE /auth/pin: remove PIN
- Lock screen with numpad, auto-submits on 8 digits, visual dot separator
- POS uses its own auth token separate from admin session
- Admin "POS" link clears admin session before navigating
- /pos route has no auth guard — lock screen is the auth
- API client uses POS token when available, admin token otherwise
- Auto-lock timer reads pos_lock_timeout from app_config (default 15 min)
- Lock button in POS top bar, shows current cashier name

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
ryan
6505b2dcb9 fix: drawer open/close updates UI immediately without refresh
- Return null instead of throwing on 404 for drawer current query
- Sync drawer session ID to null when drawer closes
- Await query invalidation before closing dialog
- Fix unused approvedBy lint error

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
ryan
12a3e170de feat: add cash in/out UI and hide drawer balance from cashier
- Cash In / Cash Out buttons in drawer dialog when open
- Amount + reason form, adjustment history with IN/OUT badges
- Drawer badge shows "Drawer Open" without balance (manager info only)
- API helpers for addAdjustment and getAdjustments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
ryan
3ed2707a66 feat: add drawer cash in/out adjustments with balance reconciliation
- New drawer_adjustment table (type: cash_in/cash_out, amount, reason)
- POST/GET /drawer/:id/adjustments endpoints
- Drawer close calculation now includes adjustments: expected = opening + sales + cash_in - cash_out
- DrawerAdjustmentSchema for input validation
- 5 new tests (44 total POS tests passing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 16:05:19 +00:00
74 changed files with 5826 additions and 500 deletions

View File

@@ -21,6 +21,10 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
- name: Generate route tree
working-directory: packages/admin
run: bunx @tanstack/router-cli generate
- name: Lint - name: Lint
run: bun run lint run: bun run lint

View File

@@ -16,6 +16,7 @@ COPY packages/admin ./packages/admin
COPY package.json ./ COPY package.json ./
COPY tsconfig.base.json ./ COPY tsconfig.base.json ./
WORKDIR /app/packages/admin WORKDIR /app/packages/admin
RUN bunx @tanstack/router-cli generate
RUN bun run build RUN bun run build
FROM nginx:alpine FROM nginx:alpine

View File

@@ -34,6 +34,8 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"html2pdf.js": "^0.14.0",
"jsbarcode": "^3.12.3",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
@@ -59,7 +61,7 @@
}, },
"packages/backend": { "packages/backend": {
"name": "@lunarfront/backend", "name": "@lunarfront/backend",
"version": "0.0.1", "version": "0.1.1",
"dependencies": { "dependencies": {
"@fastify/cors": "^10", "@fastify/cors": "^10",
"@fastify/jwt": "^9", "@fastify/jwt": "^9",
@@ -806,6 +808,8 @@
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
"html2pdf.js": ["html2pdf.js@0.14.0", "", { "dependencies": { "dompurify": "^3.3.1", "html2canvas": "^1.0.0", "jspdf": "^4.0.0" } }, "sha512-yvNJgE/8yru2UeGflkPdjW8YEY+nDH5X7/2WG4uiuSCwYiCp8PZ8EKNiTAa6HxJ1NjC51fZSIEq6xld5CADKBQ=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -842,6 +846,8 @@
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"jsbarcode": ["jsbarcode@3.12.3", "", {}, "sha512-CuHU9hC6dPsHF5oVFMo8NW76uQVjH4L22CsP4hW+dNnGywJHC/B0ThA1CTDVLnxKLrrpYdicBLnd2xsgTfRnvg=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],

View File

@@ -93,18 +93,14 @@ spec:
secretKeyRef: secretKeyRef:
name: lunarfront-secrets name: lunarfront-secrets
key: business-name key: business-name
- name: APP_URL
value: "https://{{ .Values.ingress.host }}"
- name: INITIAL_USER_EMAIL - name: INITIAL_USER_EMAIL
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:
name: lunarfront-secrets name: lunarfront-secrets
key: initial-user-email key: initial-user-email
optional: true optional: true
- name: INITIAL_USER_PASSWORD
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: initial-user-password
optional: true
- name: INITIAL_USER_FIRST_NAME - name: INITIAL_USER_FIRST_NAME
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:

View File

@@ -25,6 +25,8 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"html2pdf.js": "^0.14.0",
"jsbarcode": "^3.12.3",
"jspdf": "^4.2.1", "jspdf": "^4.2.1",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",

View File

@@ -14,3 +14,11 @@ interface LoginResponse {
export async function login(email: string, password: string): Promise<LoginResponse> { export async function login(email: string, password: string): Promise<LoginResponse> {
return api.post<LoginResponse>('/v1/auth/login', { email, password }) return api.post<LoginResponse>('/v1/auth/login', { email, password })
} }
export async function forgotPassword(email: string): Promise<{ message: string }> {
return api.post<{ message: string }>('/v1/auth/forgot-password', { email })
}
export async function resetPassword(token: string, newPassword: string): Promise<{ message: string }> {
return api.post<{ message: string }>('/v1/auth/reset-password', { token, newPassword })
}

View File

@@ -88,11 +88,34 @@ export interface Product {
isActive: boolean isActive: boolean
} }
export interface Register {
id: string
locationId: string
name: string
isActive: boolean
createdAt: string
updatedAt: string
}
// --- Query Keys --- // --- Query Keys ---
export interface DrawerAdjustment {
id: string
drawerSessionId: string
type: string
amount: string
reason: string
createdBy: string
createdAt: string
}
export const posKeys = { export const posKeys = {
transaction: (id: string) => ['pos', 'transaction', id] as const, transaction: (id: string) => ['pos', 'transaction', id] as const,
drawer: (locationId: string) => ['pos', 'drawer', locationId] as const, drawer: (locationId: string) => ['pos', 'drawer', locationId] as const,
drawerAdjustments: (id: string) => ['pos', 'drawer-adjustments', id] as const,
drawerReport: (id: string) => ['pos', 'drawer-report', id] as const,
dailyReport: (locationId: string, date: string) => ['pos', 'daily-report', locationId, date] as const,
registers: (locationId: string) => ['pos', 'registers', locationId] as const,
products: (search: string) => ['pos', 'products', search] as const, products: (search: string) => ['pos', 'products', search] as const,
discounts: ['pos', 'discounts'] as const, discounts: ['pos', 'discounts'] as const,
} }
@@ -110,7 +133,13 @@ export function transactionOptions(id: string | null) {
export function currentDrawerOptions(locationId: string | null) { export function currentDrawerOptions(locationId: string | null) {
return queryOptions({ return queryOptions({
queryKey: posKeys.drawer(locationId ?? ''), queryKey: posKeys.drawer(locationId ?? ''),
queryFn: () => api.get<DrawerSession>('/v1/drawer/current', { locationId }), queryFn: async (): Promise<DrawerSession | null> => {
try {
return await api.get<DrawerSession>('/v1/drawer/current', { locationId })
} catch {
return null // 404 = no open drawer
}
},
enabled: !!locationId, enabled: !!locationId,
retry: false, retry: false,
}) })
@@ -119,7 +148,7 @@ export function currentDrawerOptions(locationId: string | null) {
export function productSearchOptions(search: string) { export function productSearchOptions(search: string) {
return queryOptions({ return queryOptions({
queryKey: posKeys.products(search), queryKey: posKeys.products(search),
queryFn: () => api.get<{ data: Product[]; pagination: { page: number; limit: number; total: number; totalPages: number } }>('/v1/products', { q: search, limit: 24, isActive: true }), queryFn: () => api.get<{ data: Product[]; pagination: { page: number; limit: number; total: number; totalPages: number } }>('/v1/products', { q: search, limit: 24, isActive: true, isConsumable: false }),
enabled: search.length >= 1, enabled: search.length >= 1,
}) })
} }
@@ -131,10 +160,34 @@ export function discountListOptions() {
}) })
} }
export function registerListOptions(locationId: string | null) {
return queryOptions({
queryKey: posKeys.registers(locationId ?? ''),
queryFn: () => api.get<{ data: Register[] }>('/v1/registers/all', { locationId }),
enabled: !!locationId,
})
}
export function drawerReportOptions(drawerSessionId: string | null) {
return queryOptions({
queryKey: posKeys.drawerReport(drawerSessionId ?? ''),
queryFn: () => api.get<any>(`/v1/reports/drawer/${drawerSessionId}`),
enabled: !!drawerSessionId,
})
}
export function dailyReportOptions(locationId: string | null, date: string) {
return queryOptions({
queryKey: posKeys.dailyReport(locationId ?? '', date),
queryFn: () => api.get<any>('/v1/reports/daily', { locationId, date }),
enabled: !!locationId && !!date,
})
}
// --- Mutations --- // --- Mutations ---
export const posMutations = { export const posMutations = {
createTransaction: (data: { transactionType: string; locationId?: string }) => createTransaction: (data: { transactionType: string; locationId?: string; accountId?: string }) =>
api.post<Transaction>('/v1/transactions', data), api.post<Transaction>('/v1/transactions', data),
addLineItem: (txnId: string, data: { productId?: string; inventoryUnitId?: string; description: string; qty: number; unitPrice: number }) => addLineItem: (txnId: string, data: { productId?: string; inventoryUnitId?: string; description: string; qty: number; unitPrice: number }) =>
@@ -152,7 +205,7 @@ export const posMutations = {
void: (txnId: string) => void: (txnId: string) =>
api.post<Transaction>(`/v1/transactions/${txnId}/void`, {}), api.post<Transaction>(`/v1/transactions/${txnId}/void`, {}),
openDrawer: (data: { locationId?: string; openingBalance: number }) => openDrawer: (data: { locationId?: string; registerId?: string; openingBalance: number }) =>
api.post<DrawerSession>('/v1/drawer/open', data), api.post<DrawerSession>('/v1/drawer/open', data),
closeDrawer: (id: string, data: { closingBalance: number; denominations?: Record<string, number>; notes?: string }) => closeDrawer: (id: string, data: { closingBalance: number; denominations?: Record<string, number>; notes?: string }) =>
@@ -160,4 +213,13 @@ export const posMutations = {
lookupUpc: (upc: string) => lookupUpc: (upc: string) =>
api.get<Product>(`/v1/products/lookup/upc/${upc}`), api.get<Product>(`/v1/products/lookup/upc/${upc}`),
addAdjustment: (drawerId: string, data: { type: string; amount: number; reason: string }) =>
api.post<DrawerAdjustment>(`/v1/drawer/${drawerId}/adjustments`, data),
getAdjustments: (drawerId: string) =>
api.get<{ data: DrawerAdjustment[] }>(`/v1/drawer/${drawerId}/adjustments`),
createFromRepair: (ticketId: string, locationId?: string) =>
api.post<Transaction>(`/v1/transactions/from-repair/${ticketId}`, { locationId }),
} }

View File

@@ -34,6 +34,7 @@ export function ProductForm({ defaultValues, onSubmit, loading }: Props) {
isSerialized: defaultValues?.isSerialized ?? false, isSerialized: defaultValues?.isSerialized ?? false,
isRental: defaultValues?.isRental ?? false, isRental: defaultValues?.isRental ?? false,
isDualUseRepair: defaultValues?.isDualUseRepair ?? false, isDualUseRepair: defaultValues?.isDualUseRepair ?? false,
isConsumable: defaultValues?.isConsumable ?? false,
isActive: defaultValues?.isActive ?? true, isActive: defaultValues?.isActive ?? true,
}, },
}) })
@@ -42,6 +43,7 @@ export function ProductForm({ defaultValues, onSubmit, loading }: Props) {
const isRental = watch('isRental') const isRental = watch('isRental')
const isSerialized = watch('isSerialized') const isSerialized = watch('isSerialized')
const isDualUseRepair = watch('isDualUseRepair') const isDualUseRepair = watch('isDualUseRepair')
const isConsumable = watch('isConsumable')
const isActive = watch('isActive') const isActive = watch('isActive')
function handleFormSubmit(data: Record<string, unknown>) { function handleFormSubmit(data: Record<string, unknown>) {
@@ -61,6 +63,7 @@ export function ProductForm({ defaultValues, onSubmit, loading }: Props) {
isSerialized: data.isSerialized, isSerialized: data.isSerialized,
isRental: data.isRental, isRental: data.isRental,
isDualUseRepair: data.isDualUseRepair, isDualUseRepair: data.isDualUseRepair,
isConsumable: data.isConsumable,
isActive: data.isActive, isActive: data.isActive,
}) })
} }
@@ -158,6 +161,10 @@ export function ProductForm({ defaultValues, onSubmit, loading }: Props) {
<input type="checkbox" checked={isDualUseRepair} onChange={(e) => setValue('isDualUseRepair', e.target.checked)} className="h-4 w-4" /> <input type="checkbox" checked={isDualUseRepair} onChange={(e) => setValue('isDualUseRepair', e.target.checked)} className="h-4 w-4" />
Available as Repair Line Item Available as Repair Line Item
</label> </label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isConsumable} onChange={(e) => setValue('isConsumable', e.target.checked)} className="h-4 w-4" />
Consumable (internal use, not sold at POS)
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isActive} onChange={(e) => setValue('isActive', e.target.checked)} className="h-4 w-4" /> <input type="checkbox" checked={isActive} onChange={(e) => setValue('isActive', e.target.checked)} className="h-4 w-4" />
Active Active

View File

@@ -3,10 +3,15 @@ import { usePOSStore } from '@/stores/pos.store'
import { posMutations, posKeys, type Transaction } from '@/api/pos' import { posMutations, posKeys, type Transaction } from '@/api/pos'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { X, Banknote, CreditCard, FileText, Ban } from 'lucide-react' import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { X, Banknote, CreditCard, FileText, Ban, UserRound, Tag } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useState } from 'react' import { useState } from 'react'
import { POSPaymentDialog } from './pos-payment-dialog' import { POSPaymentDialog } from './pos-payment-dialog'
import { POSCustomerDialog } from './pos-customer-dialog'
import { ManagerOverrideDialog, requiresOverride, requiresDiscountOverride } from './pos-manager-override'
import type { TransactionLineItem } from '@/api/pos'
interface POSCartPanelProps { interface POSCartPanelProps {
transaction: Transaction | null transaction: Transaction | null
@@ -14,8 +19,14 @@ interface POSCartPanelProps {
export function POSCartPanel({ transaction }: POSCartPanelProps) { export function POSCartPanel({ transaction }: POSCartPanelProps) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { currentTransactionId, setTransaction } = usePOSStore() const { currentTransactionId, setTransaction, accountName, accountPhone, accountEmail } = usePOSStore()
const [paymentMethod, setPaymentMethod] = useState<string | null>(null) const [paymentMethod, setPaymentMethod] = useState<string | null>(null)
const [customerOpen, setCustomerOpen] = useState(false)
const [overrideOpen, setOverrideOpen] = useState(false)
const [priceItemId, setPriceItemId] = useState<string | null>(null)
const [pendingDiscount, setPendingDiscount] = useState<{ lineItemId: string; amount: number; reason: string } | null>(null)
const [pendingOrderDiscount, setPendingOrderDiscount] = useState<{ amount: number; reason: string } | null>(null)
const [discountOverrideOpen, setDiscountOverrideOpen] = useState(false)
const lineItems = transaction?.lineItems ?? [] const lineItems = transaction?.lineItems ?? []
const drawerSessionId = usePOSStore((s) => s.drawerSessionId) const drawerSessionId = usePOSStore((s) => s.drawerSessionId)
@@ -30,6 +41,39 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
const discountMutation = useMutation({
mutationFn: (data: { lineItemId: string; amount: number; reason: string }) =>
posMutations.applyDiscount(currentTransactionId!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
setPriceItemId(null)
toast.success('Price adjusted')
},
onError: (err) => toast.error(err.message),
})
const orderDiscountMutation = useMutation({
mutationFn: async ({ amount, reason }: { amount: number; reason: string }) => {
// Distribute discount proportionally across all line items
let remaining = amount
for (let i = 0; i < lineItems.length; i++) {
const item = lineItems[i]
const itemTotal = parseFloat(item.unitPrice) * item.qty
const isLast = i === lineItems.length - 1
const share = isLast ? remaining : Math.round((itemTotal / subtotal) * amount * 100) / 100
remaining -= share
if (share > 0) {
await posMutations.applyDiscount(currentTransactionId!, { lineItemId: item.id, amount: share, reason })
}
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
toast.success('Order discount applied')
},
onError: (err) => toast.error(err.message),
})
const voidMutation = useMutation({ const voidMutation = useMutation({
mutationFn: () => posMutations.void(currentTransactionId!), mutationFn: () => posMutations.void(currentTransactionId!),
onSuccess: () => { onSuccess: () => {
@@ -63,6 +107,24 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
</span> </span>
)} )}
</div> </div>
<button
onClick={() => setCustomerOpen(true)}
className="flex items-start gap-1.5 mt-1 text-xs text-muted-foreground hover:text-foreground text-left"
>
<UserRound className="h-3 w-3 mt-0.5 shrink-0" />
{accountName ? (
<span>
<span className="font-medium text-foreground">{accountName}</span>
{(accountPhone || accountEmail) && (
<span className="block text-[11px]">
{[accountPhone, accountEmail].filter(Boolean).join(' · ')}
</span>
)}
</span>
) : (
<span>Walk-in tap to add customer</span>
)}
</button>
</div> </div>
{/* Line items */} {/* Line items */}
@@ -73,33 +135,61 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
</div> </div>
) : ( ) : (
<div className="divide-y divide-border"> <div className="divide-y divide-border">
{lineItems.map((item) => ( {lineItems.map((item) => {
<div key={item.id} className="flex items-center gap-2 px-3 py-2.5 group"> const unitPrice = parseFloat(item.unitPrice)
<div className="flex-1 min-w-0"> const discount = parseFloat(item.discountAmount)
<p className="text-sm font-medium truncate">{item.description}</p> const hasDiscount = discount > 0
<p className="text-xs text-muted-foreground"> const listTotal = unitPrice * item.qty
{item.qty} x ${parseFloat(item.unitPrice).toFixed(2)} const discountPct = listTotal > 0 ? Math.round((discount / listTotal) * 100) : 0
{parseFloat(item.taxAmount) > 0 && (
<span className="ml-2">tax ${parseFloat(item.taxAmount).toFixed(2)}</span> return (
)} <div key={item.id} className="flex items-center gap-2 px-3 py-2.5 group">
</p> <div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{item.description}</p>
<div className="text-xs text-muted-foreground flex items-center gap-2">
<span>{item.qty} x ${unitPrice.toFixed(2)}</span>
{hasDiscount && (
<span className="text-green-600">-${discount.toFixed(2)} ({discountPct}%)</span>
)}
{parseFloat(item.taxAmount) > 0 && (
<span>tax ${parseFloat(item.taxAmount).toFixed(2)}</span>
)}
</div>
</div>
<span className="text-sm font-medium tabular-nums">
${parseFloat(item.lineTotal).toFixed(2)}
</span>
{isPending && (
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 shrink-0">
<PriceAdjuster
item={item}
open={priceItemId === item.id}
onOpenChange={(o) => setPriceItemId(o ? item.id : null)}
onApply={(amount, reason) => {
const pct = listTotal > 0 ? (amount / listTotal) * 100 : 0
if (requiresDiscountOverride(pct)) {
setPendingDiscount({ lineItemId: item.id, amount, reason })
setDiscountOverrideOpen(true)
} else {
discountMutation.mutate({ lineItemId: item.id, amount, reason })
}
}}
isPending={discountMutation.isPending}
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => removeItemMutation.mutate(item.id)}
disabled={removeItemMutation.isPending}
>
<X className="h-4 w-4 text-destructive" />
</Button>
</div>
)}
</div> </div>
<span className="text-sm font-medium tabular-nums"> )
${parseFloat(item.lineTotal).toFixed(2)} })}
</span>
{isPending && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 shrink-0"
onClick={() => removeItemMutation.mutate(item.id)}
disabled={removeItemMutation.isPending}
>
<X className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
))}
</div> </div>
)} )}
</div> </div>
@@ -128,6 +218,25 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
</div> </div>
</div> </div>
{/* Order discount button */}
{hasItems && isPending && (
<div className="px-3 pb-1">
<OrderDiscountButton
subtotal={subtotal}
onApply={(amount, reason) => {
const pct = subtotal > 0 ? (amount / subtotal) * 100 : 0
if (requiresDiscountOverride(pct)) {
setPendingOrderDiscount({ amount, reason })
setDiscountOverrideOpen(true)
} else {
orderDiscountMutation.mutate({ amount, reason })
}
}}
isPending={orderDiscountMutation.isPending}
/>
</div>
)}
{/* Payment buttons */} {/* Payment buttons */}
<div className="p-3 space-y-2"> <div className="p-3 space-y-2">
{!drawerOpen && hasItems && ( {!drawerOpen && hasItems && (
@@ -163,7 +272,13 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
variant="destructive" variant="destructive"
className="h-12 text-sm gap-2" className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending} disabled={!hasItems || !isPending}
onClick={() => voidMutation.mutate()} onClick={() => {
if (requiresOverride('void_transaction')) {
setOverrideOpen(true)
} else {
voidMutation.mutate()
}
}}
> >
<Ban className="h-4 w-4" /> <Ban className="h-4 w-4" />
Void Void
@@ -182,6 +297,226 @@ export function POSCartPanel({ transaction }: POSCartPanelProps) {
onComplete={handlePaymentComplete} onComplete={handlePaymentComplete}
/> />
)} )}
{/* Customer dialog */}
<POSCustomerDialog open={customerOpen} onOpenChange={setCustomerOpen} />
{/* Manager override for void */}
<ManagerOverrideDialog
open={overrideOpen}
onOpenChange={setOverrideOpen}
action="Void transaction"
onAuthorized={() => voidMutation.mutate()}
/>
{/* Manager override for discount */}
<ManagerOverrideDialog
open={discountOverrideOpen}
onOpenChange={setDiscountOverrideOpen}
action="Price adjustment"
onAuthorized={() => {
if (pendingDiscount) {
discountMutation.mutate(pendingDiscount)
setPendingDiscount(null)
} else if (pendingOrderDiscount) {
orderDiscountMutation.mutate(pendingOrderDiscount)
setPendingOrderDiscount(null)
}
}}
/>
</div> </div>
) )
} }
// --- Order Discount Button ---
function OrderDiscountButton({ subtotal, onApply, isPending }: {
subtotal: number
onApply: (amount: number, reason: string) => void
isPending: boolean
}) {
const [open, setOpen] = useState(false)
const [mode, setMode] = useState<AdjustMode>('percent')
const [value, setValue] = useState('')
function calculate() {
const v = parseFloat(value) || 0
if (mode === 'amount_off') return Math.min(v, subtotal)
if (mode === 'set_price') return Math.max(0, subtotal - v)
return Math.round(subtotal * (v / 100) * 100) / 100
}
const discountAmount = calculate()
function handleApply() {
if (discountAmount <= 0) return
const reason = mode === 'percent' ? `${parseFloat(value)}% order discount` : mode === 'set_price' ? `Order total set to $${parseFloat(value).toFixed(2)}` : `$${parseFloat(value).toFixed(2)} order discount`
onApply(discountAmount, reason)
setValue('')
setOpen(false)
}
return (
<Popover open={open} onOpenChange={(o) => { setOpen(o); if (!o) setValue('') }}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="w-full gap-2 text-xs h-8">
<Tag className="h-3 w-3" />Order Discount
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" side="top" align="center">
<div className="space-y-3">
<div className="text-xs text-muted-foreground">
Subtotal: <span className="font-medium text-foreground">${subtotal.toFixed(2)}</span>
</div>
<div className="flex rounded-md border overflow-hidden text-xs">
{([
{ key: 'percent' as const, label: '% Off' },
{ key: 'amount_off' as const, label: '$ Off' },
{ key: 'set_price' as const, label: 'Set Total' },
]).map((m) => (
<button
key={m.key}
type="button"
className={`flex-1 py-1.5 transition-colors ${mode === m.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
onClick={() => { setMode(m.key); setValue('') }}
>
{m.label}
</button>
))}
</div>
<Input
type="number"
step="0.01"
min="0"
placeholder={mode === 'percent' ? 'e.g. 10' : '0.00'}
value={value}
onChange={(e) => setValue(e.target.value)}
className="h-9"
autoFocus
onKeyDown={(e) => { if (e.key === 'Enter') handleApply() }}
/>
{value && discountAmount > 0 && (
<div className="text-xs flex justify-between font-medium">
<span className="text-green-600">-${discountAmount.toFixed(2)}</span>
<span>New total: ${(subtotal - discountAmount).toFixed(2)}</span>
</div>
)}
<Button size="sm" className="w-full" onClick={handleApply} disabled={isPending || discountAmount <= 0}>
{isPending ? 'Applying...' : 'Apply Discount'}
</Button>
</div>
</PopoverContent>
</Popover>
)
}
// --- Price Adjuster Popover ---
type AdjustMode = 'amount_off' | 'set_price' | 'percent'
function PriceAdjuster({ item, open, onOpenChange, onApply, isPending }: {
item: TransactionLineItem
open: boolean
onOpenChange: (open: boolean) => void
onApply: (amount: number, reason: string) => void
isPending: boolean
}) {
const [mode, setMode] = useState<AdjustMode>('percent')
const [value, setValue] = useState('')
const unitPrice = parseFloat(item.unitPrice)
const listTotal = unitPrice * item.qty
function calculate(): { discountAmount: number; salePrice: number; pct: number } {
const v = parseFloat(value) || 0
if (mode === 'amount_off') {
const d = Math.min(v, listTotal)
return { discountAmount: d, salePrice: listTotal - d, pct: listTotal > 0 ? (d / listTotal) * 100 : 0 }
}
if (mode === 'set_price') {
const d = Math.max(0, listTotal - v)
return { discountAmount: d, salePrice: v, pct: listTotal > 0 ? (d / listTotal) * 100 : 0 }
}
// percent
const d = Math.round(listTotal * (v / 100) * 100) / 100
return { discountAmount: d, salePrice: listTotal - d, pct: v }
}
const calc = calculate()
function handleApply() {
if (calc.discountAmount <= 0) return
const reason = mode === 'percent' ? `${parseFloat(value)}% off` : mode === 'set_price' ? `Price set to $${parseFloat(value).toFixed(2)}` : `$${parseFloat(value).toFixed(2)} off`
onApply(calc.discountAmount, reason)
setValue('')
}
return (
<Popover open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) setValue('') }}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Tag className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" side="left" align="start">
<div className="space-y-3">
<div className="text-xs text-muted-foreground">
List: <span className="font-medium text-foreground">${listTotal.toFixed(2)}</span>
</div>
{/* Mode tabs */}
<div className="flex rounded-md border overflow-hidden text-xs">
{([
{ key: 'percent' as const, label: '% Off' },
{ key: 'amount_off' as const, label: '$ Off' },
{ key: 'set_price' as const, label: 'Set Price' },
]).map((m) => (
<button
key={m.key}
type="button"
className={`flex-1 py-1.5 transition-colors ${mode === m.key ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
onClick={() => { setMode(m.key); setValue('') }}
>
{m.label}
</button>
))}
</div>
<Input
type="number"
step="0.01"
min="0"
placeholder={mode === 'percent' ? 'e.g. 10' : '0.00'}
value={value}
onChange={(e) => setValue(e.target.value)}
className="h-9"
autoFocus
onKeyDown={(e) => { if (e.key === 'Enter') handleApply() }}
/>
{value && parseFloat(value) > 0 && (
<div className="text-xs space-y-0.5">
<div className="flex justify-between">
<span className="text-muted-foreground">Discount</span>
<span className="text-green-600">-${calc.discountAmount.toFixed(2)} ({calc.pct.toFixed(0)}%)</span>
</div>
<div className="flex justify-between font-medium">
<span>Sale Price</span>
<span>${calc.salePrice.toFixed(2)}</span>
</div>
</div>
)}
<Button
size="sm"
className="w-full"
onClick={handleApply}
disabled={isPending || !value || calc.discountAmount <= 0}
>
{isPending ? 'Applying...' : 'Apply'}
</Button>
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,254 @@
import { useState, useCallback } from 'react'
import { useQuery } from '@tanstack/react-query'
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { usePOSStore } from '@/stores/pos.store'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Search, X, History } from 'lucide-react'
interface Account {
id: string
name: string
email: string | null
phone: string | null
accountNumber: string | null
}
interface TransactionLineItem {
id: string
description: string
qty: number
unitPrice: string
lineTotal: string
}
interface Transaction {
id: string
transactionNumber: string
total: string
status: string
paymentMethod: string | null
transactionType: string
completedAt: string | null
createdAt: string
lineItems?: TransactionLineItem[]
}
function accountSearchOptions(search: string) {
return queryOptions({
queryKey: ['pos', 'accounts', search],
queryFn: () => api.get<{ data: Account[] }>('/v1/accounts', { q: search, limit: 10 }),
enabled: search.length >= 2,
})
}
function customerHistoryOptions(accountId: string | null, itemSearch?: string) {
return queryOptions({
queryKey: ['pos', 'customer-history', accountId, itemSearch ?? ''],
queryFn: () => api.get<{ data: Transaction[] }>('/v1/transactions', {
accountId,
limit: 10,
sort: 'created_at',
order: 'desc',
...(itemSearch ? { itemSearch } : {}),
}),
enabled: !!accountId,
})
}
interface POSCustomerDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function POSCustomerDialog({ open, onOpenChange }: POSCustomerDialogProps) {
const { accountId, accountName, setAccount, clearAccount } = usePOSStore()
const [search, setSearch] = useState('')
const [showHistory, setShowHistory] = useState(false)
const [historySearch, setHistorySearch] = useState('')
const { data: searchData, isLoading } = useQuery(accountSearchOptions(search))
const accounts = searchData?.data ?? []
const { data: historyData } = useQuery(customerHistoryOptions(showHistory ? accountId : null, historySearch || undefined))
const history = historyData?.data ?? []
function handleSelect(account: Account) {
setAccount(account.id, account.name, account.phone, account.email)
setSearch('')
setShowHistory(false)
onOpenChange(false)
}
function handleClear() {
clearAccount()
setSearch('')
setShowHistory(false)
onOpenChange(false)
}
const [expandedTxn, setExpandedTxn] = useState<string | null>(null)
// Fetch detail for expanded transaction
const { data: txnDetail } = useQuery({
queryKey: ['pos', 'transaction-detail', expandedTxn],
queryFn: () => api.get<Transaction>(`/v1/transactions/${expandedTxn}`),
enabled: !!expandedTxn,
})
const toggleExpand = useCallback((id: string) => {
setExpandedTxn((prev) => prev === id ? null : id)
}, [])
// History view
if (showHistory && accountId) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>Order History {accountName}</span>
<Button variant="ghost" size="sm" onClick={() => { setShowHistory(false); setExpandedTxn(null) }}>Back</Button>
</DialogTitle>
</DialogHeader>
{/* Search items in history */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={historySearch}
onChange={(e) => setHistorySearch(e.target.value)}
placeholder="Search items (e.g. strings, bow)..."
className="pl-10 h-10 text-sm"
/>
</div>
<div className="flex-1 overflow-y-auto">
{history.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
{historySearch ? `No orders with "${historySearch}"` : 'No transactions found'}
</p>
) : (
<div className="divide-y divide-border">
{history.map((txn) => (
<div key={txn.id}>
<button
onClick={() => toggleExpand(txn.id)}
className="w-full text-left py-2.5 px-1 hover:bg-accent/50 rounded transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-mono">{txn.transactionNumber}</span>
<span className="text-sm font-semibold">${parseFloat(txn.total).toFixed(2)}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<Badge variant={txn.status === 'completed' ? 'default' : 'outline'} className="text-[10px]">
{txn.status}
</Badge>
{txn.paymentMethod && (
<span className="text-xs text-muted-foreground">{txn.paymentMethod.replace('_', ' ')}</span>
)}
<span className="text-xs text-muted-foreground ml-auto">
{new Date(txn.completedAt ?? txn.createdAt).toLocaleDateString()}
</span>
</div>
</button>
{expandedTxn === txn.id && txnDetail?.lineItems && (
<div className="px-3 pb-2 space-y-1">
{txnDetail.lineItems.map((item) => (
<div key={item.id} className="flex justify-between text-xs text-muted-foreground">
<span>{item.qty} x {item.description}</span>
<span>${parseFloat(item.lineTotal).toFixed(2)}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Customer</DialogTitle>
</DialogHeader>
{/* Current selection */}
{accountId && (
<>
<div className="flex items-center justify-between p-3 rounded-md border bg-muted/30">
<div>
<p className="font-medium text-sm">{accountName}</p>
<p className="text-xs text-muted-foreground">Selected customer</p>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => setShowHistory(true)}>
<History className="h-4 w-4 mr-1" />
History
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleClear}>
<X className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
<Separator />
</>
)}
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name, phone, email, account #..."
className="pl-10 h-11"
autoFocus
/>
</div>
{/* Results */}
<div className="flex-1 overflow-y-auto">
{isLoading ? (
<p className="text-sm text-muted-foreground text-center py-4">Searching...</p>
) : search.length >= 2 && accounts.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No customers found</p>
) : (
<div className="divide-y divide-border">
{accounts.map((account) => (
<button
key={account.id}
onClick={() => handleSelect(account)}
className="w-full text-left px-2 py-3 hover:bg-accent rounded-md transition-colors"
>
<p className="font-medium text-sm">{account.name}</p>
<div className="flex gap-3 text-xs text-muted-foreground mt-0.5">
{account.phone && <span>{account.phone}</span>}
{account.email && <span>{account.email}</span>}
{account.accountNumber && <span>#{account.accountNumber}</span>}
</div>
</button>
))}
</div>
)}
</div>
{/* Walk-in button */}
{accountId && (
<Button variant="outline" className="w-full h-11" onClick={handleClear}>
Clear Customer (Walk-in)
</Button>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -1,13 +1,16 @@
import { useState } from 'react' import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { usePOSStore } from '@/stores/pos.store' import { usePOSStore } from '@/stores/pos.store'
import { posMutations, posKeys, type DrawerSession } from '@/api/pos' import { posMutations, posKeys, drawerReportOptions, type DrawerSession } from '@/api/pos'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Badge } from '@/components/ui/badge'
import { ArrowDownToLine, ArrowUpFromLine } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ManagerOverrideDialog, requiresOverride } from './pos-manager-override'
interface POSDrawerDialogProps { interface POSDrawerDialogProps {
open: boolean open: boolean
@@ -17,22 +20,45 @@ interface POSDrawerDialogProps {
export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogProps) { export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogProps) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { locationId, setDrawerSession } = usePOSStore() const { locationId, registerId, setDrawerSession } = usePOSStore()
const isOpen = drawer?.status === 'open' const isOpen = drawer?.status === 'open'
const [openingBalance, setOpeningBalance] = useState('200') const [openingBalance, setOpeningBalance] = useState('200')
const [closingBalance, setClosingBalance] = useState('') const [closingBalance, setClosingBalance] = useState('')
const [notes, setNotes] = useState('') const [notes, setNotes] = useState('')
const [adjustView, setAdjustView] = useState<'cash_in' | 'cash_out' | null>(null)
const [adjAmount, setAdjAmount] = useState('')
const [adjReason, setAdjReason] = useState('')
const [overrideOpen, setOverrideOpen] = useState(false)
const [pendingAdjustView, setPendingAdjustView] = useState<'cash_in' | 'cash_out' | null>(null)
const [showZReport, setShowZReport] = useState(false)
const [closedDrawerId, setClosedDrawerId] = useState<string | null>(null)
const [showXReport, setShowXReport] = useState(false)
// Z Report data (after close)
const { data: reportData } = useQuery(drawerReportOptions(closedDrawerId))
// X Report data (live, for open drawer)
const { data: xReportData } = useQuery(drawerReportOptions(showXReport ? drawer?.id ?? null : null))
// Fetch adjustments for open drawer
const { data: adjData } = useQuery({
queryKey: posKeys.drawerAdjustments(drawer?.id ?? ''),
queryFn: () => posMutations.getAdjustments(drawer!.id),
enabled: !!drawer?.id && isOpen,
})
const adjustments = adjData?.data ?? []
const openMutation = useMutation({ const openMutation = useMutation({
mutationFn: () => mutationFn: () =>
posMutations.openDrawer({ posMutations.openDrawer({
locationId: locationId ?? undefined, locationId: locationId ?? undefined,
registerId: registerId ?? undefined,
openingBalance: parseFloat(openingBalance) || 0, openingBalance: parseFloat(openingBalance) || 0,
}), }),
onSuccess: (session) => { onSuccess: async (session) => {
setDrawerSession(session.id) setDrawerSession(session.id)
queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') }) await queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') })
toast.success('Drawer opened') toast.success('Drawer opened')
onOpenChange(false) onOpenChange(false)
}, },
@@ -45,25 +71,136 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
closingBalance: parseFloat(closingBalance) || 0, closingBalance: parseFloat(closingBalance) || 0,
notes: notes || undefined, notes: notes || undefined,
}), }),
onSuccess: (session) => { onSuccess: async (session) => {
setDrawerSession(null) setDrawerSession(null)
queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') })
const overShort = parseFloat(session.overShort ?? '0') const overShort = parseFloat(session.overShort ?? '0')
if (Math.abs(overShort) < 0.01) { if (Math.abs(overShort) < 0.01) {
toast.success('Drawer closed - balanced') toast.success('Drawer closed - balanced')
} else { } else {
toast.warning(`Drawer closed - ${overShort > 0 ? 'over' : 'short'} $${Math.abs(overShort).toFixed(2)}`) toast.warning(`Drawer closed - ${overShort > 0 ? 'over' : 'short'} $${Math.abs(overShort).toFixed(2)}`)
} }
onOpenChange(false) // Show Z report
setClosedDrawerId(session.id)
setShowZReport(true)
await queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') })
}, },
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
const adjustMutation = useMutation({
mutationFn: () =>
posMutations.addAdjustment(drawer!.id, {
type: adjustView!,
amount: parseFloat(adjAmount) || 0,
reason: adjReason,
}),
onSuccess: (adj) => {
queryClient.invalidateQueries({ queryKey: posKeys.drawerAdjustments(drawer!.id) })
toast.success(`${adj.type === 'cash_in' ? 'Cash added' : 'Cash removed'}: $${parseFloat(adj.amount).toFixed(2)}`)
setAdjustView(null)
setAdjAmount('')
setAdjReason('')
},
onError: (err) => toast.error(err.message),
})
// Z Report view (shown after drawer close)
if (showZReport && reportData) {
const r = reportData
return (
<>
<Dialog open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) { setShowZReport(false); setClosedDrawerId(null) } }}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Z Report Drawer Closed</DialogTitle>
</DialogHeader>
<DrawerReportView report={r} />
<Button variant="outline" className="w-full" onClick={() => { setShowZReport(false); setClosedDrawerId(null); onOpenChange(false) }}>
Done
</Button>
</DialogContent>
</Dialog>
</>
)
}
// X Report view (mid-shift snapshot)
if (showXReport && xReportData) {
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>X Report Current Shift</DialogTitle>
</DialogHeader>
<DrawerReportView report={xReportData} />
<Button variant="outline" className="w-full" onClick={() => setShowXReport(false)}>
Back
</Button>
</DialogContent>
</Dialog>
</>
)
}
// Adjustment entry view
if (adjustView && isOpen) {
const isCashIn = adjustView === 'cash_in'
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{isCashIn ? 'Cash In' : 'Cash Out'}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Amount *</Label>
<Input
type="number"
step="0.01"
min="0.01"
value={adjAmount}
onChange={(e) => setAdjAmount(e.target.value)}
placeholder="0.00"
className="h-11 text-lg"
autoFocus
/>
</div>
<div className="space-y-2">
<Label>Reason *</Label>
<Input
value={adjReason}
onChange={(e) => setAdjReason(e.target.value)}
placeholder={isCashIn ? 'e.g. Extra change' : 'e.g. Bank deposit'}
className="h-11"
/>
</div>
<div className="flex gap-2">
<Button
className="flex-1 h-12"
onClick={() => adjustMutation.mutate()}
disabled={!adjAmount || !adjReason || adjustMutation.isPending}
>
{adjustMutation.isPending ? 'Saving...' : isCashIn ? 'Add Cash' : 'Remove Cash'}
</Button>
<Button variant="outline" className="h-12" onClick={() => setAdjustView(null)}>
Cancel
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
)
}
return ( return (
<>
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm"> <DialogContent className="max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle>{isOpen ? 'Close Drawer' : 'Open Drawer'}</DialogTitle> <DialogTitle>{isOpen ? 'Drawer' : 'Open Drawer'}</DialogTitle>
</DialogHeader> </DialogHeader>
{isOpen ? ( {isOpen ? (
@@ -78,7 +215,72 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
<span>{new Date(drawer!.openedAt).toLocaleTimeString()}</span> <span>{new Date(drawer!.openedAt).toLocaleTimeString()}</span>
</div> </div>
</div> </div>
{/* Cash In / Cash Out buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
className="h-11 gap-2"
onClick={() => {
if (requiresOverride('cash_in_out')) {
setPendingAdjustView('cash_in')
setOverrideOpen(true)
} else {
setAdjustView('cash_in')
}
}}
>
<ArrowDownToLine className="h-4 w-4 text-green-600" />
Cash In
</Button>
<Button
variant="outline"
className="h-11 gap-2"
onClick={() => {
if (requiresOverride('cash_in_out')) {
setPendingAdjustView('cash_out')
setOverrideOpen(true)
} else {
setAdjustView('cash_out')
}
}}
>
<ArrowUpFromLine className="h-4 w-4 text-red-600" />
Cash Out
</Button>
</div>
{/* X Report button */}
<Button variant="outline" className="w-full h-10 gap-2 text-sm" onClick={() => setShowXReport(true)}>
Current Shift Report
</Button>
{/* Adjustment history */}
{adjustments.length > 0 && (
<>
<Separator />
<div className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Adjustments</span>
{adjustments.map((adj) => (
<div key={adj.id} className="flex items-center justify-between text-sm py-1">
<div className="flex items-center gap-2">
<Badge variant={adj.type === 'cash_in' ? 'default' : 'destructive'} className="text-[10px]">
{adj.type === 'cash_in' ? 'IN' : 'OUT'}
</Badge>
<span className="text-muted-foreground truncate max-w-[140px]">{adj.reason}</span>
</div>
<span className={adj.type === 'cash_in' ? 'text-green-600' : 'text-red-600'}>
{adj.type === 'cash_in' ? '+' : '-'}${parseFloat(adj.amount).toFixed(2)}
</span>
</div>
))}
</div>
</>
)}
<Separator /> <Separator />
{/* Close drawer */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Closing Balance *</Label> <Label>Closing Balance *</Label>
<Input <Input
@@ -89,7 +291,6 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
onChange={(e) => setClosingBalance(e.target.value)} onChange={(e) => setClosingBalance(e.target.value)}
placeholder="Count the cash in the drawer" placeholder="Count the cash in the drawer"
className="h-11 text-lg" className="h-11 text-lg"
autoFocus
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -135,5 +336,136 @@ export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogP
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<ManagerOverrideDialog
open={overrideOpen}
onOpenChange={setOverrideOpen}
action={pendingAdjustView === 'cash_in' ? 'Cash In' : 'Cash Out'}
onAuthorized={() => {
if (pendingAdjustView) setAdjustView(pendingAdjustView)
setPendingAdjustView(null)
}}
/>
</>
)
}
// --- Shared report view used by both X and Z reports ---
const PAYMENT_LABELS: Record<string, string> = {
cash: 'Cash',
card_present: 'Card (Present)',
card_keyed: 'Card (Keyed)',
check: 'Check',
account_charge: 'Account',
unknown: 'Other',
}
function DrawerReportView({ report }: { report: any }) {
const { session, sales, payments, discounts, cash, adjustments } = report
return (
<div className="space-y-4 text-sm">
{/* Session info */}
<div className="space-y-1">
{session.register && <div className="flex justify-between"><span className="text-muted-foreground">Register</span><span>{session.register.name}</span></div>}
{session.openedBy && <div className="flex justify-between"><span className="text-muted-foreground">Opened by</span><span>{session.openedBy.firstName} {session.openedBy.lastName}</span></div>}
<div className="flex justify-between"><span className="text-muted-foreground">Opened</span><span>{new Date(session.openedAt).toLocaleString()}</span></div>
{session.closedAt && (
<>
{session.closedBy && <div className="flex justify-between"><span className="text-muted-foreground">Closed by</span><span>{session.closedBy.firstName} {session.closedBy.lastName}</span></div>}
<div className="flex justify-between"><span className="text-muted-foreground">Closed</span><span>{new Date(session.closedAt).toLocaleString()}</span></div>
</>
)}
</div>
<Separator />
{/* Sales */}
<div>
<h4 className="font-semibold text-xs uppercase text-muted-foreground mb-2">Sales</h4>
<div className="space-y-1">
<div className="flex justify-between"><span>Transactions</span><span>{sales.transactionCount}</span></div>
<div className="flex justify-between"><span>Gross Sales</span><span className="tabular-nums">${sales.grossSales.toFixed(2)}</span></div>
{sales.refundTotal > 0 && <div className="flex justify-between text-red-600"><span>Refunds</span><span className="tabular-nums">-${sales.refundTotal.toFixed(2)}</span></div>}
<div className="flex justify-between font-medium"><span>Net Sales</span><span className="tabular-nums">${sales.netSales.toFixed(2)}</span></div>
{sales.voidCount > 0 && <div className="flex justify-between text-muted-foreground"><span>Voided</span><span>{sales.voidCount}</span></div>}
</div>
</div>
<Separator />
{/* Payment breakdown */}
<div>
<h4 className="font-semibold text-xs uppercase text-muted-foreground mb-2">Payments</h4>
<div className="space-y-1">
{Object.entries(payments as Record<string, { count: number; total: number }>).map(([method, data]) => (
<div key={method} className="flex justify-between">
<span>{PAYMENT_LABELS[method] ?? method} ({data.count})</span>
<span className="tabular-nums">${data.total.toFixed(2)}</span>
</div>
))}
{Object.keys(payments).length === 0 && <p className="text-muted-foreground">No payments</p>}
</div>
</div>
{/* Discounts */}
{discounts.count > 0 && (
<>
<Separator />
<div>
<h4 className="font-semibold text-xs uppercase text-muted-foreground mb-2">Discounts</h4>
<div className="flex justify-between"><span>Total ({discounts.count})</span><span className="tabular-nums text-green-600">-${discounts.total.toFixed(2)}</span></div>
</div>
</>
)}
<Separator />
{/* Cash accountability */}
<div>
<h4 className="font-semibold text-xs uppercase text-muted-foreground mb-2">Cash</h4>
<div className="space-y-1">
<div className="flex justify-between"><span>Opening Balance</span><span className="tabular-nums">${cash.openingBalance.toFixed(2)}</span></div>
<div className="flex justify-between"><span>Cash Sales</span><span className="tabular-nums">${cash.cashSales.toFixed(2)}</span></div>
{cash.cashIn > 0 && <div className="flex justify-between text-green-600"><span>Cash In</span><span className="tabular-nums">+${cash.cashIn.toFixed(2)}</span></div>}
{cash.cashOut > 0 && <div className="flex justify-between text-red-600"><span>Cash Out</span><span className="tabular-nums">-${cash.cashOut.toFixed(2)}</span></div>}
<Separator />
<div className="flex justify-between font-medium"><span>Expected</span><span className="tabular-nums">${cash.expectedBalance.toFixed(2)}</span></div>
{cash.actualBalance !== null && (
<>
<div className="flex justify-between"><span>Actual Count</span><span className="tabular-nums">${cash.actualBalance.toFixed(2)}</span></div>
<div className={`flex justify-between font-bold ${cash.overShort === 0 ? 'text-green-600' : 'text-red-600'}`}>
<span>{cash.overShort! >= 0 ? 'Over' : 'Short'}</span>
<span className="tabular-nums">${Math.abs(cash.overShort!).toFixed(2)}</span>
</div>
</>
)}
</div>
</div>
{/* Adjustments */}
{adjustments.length > 0 && (
<>
<Separator />
<div>
<h4 className="font-semibold text-xs uppercase text-muted-foreground mb-2">Adjustments</h4>
<div className="space-y-1">
{adjustments.map((adj: any) => (
<div key={adj.id} className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<Badge variant={adj.type === 'cash_in' ? 'default' : 'destructive'} className="text-[10px]">
{adj.type === 'cash_in' ? 'IN' : 'OUT'}
</Badge>
<span className="text-muted-foreground truncate max-w-[150px]">{adj.reason}</span>
</div>
<span className="tabular-nums">${parseFloat(adj.amount).toFixed(2)}</span>
</div>
))}
</div>
</div>
</>
)}
</div>
) )
} }

View File

@@ -7,8 +7,10 @@ import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Search, ScanBarcode, Wrench, PenLine } from 'lucide-react' import { Search, ScanBarcode, Wrench, PenLine, ClipboardList } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { POSTransactionsDialog } from './pos-transactions-dialog'
import { POSRepairDialog } from './pos-repair-dialog'
interface POSItemPanelProps { interface POSItemPanelProps {
transaction: Transaction | null transaction: Transaction | null
@@ -16,9 +18,11 @@ interface POSItemPanelProps {
export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) { export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { currentTransactionId, setTransaction, locationId } = usePOSStore() const { currentTransactionId, setTransaction, locationId, accountId } = usePOSStore()
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [customOpen, setCustomOpen] = useState(false) const [customOpen, setCustomOpen] = useState(false)
const [txnDialogOpen, setTxnDialogOpen] = useState(false)
const [repairOpen, setRepairOpen] = useState(false)
const [customDesc, setCustomDesc] = useState('') const [customDesc, setCustomDesc] = useState('')
const [customPrice, setCustomPrice] = useState('') const [customPrice, setCustomPrice] = useState('')
const [customQty, setCustomQty] = useState('1') const [customQty, setCustomQty] = useState('1')
@@ -40,6 +44,7 @@ export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) {
const txn = await posMutations.createTransaction({ const txn = await posMutations.createTransaction({
transactionType: 'sale', transactionType: 'sale',
locationId: locationId ?? undefined, locationId: locationId ?? undefined,
accountId: accountId ?? undefined,
}) })
txnId = txn.id txnId = txn.id
setTransaction(txnId) setTransaction(txnId)
@@ -66,6 +71,7 @@ export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) {
const txn = await posMutations.createTransaction({ const txn = await posMutations.createTransaction({
transactionType: 'sale', transactionType: 'sale',
locationId: locationId ?? undefined, locationId: locationId ?? undefined,
accountId: accountId ?? undefined,
}) })
txnId = txn.id txnId = txn.id
setTransaction(txnId) setTransaction(txnId)
@@ -96,6 +102,7 @@ export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) {
const txn = await posMutations.createTransaction({ const txn = await posMutations.createTransaction({
transactionType: 'sale', transactionType: 'sale',
locationId: locationId ?? undefined, locationId: locationId ?? undefined,
accountId: accountId ?? undefined,
}) })
txnId = txn.id txnId = txn.id
setTransaction(txnId) setTransaction(txnId)
@@ -195,7 +202,7 @@ export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) {
<Button <Button
variant="outline" variant="outline"
className="flex-1 h-11 text-sm gap-2" className="flex-1 h-11 text-sm gap-2"
disabled onClick={() => setRepairOpen(true)}
> >
<Wrench className="h-4 w-4" /> <Wrench className="h-4 w-4" />
Repairs Repairs
@@ -208,6 +215,14 @@ export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) {
<PenLine className="h-4 w-4" /> <PenLine className="h-4 w-4" />
Custom Custom
</Button> </Button>
<Button
variant="outline"
className="flex-1 h-11 text-sm gap-2"
onClick={() => setTxnDialogOpen(true)}
>
<ClipboardList className="h-4 w-4" />
Orders
</Button>
</div> </div>
{/* Custom item dialog */} {/* Custom item dialog */}
@@ -261,6 +276,10 @@ export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) {
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Transactions dialog */}
<POSTransactionsDialog open={txnDialogOpen} onOpenChange={setTxnDialogOpen} />
<POSRepairDialog open={repairOpen} onOpenChange={setRepairOpen} />
</div> </div>
) )
} }

View File

@@ -0,0 +1,168 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { usePOSStore } from '@/stores/pos.store'
import { api } from '@/lib/api-client'
import { Button } from '@/components/ui/button'
import { Delete, Lock } from 'lucide-react'
interface PinUser {
id: string
email: string
firstName: string
lastName: string
role: string
}
export function POSLockScreen() {
const unlock = usePOSStore((s) => s.unlock)
const [code, setCode] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
// Auto-focus on mount
useEffect(() => {
containerRef.current?.focus()
}, [])
const handleDigit = useCallback((digit: string) => {
setError('')
setCode((p) => {
if (p.length >= 10) return p
return p + digit
})
}, [])
const handleBackspace = useCallback(() => {
setError('')
setCode((p) => p.slice(0, -1))
}, [])
const handleClear = useCallback(() => {
setError('')
setCode('')
}, [])
const handleSubmit = useCallback(async (submitCode: string) => {
if (submitCode.length < 8) {
setError('Enter your employee # (4) + PIN (4)')
return
}
setLoading(true)
setError('')
try {
const res = await api.post<{ user: PinUser; token: string }>('/v1/auth/pin-login', { code: submitCode })
unlock(res.user, res.token)
setCode('')
} catch {
setError('Invalid code')
setCode('')
} finally {
setLoading(false)
}
}, [unlock])
// Auto-submit when 8 digits entered
useEffect(() => {
if (code.length === 8) {
handleSubmit(code)
}
}, [code, handleSubmit])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key >= '0' && e.key <= '9') handleDigit(e.key)
else if (e.key === 'Backspace') handleBackspace()
else if (e.key === 'Enter' && code.length >= 8) handleSubmit(code)
else if (e.key === 'Escape') handleClear()
}, [handleDigit, handleBackspace, handleSubmit, handleClear, code])
return (
<div
ref={containerRef}
className="absolute inset-0 z-50 bg-background flex items-center justify-center"
onKeyDown={handleKeyDown}
tabIndex={0}
>
<div className="w-80 space-y-6">
{/* Header */}
<div className="text-center space-y-2">
<Lock className="h-10 w-10 mx-auto text-muted-foreground" />
<h1 className="text-xl font-semibold">POS Locked</h1>
<p className="text-sm text-muted-foreground">Employee # + PIN</p>
</div>
{/* Code dots — 4 employee + 4 PIN with separator */}
<div className="flex justify-center items-center gap-2">
<div className="flex gap-1.5">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={`e${i}`}
className={`w-3.5 h-3.5 rounded-full border-2 ${
i < Math.min(code.length, 4) ? 'bg-foreground border-foreground' : 'border-muted-foreground/30'
}`}
/>
))}
</div>
<span className="text-muted-foreground/40 text-lg">-</span>
<div className="flex gap-1.5">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={`p${i}`}
className={`w-3.5 h-3.5 rounded-full border-2 ${
i < Math.max(0, code.length - 4) ? 'bg-foreground border-foreground' : 'border-muted-foreground/30'
}`}
/>
))}
</div>
</div>
{/* Error */}
{error && (
<p className="text-sm text-destructive text-center">{error}</p>
)}
{/* Numpad */}
<div className="grid grid-cols-3 gap-2">
{['1', '2', '3', '4', '5', '6', '7', '8', '9'].map((d) => (
<Button
key={d}
variant="outline"
className="h-14 text-xl font-medium"
onClick={() => handleDigit(d)}
disabled={loading}
>
{d}
</Button>
))}
<Button
variant="outline"
className="h-14 text-sm"
onClick={handleClear}
disabled={loading}
>
Clear
</Button>
<Button
variant="outline"
className="h-14 text-xl font-medium"
onClick={() => handleDigit('0')}
disabled={loading}
>
0
</Button>
<Button
variant="outline"
className="h-14"
onClick={handleBackspace}
disabled={loading}
>
<Delete className="h-5 w-5" />
</Button>
</div>
{loading && (
<p className="text-sm text-muted-foreground text-center">Verifying...</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,201 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { api } from '@/lib/api-client'
import { Delete, ShieldCheck } from 'lucide-react'
interface ManagerOverrideDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
action: string
onAuthorized: () => void
}
interface PinUser {
id: string
role: string
firstName: string
lastName: string
}
export function ManagerOverrideDialog({ open, onOpenChange, action, onAuthorized }: ManagerOverrideDialogProps) {
const [code, setCode] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (open) {
setCode('')
setError('')
containerRef.current?.focus()
}
}, [open])
const handleDigit = useCallback((digit: string) => {
setError('')
setCode((p) => (p.length >= 10 ? p : p + digit))
}, [])
const handleBackspace = useCallback(() => {
setError('')
setCode((p) => p.slice(0, -1))
}, [])
const handleClear = useCallback(() => {
setError('')
setCode('')
}, [])
const handleSubmit = useCallback(async (submitCode: string) => {
if (submitCode.length < 8) {
setError('Enter manager employee # + PIN')
return
}
setLoading(true)
setError('')
try {
const res = await api.post<{ user: PinUser; token: string }>('/v1/auth/pin-login', { code: submitCode })
if (res.user.role === 'admin' || res.user.role === 'manager') {
onAuthorized()
onOpenChange(false)
} else {
setError('Manager or admin access required')
setCode('')
}
} catch {
setError('Invalid code')
setCode('')
} finally {
setLoading(false)
}
}, [onAuthorized, onOpenChange])
useEffect(() => {
if (code.length === 8) {
handleSubmit(code)
}
}, [code, handleSubmit])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key >= '0' && e.key <= '9') handleDigit(e.key)
else if (e.key === 'Backspace') handleBackspace()
else if (e.key === 'Enter' && code.length >= 8) handleSubmit(code)
else if (e.key === 'Escape') handleClear()
}, [handleDigit, handleBackspace, handleSubmit, handleClear, code])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xs">
<div
ref={containerRef}
onKeyDown={handleKeyDown}
tabIndex={0}
className="outline-none space-y-4"
>
<div className="text-center space-y-1">
<ShieldCheck className="h-8 w-8 mx-auto text-amber-500" />
<DialogTitle className="text-base">Manager Override</DialogTitle>
<p className="text-xs text-muted-foreground">{action}</p>
</div>
{/* Code dots */}
<div className="flex justify-center items-center gap-2">
<div className="flex gap-1.5">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={`e${i}`}
className={`w-3 h-3 rounded-full border-2 ${
i < Math.min(code.length, 4) ? 'bg-foreground border-foreground' : 'border-muted-foreground/30'
}`}
/>
))}
</div>
<span className="text-muted-foreground/40">-</span>
<div className="flex gap-1.5">
{Array.from({ length: 4 }).map((_, i) => (
<div
key={`p${i}`}
className={`w-3 h-3 rounded-full border-2 ${
i < Math.max(0, code.length - 4) ? 'bg-foreground border-foreground' : 'border-muted-foreground/30'
}`}
/>
))}
</div>
</div>
{error && <p className="text-xs text-destructive text-center">{error}</p>}
{/* Numpad */}
<div className="grid grid-cols-3 gap-1.5">
{['1', '2', '3', '4', '5', '6', '7', '8', '9'].map((d) => (
<Button key={d} variant="outline" className="h-12 text-lg font-medium" onClick={() => handleDigit(d)} disabled={loading}>
{d}
</Button>
))}
<Button variant="outline" className="h-12 text-xs" onClick={handleClear} disabled={loading}>Clear</Button>
<Button variant="outline" className="h-12 text-lg font-medium" onClick={() => handleDigit('0')} disabled={loading}>0</Button>
<Button variant="outline" className="h-12" onClick={handleBackspace} disabled={loading}>
<Delete className="h-4 w-4" />
</Button>
</div>
{loading && <p className="text-xs text-muted-foreground text-center">Verifying...</p>}
</div>
</DialogContent>
</Dialog>
)
}
// --- Config types & helpers ---
export const OVERRIDE_ACTIONS = [
{ key: 'void_transaction', label: 'Void Transaction', description: 'Cancel an in-progress sale' },
{ key: 'refund', label: 'Refund', description: 'Process a return or refund' },
{ key: 'manual_discount', label: 'Manual Discount', description: 'Apply a discount not from a preset' },
{ key: 'price_override', label: 'Price Override', description: 'Change an item price at the register' },
{ key: 'no_sale_drawer', label: 'No-Sale Drawer Open', description: 'Open the drawer without a transaction' },
{ key: 'cash_in_out', label: 'Cash In / Cash Out', description: 'Add or remove cash from the drawer' },
] as const
export type OverrideAction = typeof OVERRIDE_ACTIONS[number]['key']
const STORAGE_KEY = 'pos_manager_overrides'
export function getRequiredOverrides(): Set<OverrideAction> {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (!stored) return new Set()
return new Set(JSON.parse(stored) as OverrideAction[])
} catch {
return new Set()
}
}
export function setRequiredOverrides(actions: Set<OverrideAction>) {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...actions]))
}
export function requiresOverride(action: OverrideAction): boolean {
return getRequiredOverrides().has(action)
}
// Discount threshold — discounts above this percentage require manager override
const DISCOUNT_THRESHOLD_KEY = 'pos_discount_threshold_pct'
export function getDiscountThreshold(): number {
const stored = localStorage.getItem(DISCOUNT_THRESHOLD_KEY)
return stored ? parseInt(stored, 10) : 0 // 0 = disabled
}
export function setDiscountThreshold(pct: number) {
localStorage.setItem(DISCOUNT_THRESHOLD_KEY, String(pct))
}
export function requiresDiscountOverride(discountPct: number): boolean {
// Check percentage threshold first
const threshold = getDiscountThreshold()
if (threshold > 0 && discountPct >= threshold) return true
// Fall back to the blanket manual_discount toggle
return requiresOverride('manual_discount')
}

View File

@@ -1,14 +1,16 @@
import { useState } from 'react' import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { usePOSStore } from '@/stores/pos.store' import { usePOSStore } from '@/stores/pos.store'
import { api } from '@/lib/api-client'
import { posMutations, posKeys, type Transaction } from '@/api/pos' import { posMutations, posKeys, type Transaction } from '@/api/pos'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { CheckCircle } from 'lucide-react' import { CheckCircle, Printer, Mail } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { POSReceipt, downloadReceiptPDF, printReceipt } from './pos-receipt'
interface POSPaymentDialogProps { interface POSPaymentDialogProps {
open: boolean open: boolean
@@ -42,6 +44,7 @@ export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transactio
}, },
onSuccess: (txn) => { onSuccess: (txn) => {
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) }) queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
queryClient.invalidateQueries({ queryKey: ['pos', 'products'] })
setResult(txn) setResult(txn)
setCompleted(true) setCompleted(true)
}, },
@@ -61,9 +64,60 @@ export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transactio
const QUICK_AMOUNTS = [1, 5, 10, 20, 50, 100] const QUICK_AMOUNTS = [1, 5, 10, 20, 50, 100]
// Fetch receipt config
interface AppConfigEntry { key: string; value: string | null }
const { data: configData } = useQuery({
queryKey: ['config'],
queryFn: () => api.get<{ data: AppConfigEntry[] }>('/v1/config'),
enabled: !!result?.id,
})
const receiptFormat = usePOSStore((s) => s.receiptFormat)
const receiptConfig = {
header: configData?.data?.find((c) => c.key === 'receipt_header')?.value || undefined,
footer: configData?.data?.find((c) => c.key === 'receipt_footer')?.value || undefined,
returnPolicy: configData?.data?.find((c) => c.key === 'receipt_return_policy')?.value || undefined,
social: configData?.data?.find((c) => c.key === 'receipt_social')?.value || undefined,
}
// Fetch full receipt data after completion
const { data: receiptData } = useQuery({
queryKey: ['pos', 'receipt', result?.id],
queryFn: () => api.get<{
transaction: Transaction & { lineItems: { description: string; qty: number; unitPrice: string; taxAmount: string; lineTotal: string; discountAmount: string }[] }
company: { name: string; phone: string | null; email: string | null; address: { street?: string; city?: string; state?: string; zip?: string } | null }
location: { name: string; phone: string | null; email: string | null; address: { street?: string; city?: string; state?: string; zip?: string } | null }
}>(`/v1/transactions/${result!.id}/receipt`),
enabled: !!result?.id,
})
const [showReceipt, setShowReceipt] = useState(false)
if (completed && result) { if (completed && result) {
const changeGiven = parseFloat(result.changeGiven ?? '0') const changeGiven = parseFloat(result.changeGiven ?? '0')
const roundingAdj = parseFloat(result.roundingAdjustment ?? '0')
// Receipt print view
if (showReceipt && receiptData) {
return (
<Dialog open={open} onOpenChange={() => { setShowReceipt(false); handleDone() }}>
<DialogContent className={`${receiptFormat === 'full' ? 'max-w-2xl' : 'max-w-sm'} max-h-[90vh] overflow-y-auto print:max-w-none print:max-h-none print:overflow-visible print:shadow-none print:border-none`}>
<div className="flex justify-between items-center mb-2">
<Button variant="ghost" size="sm" onClick={() => setShowReceipt(false)}>Back</Button>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => downloadReceiptPDF(result.transactionNumber, receiptFormat)} className="gap-2">
Save PDF
</Button>
<Button size="sm" onClick={printReceipt} className="gap-2">
<Printer className="h-4 w-4" />Print
</Button>
</div>
</div>
<div id="pos-receipt-print">
<POSReceipt data={receiptData} size={receiptFormat} config={receiptConfig} />
</div>
</DialogContent>
</Dialog>
)
}
return ( return (
<Dialog open={open} onOpenChange={() => handleDone()}> <Dialog open={open} onOpenChange={() => handleDone()}>
@@ -78,26 +132,21 @@ export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transactio
<span>Total</span> <span>Total</span>
<span>${parseFloat(result.total).toFixed(2)}</span> <span>${parseFloat(result.total).toFixed(2)}</span>
</div> </div>
{roundingAdj !== 0 && ( {paymentMethod === 'cash' && changeGiven > 0 && (
<div className="flex justify-between text-muted-foreground"> <div className="flex justify-between text-lg font-bold text-green-600">
<span>Rounding</span> <span>Change Due</span>
<span>{roundingAdj > 0 ? '+' : ''}{roundingAdj.toFixed(2)}</span> <span>${changeGiven.toFixed(2)}</span>
</div> </div>
)} )}
{paymentMethod === 'cash' && ( </div>
<>
<div className="flex justify-between"> <div className="w-full grid grid-cols-2 gap-2">
<span>Tendered</span> <Button variant="outline" className="h-11 gap-2" onClick={() => setShowReceipt(true)}>
<span>${parseFloat(result.amountTendered ?? '0').toFixed(2)}</span> <Printer className="h-4 w-4" />Receipt
</div> </Button>
{changeGiven > 0 && ( <Button variant="outline" className="h-11 gap-2" disabled>
<div className="flex justify-between text-lg font-bold text-green-600"> <Mail className="h-4 w-4" />Email
<span>Change Due</span> </Button>
<span>${changeGiven.toFixed(2)}</span>
</div>
)}
</>
)}
</div> </div>
<Button className="w-full h-12 text-base" onClick={handleDone}> <Button className="w-full h-12 text-base" onClick={handleDone}>

View File

@@ -0,0 +1,443 @@
import { useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { usePOSStore } from '@/stores/pos.store'
import JsBarcode from 'jsbarcode'
interface ReceiptLineItem {
description: string
qty: number
unitPrice: string
taxAmount: string
lineTotal: string
discountAmount?: string
}
interface ReceiptData {
transaction: {
transactionNumber: string
transactionType: string
status: string
subtotal: string
discountTotal: string
taxTotal: string
total: string
paymentMethod: string | null
amountTendered: string | null
changeGiven: string | null
roundingAdjustment: string
completedAt: string | null
createdAt: string
lineItems: ReceiptLineItem[]
}
company: {
name: string
phone: string | null
email: string | null
address: { street?: string; city?: string; state?: string; zip?: string } | null
}
location: {
name: string
phone: string | null
email: string | null
address: { street?: string; city?: string; state?: string; zip?: string } | null
}
}
interface ReceiptConfig {
header?: string
footer?: string
returnPolicy?: string
social?: string
}
interface POSReceiptProps {
data: ReceiptData
size?: 'thermal' | 'full'
footerText?: string
config?: ReceiptConfig
}
function useStoreLogo(companyId?: string) {
const token = usePOSStore((s) => s.token)
const [logoSrc, setLogoSrc] = useState<string | null>(null)
const { data: storeData } = useQuery(queryOptions({
queryKey: ['store'],
queryFn: () => api.get<{ id: string }>('/v1/store'),
enabled: !!token,
}))
const storeId = companyId ?? storeData?.id
const { data: filesData } = useQuery(queryOptions({
queryKey: ['files', 'company', storeId ?? ''],
queryFn: () => api.get<{ data: { id: string; path: string }[] }>('/v1/files', { entityType: 'company', entityId: storeId }),
enabled: !!storeId,
}))
const logoFile = filesData?.data?.find((f) => f.path.includes('/logo-'))
useEffect(() => {
if (!logoFile || !token) { setLogoSrc(null); return }
let cancelled = false
let blobUrl: string | null = null
async function load() {
try {
const res = await fetch(`/v1/files/serve/${logoFile!.path}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok || cancelled) return
const blob = await res.blob()
if (!cancelled) { blobUrl = URL.createObjectURL(blob); setLogoSrc(blobUrl) }
} catch { /* ignore */ }
}
load()
return () => { cancelled = true; if (blobUrl) URL.revokeObjectURL(blobUrl) }
}, [logoFile?.path, token])
return logoSrc
}
export function POSReceipt({ data, size = 'thermal', footerText, config }: POSReceiptProps) {
const isThermal = size === 'thermal'
if (!isThermal) {
return <FullPageReceipt data={data} config={config} footerText={footerText} />
}
return <ThermalReceipt data={data} config={config} footerText={footerText} />
}
function useReceiptData(data: POSReceiptProps['data']) {
const { transaction: txn, company, location } = data
return {
txn,
company,
location,
date: new Date(txn.completedAt ?? txn.createdAt),
subtotal: parseFloat(txn.subtotal),
discountTotal: parseFloat(txn.discountTotal),
taxTotal: parseFloat(txn.taxTotal),
total: parseFloat(txn.total),
rounding: parseFloat(txn.roundingAdjustment),
tendered: txn.amountTendered ? parseFloat(txn.amountTendered) : null,
change: txn.changeGiven ? parseFloat(txn.changeGiven) : null,
addr: location.address ?? company.address,
phone: location.phone ?? company.phone,
email: location.email ?? company.email,
}
}
function useBarcode(ref: React.RefObject<SVGSVGElement | null>, value: string, opts: { width: number; height: number; fontSize: number }) {
useEffect(() => {
if (ref.current) {
try {
JsBarcode(ref.current, value, { format: 'CODE128', displayValue: true, margin: 4, ...opts })
} catch { /* barcode generation failed — show text fallback */ }
}
}, [value])
}
function ThermalReceipt({ data, config, footerText }: { data: POSReceiptProps['data']; config?: ReceiptConfig; footerText?: string }) {
const barcodeRef = useRef<SVGSVGElement>(null)
const logoSrc = useStoreLogo()
const { txn, company, location, date, subtotal, discountTotal, taxTotal, total, rounding, tendered, change, addr, phone } = useReceiptData(data)
useBarcode(barcodeRef, txn.transactionNumber, { width: 1.5, height: 40, fontSize: 10 })
const s = {
row: { display: 'flex', justifyContent: 'space-between' } as const,
section: { padding: '8px 0', borderBottom: '1px dashed #999' } as const,
gray: { color: '#666' } as const,
light: { color: '#999' } as const,
bold: { fontWeight: 'bold' } as const,
center: { textAlign: 'center' } as const,
nums: { fontVariantNumeric: 'tabular-nums' } as const,
}
return (
<div style={{
background: '#fff', color: '#000', fontFamily: 'monospace',
width: 260, maxWidth: 260, fontSize: 10, lineHeight: '1.3', margin: '0 auto',
}}>
{/* Store header */}
<div style={{ ...s.section, ...s.center }}>
{logoSrc ? (
<img src={logoSrc} alt={company.name} style={{ display: 'block', margin: '0 auto 4px', maxHeight: 48, maxWidth: 200, objectFit: 'contain' }} />
) : (
<div style={{ ...s.bold, fontSize: 14 }}>{company.name}</div>
)}
{location.name !== company.name && <div style={s.gray}>{location.name}</div>}
{addr?.street && <div>{addr.street}</div>}
{(addr?.city || addr?.state || addr?.zip) && (
<div>{[addr?.city, addr?.state].filter(Boolean).join(', ')} {addr?.zip}</div>
)}
{phone && <div>{phone}</div>}
{config?.header && <div style={{ ...s.gray, marginTop: 4 }}>{config.header}</div>}
</div>
{/* Transaction info */}
<div style={s.section}>
<div style={s.row}>
<span>{txn.transactionNumber}</span>
<span>{txn.transactionType.replace('_', ' ')}</span>
</div>
<div style={{ ...s.row, ...s.gray }}>
<span>{date.toLocaleDateString()}</span>
<span>{date.toLocaleTimeString()}</span>
</div>
</div>
{/* Line items */}
<div style={s.section}>
{txn.lineItems.map((item, i) => (
<div key={i} style={{ padding: '2px 0' }}>
<div style={s.row}>
<span style={{ flex: 1, paddingRight: 8 }}>{item.description}</span>
<span style={s.nums}>${parseFloat(item.lineTotal).toFixed(2)}</span>
</div>
{(item.qty > 1 || parseFloat(item.discountAmount ?? '0') > 0) && (
<div style={{ ...s.light, paddingLeft: 8 }}>
{item.qty > 1 && <span>{item.qty} x ${parseFloat(item.unitPrice).toFixed(2)}</span>}
{parseFloat(item.discountAmount ?? '0') > 0 && (
<span style={{ marginLeft: 8 }}>disc -${parseFloat(item.discountAmount!).toFixed(2)}</span>
)}
</div>
)}
</div>
))}
</div>
{/* Totals */}
<div style={s.section}>
<div style={s.row}><span>Subtotal</span><span style={s.nums}>${subtotal.toFixed(2)}</span></div>
{discountTotal > 0 && (
<div style={s.row}><span>Discount</span><span style={s.nums}>-${discountTotal.toFixed(2)}</span></div>
)}
<div style={s.row}><span>Tax</span><span style={s.nums}>${taxTotal.toFixed(2)}</span></div>
{rounding !== 0 && (
<div style={{ ...s.row, ...s.gray }}><span>Rounding</span><span style={s.nums}>{rounding > 0 ? '+' : ''}{rounding.toFixed(2)}</span></div>
)}
<div style={{ ...s.row, ...s.bold, fontSize: 14, paddingTop: 4 }}>
<span>TOTAL</span><span style={s.nums}>${total.toFixed(2)}</span>
</div>
</div>
{/* Payment */}
<div style={s.section}>
<div style={s.row}><span>Payment</span><span style={{ textTransform: 'capitalize' }}>{txn.paymentMethod?.replace('_', ' ') ?? 'N/A'}</span></div>
{tendered !== null && (
<div style={s.row}><span>Tendered</span><span style={s.nums}>${tendered.toFixed(2)}</span></div>
)}
{change !== null && change > 0 && (
<div style={{ ...s.row, ...s.bold }}><span>Change</span><span style={s.nums}>${change.toFixed(2)}</span></div>
)}
</div>
{/* Barcode */}
<div style={{ display: 'flex', justifyContent: 'center', padding: '12px 0' }}>
<svg ref={barcodeRef} />
</div>
{/* Footer */}
{(config?.footer || footerText) && (
<div style={{ ...s.center, ...s.light, paddingBottom: 4 }}>{config?.footer || footerText}</div>
)}
{config?.returnPolicy && (
<div style={{ ...s.center, color: '#aaa', fontSize: 10, paddingBottom: 4 }}>{config.returnPolicy}</div>
)}
{config?.social && (
<div style={{ ...s.center, ...s.light, paddingBottom: 8 }}>{config.social}</div>
)}
</div>
)
}
function FullPageReceipt({ data, config, footerText }: { data: POSReceiptProps['data']; config?: ReceiptConfig; footerText?: string }) {
const barcodeRef = useRef<SVGSVGElement>(null)
const logoSrc = useStoreLogo()
const { txn, company, location, date, subtotal, discountTotal, taxTotal, total, rounding, tendered, change, addr, phone, email } = useReceiptData(data)
useBarcode(barcodeRef, txn.transactionNumber, { width: 2, height: 50, fontSize: 12 })
const f = (n: number) => `$${n.toFixed(2)}`
return (
<div style={{
background: '#fff', color: '#000', fontFamily: 'Helvetica, Arial, sans-serif',
width: '100%', maxWidth: 600, margin: '0 auto', fontSize: 13, lineHeight: '1.5',
}}>
{/* Header — company left, transaction right */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', paddingBottom: 16 }}>
<div>
{logoSrc ? (
<img src={logoSrc} alt={company.name} style={{ maxHeight: 56, maxWidth: 200, objectFit: 'contain', marginBottom: 4 }} />
) : (
<div style={{ fontSize: 20, fontWeight: 'bold' }}>{company.name}</div>
)}
{location.name !== company.name && <div style={{ color: '#555', fontSize: 12 }}>{location.name}</div>}
{addr?.street && <div style={{ fontSize: 12 }}>{addr.street}</div>}
{(addr?.city || addr?.state || addr?.zip) && (
<div style={{ fontSize: 12 }}>{[addr?.city, addr?.state].filter(Boolean).join(', ')} {addr?.zip}</div>
)}
{phone && <div style={{ fontSize: 12 }}>{phone}</div>}
{email && <div style={{ fontSize: 12 }}>{email}</div>}
{config?.header && <div style={{ fontSize: 12, color: '#555', marginTop: 2 }}>{config.header}</div>}
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 16, fontWeight: 'bold' }}>{txn.transactionNumber}</div>
<div style={{ fontSize: 12, color: '#555', textTransform: 'capitalize' }}>{txn.transactionType.replace('_', ' ')}</div>
<div style={{ fontSize: 12, color: '#555' }}>{date.toLocaleDateString()}</div>
<div style={{ fontSize: 12, color: '#555' }}>{date.toLocaleTimeString()}</div>
</div>
</div>
{/* Divider */}
<div style={{ borderBottom: '1px solid #ddd', marginBottom: 16 }} />
{/* Line items table */}
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f5f5f5' }}>
<th style={{ textAlign: 'left', padding: '6px 8px', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', color: '#555' }}>Item</th>
<th style={{ textAlign: 'right', padding: '6px 8px', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', color: '#555', width: 50 }}>Qty</th>
<th style={{ textAlign: 'right', padding: '6px 8px', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', color: '#555', width: 80 }}>Price</th>
<th style={{ textAlign: 'right', padding: '6px 8px', fontWeight: 600, fontSize: 11, textTransform: 'uppercase', color: '#555', width: 80 }}>Total</th>
</tr>
</thead>
<tbody>
{txn.lineItems.map((item, i) => (
<tr key={i} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '6px 8px' }}>
{item.description}
{parseFloat(item.discountAmount ?? '0') > 0 && (
<div style={{ fontSize: 11, color: '#999' }}>Discount: -{f(parseFloat(item.discountAmount!))}</div>
)}
</td>
<td style={{ padding: '6px 8px', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{item.qty}</td>
<td style={{ padding: '6px 8px', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{f(parseFloat(item.unitPrice))}</td>
<td style={{ padding: '6px 8px', textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{f(parseFloat(item.lineTotal))}</td>
</tr>
))}
</tbody>
</table>
{/* Totals — right aligned */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
<div style={{ width: 220 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0' }}>
<span>Subtotal</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>{f(subtotal)}</span>
</div>
{discountTotal > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', color: '#555' }}>
<span>Discount</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>-{f(discountTotal)}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0' }}>
<span>Tax</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>{f(taxTotal)}</span>
</div>
{rounding !== 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', color: '#888', fontSize: 12 }}>
<span>Rounding</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>{rounding > 0 ? '+' : ''}{rounding.toFixed(2)}</span>
</div>
)}
<div style={{ borderTop: '1px solid #ddd', marginTop: 4, paddingTop: 6, display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', fontSize: 16 }}>
<span>Total</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>{f(total)}</span>
</div>
</div>
</div>
{/* Payment info */}
<div style={{ marginTop: 16, padding: '12px 0', borderTop: '1px solid #ddd' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>Payment Method</span>
<span style={{ textTransform: 'capitalize', fontWeight: 500 }}>{txn.paymentMethod?.replace('_', ' ') ?? 'N/A'}</span>
</div>
{tendered !== null && (
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#555', fontSize: 12, marginTop: 2 }}>
<span>Tendered</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>{f(tendered)}</span>
</div>
)}
{change !== null && change > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', marginTop: 2 }}>
<span>Change</span><span style={{ fontVariantNumeric: 'tabular-nums' }}>{f(change)}</span>
</div>
)}
</div>
{/* Barcode */}
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
<svg ref={barcodeRef} />
</div>
{/* Footer */}
<div style={{ borderTop: '1px solid #eee', paddingTop: 12, textAlign: 'center', fontSize: 11, color: '#999' }}>
{(config?.footer || footerText) && <div>{config?.footer || footerText}</div>}
{config?.returnPolicy && <div style={{ marginTop: 4 }}>{config.returnPolicy}</div>}
{config?.social && <div style={{ marginTop: 4 }}>{config.social}</div>}
</div>
</div>
)
}
export function printReceipt() {
const el = document.getElementById('pos-receipt-print')
if (!el) return
// Clone the receipt into an iframe for clean printing
const iframe = document.createElement('iframe')
iframe.style.cssText = 'position:fixed;left:-9999px;width:400px;height:800px;border:none;'
document.body.appendChild(iframe)
const doc = iframe.contentDocument
if (!doc) { document.body.removeChild(iframe); return }
doc.open()
doc.write(`<!DOCTYPE html><html><head><style>body{margin:0;padding:8px;background:#fff;}</style></head><body>${el.innerHTML}</body></html>`)
doc.close()
// Wait for content to render then print
setTimeout(() => {
try {
iframe.contentWindow?.focus()
iframe.contentWindow?.print()
} catch {
// Fallback: just use window.print
window.print()
}
setTimeout(() => document.body.removeChild(iframe), 2000)
}, 300)
}
export async function downloadReceiptPDF(txnNumber?: string, format: 'thermal' | 'full' = 'thermal') {
const el = document.getElementById('pos-receipt-print')
if (!el) return
const html2pdf = (await import('html2pdf.js')).default
if (format === 'full') {
html2pdf()
.set({
margin: [10, 10, 10, 10],
filename: `receipt-${txnNumber ?? 'unknown'}.pdf`,
image: { type: 'jpeg', quality: 0.95 },
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: 'mm', format: 'letter', orientation: 'portrait' },
})
.from(el)
.save()
} else {
// Thermal — dynamic height based on content
const heightPx = el.scrollHeight + 16
const heightMm = Math.ceil(heightPx * 0.265) + 8
html2pdf()
.set({
margin: [2, 2, 2, 2],
filename: `receipt-${txnNumber ?? 'unknown'}.pdf`,
image: { type: 'jpeg', quality: 0.95 },
html2canvas: { scale: 2, useCORS: true, width: 280 },
jsPDF: { unit: 'mm', format: [72, heightMm], orientation: 'portrait' },
})
.from(el)
.save()
}
}

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react' import { useEffect, useRef, useCallback } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { queryOptions } from '@tanstack/react-query' import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
@@ -7,12 +7,18 @@ import { currentDrawerOptions, transactionOptions } from '@/api/pos'
import { POSTopBar } from './pos-top-bar' import { POSTopBar } from './pos-top-bar'
import { POSItemPanel } from './pos-item-panel' import { POSItemPanel } from './pos-item-panel'
import { POSCartPanel } from './pos-cart-panel' import { POSCartPanel } from './pos-cart-panel'
import { POSLockScreen } from './pos-lock-screen'
interface Location { interface Location {
id: string id: string
name: string name: string
} }
interface AppConfigEntry {
key: string
value: string | null
}
function locationsOptions() { function locationsOptions() {
return queryOptions({ return queryOptions({
queryKey: ['locations'], queryKey: ['locations'],
@@ -20,11 +26,61 @@ function locationsOptions() {
}) })
} }
function configOptions(key: string) {
return queryOptions({
queryKey: ['config', key],
queryFn: async (): Promise<string | null> => {
try {
const entry = await api.get<AppConfigEntry>(`/v1/config/${key}`)
return entry.value
} catch {
return null
}
},
})
}
export function POSRegister() { export function POSRegister() {
const { locationId, setLocation, currentTransactionId, setDrawerSession } = usePOSStore() const { locationId, setLocation, currentTransactionId, setDrawerSession, locked, lock, touchActivity, token } = usePOSStore()
// Fetch lock timeout from config
const { data: lockTimeoutStr } = useQuery({
...configOptions('pos_lock_timeout'),
enabled: !!token,
})
const lockTimeoutMinutes = parseInt(lockTimeoutStr ?? '15') || 15
// Auto-lock timer
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
if (locked || lockTimeoutMinutes === 0) {
if (timerRef.current) clearInterval(timerRef.current)
return
}
timerRef.current = setInterval(() => {
const { lastActivity, locked: isLocked } = usePOSStore.getState()
if (!isLocked && Date.now() - lastActivity > lockTimeoutMinutes * 60_000) {
lock()
}
}, 30_000)
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [locked, lockTimeoutMinutes, lock])
// Track activity on any interaction
const handleActivity = useCallback(() => {
if (!locked) touchActivity()
}, [locked, touchActivity])
// Fetch locations // Fetch locations
const { data: locationsData } = useQuery(locationsOptions()) const { data: locationsData } = useQuery({
...locationsOptions(),
enabled: !!token,
})
const locations = locationsData?.data ?? [] const locations = locationsData?.data ?? []
// Auto-select first location // Auto-select first location
@@ -38,20 +94,31 @@ export function POSRegister() {
const { data: drawer } = useQuery({ const { data: drawer } = useQuery({
...currentDrawerOptions(locationId), ...currentDrawerOptions(locationId),
retry: false, retry: false,
enabled: !!locationId && !!token,
}) })
// Sync drawer session ID // Sync drawer session ID
useEffect(() => { useEffect(() => {
if (drawer?.id && drawer.status === 'open') { if (drawer?.id && drawer.status === 'open') {
setDrawerSession(drawer.id) setDrawerSession(drawer.id)
} else {
setDrawerSession(null)
} }
}, [drawer, setDrawerSession]) }, [drawer, setDrawerSession])
// Fetch current transaction // Fetch current transaction
const { data: transaction } = useQuery(transactionOptions(currentTransactionId)) const { data: transaction } = useQuery({
...transactionOptions(currentTransactionId),
enabled: !!currentTransactionId && !!token,
})
return ( return (
<div className="flex flex-col h-full"> <div
className="relative flex flex-col h-full"
onPointerDown={handleActivity}
onKeyDown={handleActivity}
>
{locked && <POSLockScreen />}
<POSTopBar <POSTopBar
locations={locations} locations={locations}
locationId={locationId} locationId={locationId}

View File

@@ -0,0 +1,263 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { usePOSStore } from '@/stores/pos.store'
import { api } from '@/lib/api-client'
import { posMutations, posKeys } from '@/api/pos'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Search, Wrench, Plus } from 'lucide-react'
import { toast } from 'sonner'
interface Account {
id: string
name: string
email: string | null
phone: string | null
accountNumber: string | null
}
interface RepairTicketSummary {
id: string
ticketNumber: string | null
customerName: string
customerPhone: string | null
itemDescription: string | null
estimatedCost: string | null
status: string
}
interface POSRepairDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function POSRepairDialog({ open, onOpenChange }: POSRepairDialogProps) {
const queryClient = useQueryClient()
const { locationId, setTransaction, setAccount } = usePOSStore()
const [search, setSearch] = useState('')
const [tab, setTab] = useState('pickup')
// --- Pickup tab ---
const { data, isLoading } = useQuery({
queryKey: ['pos', 'repair-tickets-ready', search],
queryFn: () => api.get<{ data: RepairTicketSummary[] }>('/v1/repair-tickets/ready', { q: search || undefined, limit: 20 }),
enabled: open && tab === 'pickup',
})
const tickets = data?.data ?? []
const pickupMutation = useMutation({
mutationFn: (ticketId: string) => posMutations.createFromRepair(ticketId, locationId ?? undefined),
onSuccess: (txn) => {
setTransaction(txn.id)
if (txn.accountId) {
const ticket = tickets.find((t) => t.id === pickupMutation.variables)
if (ticket) setAccount(txn.accountId, ticket.customerName, ticket.customerPhone)
}
queryClient.invalidateQueries({ queryKey: posKeys.transaction(txn.id) })
toast.success(`Repair payment loaded — ${txn.transactionNumber}`)
close()
},
onError: (err) => toast.error(err.message),
})
// --- New intake tab ---
const [customerName, setCustomerName] = useState('')
const [customerPhone, setCustomerPhone] = useState('')
const [itemDescription, setItemDescription] = useState('')
const [problemDescription, setProblemDescription] = useState('')
const [estimatedCost, setEstimatedCost] = useState('')
const [accountId, setAccountId] = useState<string | null>(null)
const [customerSearch, setCustomerSearch] = useState('')
const [showCustomers, setShowCustomers] = useState(false)
const { data: customerData } = useQuery({
queryKey: ['pos', 'accounts', customerSearch],
queryFn: () => api.get<{ data: Account[] }>('/v1/accounts', { q: customerSearch, limit: 10 }),
enabled: customerSearch.length >= 2 && tab === 'intake',
})
const customerResults = customerData?.data ?? []
function selectCustomer(account: Account) {
setAccountId(account.id)
setCustomerName(account.name)
setCustomerPhone(account.phone ?? '')
setCustomerSearch('')
setShowCustomers(false)
}
function clearCustomer() {
setAccountId(null)
setCustomerName('')
setCustomerPhone('')
}
const intakeMutation = useMutation({
mutationFn: (data: Record<string, unknown>) =>
api.post<{ id: string; ticketNumber: string }>('/v1/repair-tickets', data),
onSuccess: (ticket) => {
toast.success(`Repair ticket #${ticket.ticketNumber} created`)
close()
},
onError: (err) => toast.error(err.message),
})
function handleIntakeSubmit(e: React.FormEvent) {
e.preventDefault()
intakeMutation.mutate({
customerName,
customerPhone: customerPhone || undefined,
accountId: accountId ?? undefined,
itemDescription: itemDescription || undefined,
problemDescription,
estimatedCost: estimatedCost ? parseFloat(estimatedCost) : undefined,
locationId: locationId ?? undefined,
})
}
function close() {
onOpenChange(false)
setSearch('')
setCustomerName('')
setCustomerPhone('')
setItemDescription('')
setProblemDescription('')
setEstimatedCost('')
setAccountId(null)
setCustomerSearch('')
setShowCustomers(false)
}
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) close(); else onOpenChange(true) }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Wrench className="h-5 w-5" />Repairs
</DialogTitle>
</DialogHeader>
<Tabs value={tab} onValueChange={setTab}>
<TabsList className="w-full">
<TabsTrigger value="pickup" className="flex-1">Pickup</TabsTrigger>
<TabsTrigger value="intake" className="flex-1">New Intake</TabsTrigger>
</TabsList>
<TabsContent value="pickup" className="mt-3 space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by ticket #, name, or phone..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
autoFocus={tab === 'pickup'}
/>
</div>
<div className="max-h-64 overflow-y-auto space-y-1">
{isLoading ? (
<p className="text-sm text-muted-foreground text-center py-4">Loading...</p>
) : tickets.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
{search ? 'No ready tickets found' : 'No tickets ready for pickup'}
</p>
) : (
tickets.map((ticket) => (
<button
key={ticket.id}
type="button"
className="w-full text-left rounded-md border p-3 hover:bg-accent transition-colors"
onClick={() => pickupMutation.mutate(ticket.id)}
disabled={pickupMutation.isPending}
>
<div className="flex items-center justify-between">
<span className="font-medium text-sm">#{ticket.ticketNumber}</span>
<Badge variant="outline" className="text-xs">Ready</Badge>
</div>
<div className="text-sm mt-0.5">{ticket.customerName}</div>
{ticket.itemDescription && (
<div className="text-xs text-muted-foreground mt-0.5 truncate">{ticket.itemDescription}</div>
)}
{ticket.estimatedCost && (
<div className="text-xs text-muted-foreground mt-0.5">Est: ${ticket.estimatedCost}</div>
)}
</button>
))
)}
</div>
</TabsContent>
<TabsContent value="intake" className="mt-3">
<form onSubmit={handleIntakeSubmit} className="space-y-3">
{/* Customer lookup */}
<div className="relative space-y-1">
<Label className="text-xs">Customer Lookup</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Search by name, phone, or email..."
value={customerSearch}
onChange={(e) => { setCustomerSearch(e.target.value); setShowCustomers(true) }}
onFocus={() => customerSearch.length >= 2 && setShowCustomers(true)}
className="pl-9 h-8 text-sm"
/>
</div>
{accountId && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Badge variant="secondary" className="text-[10px]">Linked</Badge>
<span>{customerName}</span>
<button type="button" className="underline text-destructive ml-1" onClick={clearCustomer}>clear</button>
</div>
)}
{showCustomers && customerSearch.length >= 2 && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-40 overflow-auto">
{customerResults.length === 0 ? (
<div className="p-2 text-xs text-muted-foreground">No accounts found</div>
) : customerResults.map((a) => (
<button key={a.id} type="button" className="w-full text-left px-3 py-2 text-sm hover:bg-accent" onClick={() => selectCustomer(a)}>
<div className="font-medium">{a.name}</div>
<div className="text-xs text-muted-foreground">{[a.phone, a.email].filter(Boolean).join(' · ')}</div>
</button>
))}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Customer Name *</Label>
<Input value={customerName} onChange={(e) => setCustomerName(e.target.value)} required />
</div>
<div className="space-y-1">
<Label className="text-xs">Phone</Label>
<Input value={customerPhone} onChange={(e) => setCustomerPhone(e.target.value)} />
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Item Description</Label>
<Input value={itemDescription} onChange={(e) => setItemDescription(e.target.value)} placeholder="e.g. Violin, iPhone 12, Trek bicycle" />
</div>
<div className="space-y-1">
<Label className="text-xs">Problem *</Label>
<Textarea value={problemDescription} onChange={(e) => setProblemDescription(e.target.value)} rows={2} placeholder="What needs to be fixed?" required />
</div>
<div className="space-y-1">
<Label className="text-xs">Estimated Cost</Label>
<Input type="number" step="0.01" min="0" value={estimatedCost} onChange={(e) => setEstimatedCost(e.target.value)} placeholder="0.00" />
</div>
<Button type="submit" className="w-full gap-2" disabled={intakeMutation.isPending}>
<Plus className="h-4 w-4" />
{intakeMutation.isPending ? 'Creating...' : 'Create Repair Ticket'}
</Button>
</form>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,10 +1,9 @@
import { Link, useRouter } from '@tanstack/react-router' import { Link } from '@tanstack/react-router'
import { useAuthStore } from '@/stores/auth.store' import { usePOSStore } from '@/stores/pos.store'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { ArrowLeft, LogOut, DollarSign } from 'lucide-react' import { ArrowLeft, Lock, DollarSign, Receipt, FileText } from 'lucide-react'
import type { DrawerSession } from '@/api/pos' import type { DrawerSession } from '@/api/pos'
import { useState } from 'react' import { useState } from 'react'
import { POSDrawerDialog } from './pos-drawer-dialog' import { POSDrawerDialog } from './pos-drawer-dialog'
@@ -17,24 +16,21 @@ interface POSTopBarProps {
} }
export function POSTopBar({ locations, locationId, onLocationChange, drawer }: POSTopBarProps) { export function POSTopBar({ locations, locationId, onLocationChange, drawer }: POSTopBarProps) {
const router = useRouter() const cashier = usePOSStore((s) => s.cashier)
const user = useAuthStore((s) => s.user) const lockFn = usePOSStore((s) => s.lock)
const logout = useAuthStore((s) => s.logout) const receiptFormat = usePOSStore((s) => s.receiptFormat)
const setReceiptFormat = usePOSStore((s) => s.setReceiptFormat)
const [drawerDialogOpen, setDrawerDialogOpen] = useState(false) const [drawerDialogOpen, setDrawerDialogOpen] = useState(false)
const drawerOpen = drawer?.status === 'open' const drawerOpen = drawer?.status === 'open'
const isThermal = receiptFormat === 'thermal'
function handleLogout() {
logout()
router.navigate({ to: '/login', replace: true })
}
return ( return (
<> <>
<div className="h-12 border-b border-border bg-card flex items-center justify-between px-3 shrink-0"> <div className="h-12 border-b border-border bg-card flex items-center justify-between px-3 shrink-0">
{/* Left: back + location */} {/* Left: back + location */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link to="/" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"> <Link to="/login" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">Admin</span> <span className="hidden sm:inline">Admin</span>
</Link> </Link>
@@ -53,6 +49,17 @@ export function POSTopBar({ locations, locationId, onLocationChange, drawer }: P
) : locations.length === 1 ? ( ) : locations.length === 1 ? (
<span className="text-sm font-medium">{locations[0].name}</span> <span className="text-sm font-medium">{locations[0].name}</span>
) : null} ) : null}
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setReceiptFormat(isThermal ? 'full' : 'thermal')}
title={isThermal ? 'Receipt: Thermal — click to switch to Full Page' : 'Receipt: Full Page — click to switch to Thermal'}
>
{isThermal ? <Receipt className="h-3.5 w-3.5" /> : <FileText className="h-3.5 w-3.5" />}
<span className="hidden sm:inline">{isThermal ? 'Thermal' : 'Full Page'}</span>
</Button>
</div> </div>
{/* Center: drawer status */} {/* Center: drawer status */}
@@ -64,19 +71,19 @@ export function POSTopBar({ locations, locationId, onLocationChange, drawer }: P
> >
<DollarSign className="h-4 w-4" /> <DollarSign className="h-4 w-4" />
{drawerOpen ? ( {drawerOpen ? (
<Badge variant="default" className="text-xs"> <Badge variant="default" className="text-xs">Drawer Open</Badge>
Drawer Open &mdash; ${parseFloat(drawer!.openingBalance).toFixed(2)}
</Badge>
) : ( ) : (
<Badge variant="outline" className="text-xs">Drawer Closed</Badge> <Badge variant="outline" className="text-xs">Drawer Closed</Badge>
)} )}
</Button> </Button>
{/* Right: user + logout */} {/* Right: cashier + lock */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{user?.firstName}</span> {cashier && (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={handleLogout} title="Sign out"> <span className="text-sm text-muted-foreground">{cashier.firstName}</span>
<LogOut className="h-4 w-4" /> )}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={lockFn} title="Lock POS">
<Lock className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,145 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { usePOSStore } from '@/stores/pos.store'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Printer } from 'lucide-react'
import { POSReceipt, printReceipt, downloadReceiptPDF } from './pos-receipt'
import type { Transaction } from '@/api/pos'
interface ReceiptData {
transaction: Transaction & { lineItems: { description: string; qty: number; unitPrice: string; taxAmount: string; lineTotal: string; discountAmount: string }[] }
company: { name: string; phone: string | null; email: string | null; address: { street?: string; city?: string; state?: string; zip?: string } | null }
location: { name: string; phone: string | null; email: string | null; address: { street?: string; city?: string; state?: string; zip?: string } | null }
}
interface AppConfigEntry { key: string; value: string | null }
function recentTransactionsOptions(search: string) {
return queryOptions({
queryKey: ['pos', 'recent-transactions', search],
queryFn: () => api.get<{ data: Transaction[] }>('/v1/transactions', {
limit: 15,
sort: 'created_at',
order: 'desc',
...(search ? { q: search } : {}),
}),
})
}
interface POSTransactionsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function POSTransactionsDialog({ open, onOpenChange }: POSTransactionsDialogProps) {
const [search, setSearch] = useState('')
const [receiptTxnId, setReceiptTxnId] = useState<string | null>(null)
const { data: txnData } = useQuery({
...recentTransactionsOptions(search),
enabled: open,
})
const transactions = txnData?.data ?? []
// Fetch receipt for selected transaction
const { data: receiptData } = useQuery({
queryKey: ['pos', 'receipt', receiptTxnId],
queryFn: () => api.get<ReceiptData>(`/v1/transactions/${receiptTxnId}/receipt`),
enabled: !!receiptTxnId,
})
const { data: configData } = useQuery({
queryKey: ['config'],
queryFn: () => api.get<{ data: AppConfigEntry[] }>('/v1/config'),
enabled: !!receiptTxnId,
})
const receiptFormat = usePOSStore((s) => s.receiptFormat)
const receiptConfig = {
header: configData?.data?.find((c) => c.key === 'receipt_header')?.value || undefined,
footer: configData?.data?.find((c) => c.key === 'receipt_footer')?.value || undefined,
returnPolicy: configData?.data?.find((c) => c.key === 'receipt_return_policy')?.value || undefined,
social: configData?.data?.find((c) => c.key === 'receipt_social')?.value || undefined,
}
// Receipt view
if (receiptTxnId && receiptData) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={`${receiptFormat === 'full' ? 'max-w-2xl' : 'max-w-sm'} max-h-[90vh] overflow-y-auto`}>
<div className="flex justify-between items-center mb-2">
<Button variant="ghost" size="sm" onClick={() => setReceiptTxnId(null)}>Back</Button>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => downloadReceiptPDF(receiptData.transaction.transactionNumber, receiptFormat)}>
Save PDF
</Button>
<Button size="sm" onClick={printReceipt} className="gap-2">
<Printer className="h-4 w-4" />Print
</Button>
</div>
</div>
<div id="pos-receipt-print">
<POSReceipt data={receiptData} size={receiptFormat} config={receiptConfig} />
</div>
</DialogContent>
</Dialog>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Recent Transactions</DialogTitle>
</DialogHeader>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by transaction number..."
className="h-10"
autoFocus
/>
<div className="flex-1 overflow-y-auto">
{transactions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No transactions found</p>
) : (
<div className="divide-y divide-border">
{transactions.map((txn) => (
<button
key={txn.id}
onClick={() => setReceiptTxnId(txn.id)}
className="w-full text-left py-2.5 px-1 hover:bg-accent/50 rounded transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-mono">{txn.transactionNumber}</span>
<span className="text-sm font-semibold">${parseFloat(txn.total).toFixed(2)}</span>
</div>
<div className="flex items-center gap-2 mt-0.5">
<Badge
variant={txn.status === 'completed' ? 'default' : txn.status === 'voided' ? 'destructive' : 'outline'}
className="text-[10px]"
>
{txn.status}
</Badge>
{txn.paymentMethod && (
<span className="text-xs text-muted-foreground">{txn.paymentMethod.replace('_', ' ')}</span>
)}
<span className="text-xs text-muted-foreground ml-auto">
{new Date(txn.completedAt ?? txn.createdAt).toLocaleString()}
</span>
</div>
</button>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -85,8 +85,9 @@ export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, comp
doc.text(dateInfo, 14, y) doc.text(dateInfo, 14, y)
y += 8 y += 8
// Line items table // Line items table (exclude consumables — internal only)
if (lineItems.length > 0) { const billableItems = lineItems.filter((i) => i.itemType !== 'consumable')
if (billableItems.length > 0) {
doc.setDrawColor(200) doc.setDrawColor(200)
doc.line(14, y, 196, y) doc.line(14, y, 196, y)
y += 6 y += 6
@@ -109,7 +110,7 @@ export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, comp
// Table rows // Table rows
doc.setFont('helvetica', 'normal') doc.setFont('helvetica', 'normal')
for (const item of lineItems) { for (const item of billableItems) {
if (y > 270) { doc.addPage(); y = 20 } if (y > 270) { doc.addPage(); y = 20 }
doc.text(item.itemType.replace('_', ' '), 16, y) doc.text(item.itemType.replace('_', ' '), 16, y)
const descLines = doc.splitTextToSize(item.description, 85) const descLines = doc.splitTextToSize(item.description, 85)
@@ -127,7 +128,7 @@ export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, comp
y += 5 y += 5
doc.setFont('helvetica', 'bold') doc.setFont('helvetica', 'bold')
doc.setFontSize(10) doc.setFontSize(10)
const total = lineItems.reduce((sum, i) => sum + parseFloat(i.totalPrice), 0) const total = billableItems.reduce((sum, i) => sum + parseFloat(i.totalPrice), 0)
doc.text('Total:', 155, y, { align: 'right' }) doc.text('Total:', 155, y, { align: 'right' })
doc.text(`$${total.toFixed(2)}`, 190, y, { align: 'right' }) doc.text(`$${total.toFixed(2)}`, 190, y, { align: 'right' })
y += 4 y += 4

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -1,4 +1,5 @@
import { useAuthStore } from '@/stores/auth.store' import { useAuthStore } from '@/stores/auth.store'
import { usePOSStore } from '@/stores/pos.store'
class ApiError extends Error { class ApiError extends Error {
statusCode: number statusCode: number
@@ -13,7 +14,8 @@ class ApiError extends Error {
} }
async function request<T>(method: string, path: string, body?: unknown): Promise<T> { async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const { token } = useAuthStore.getState() // Use POS token if available (POS screen), otherwise admin token
const token = usePOSStore.getState().token ?? useAuthStore.getState().token
const headers: Record<string, string> = {} const headers: Record<string, string> = {}
@@ -32,9 +34,12 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
}) })
if (res.status === 401) { if (res.status === 401) {
useAuthStore.getState().logout() // On POS, lock the screen instead of logging out admin
// Don't use window.location — that causes a full reload and flash if (usePOSStore.getState().token) {
// The router's beforeLoad guard will redirect to /login on next navigation usePOSStore.getState().lock()
} else {
useAuthStore.getState().logout()
}
throw new ApiError('Unauthorized', 401) throw new ApiError('Unauthorized', 401)
} }

View File

@@ -9,6 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as PosRouteImport } from './routes/pos' import { Route as PosRouteImport } from './routes/pos'
import { Route as LoginRouteImport } from './routes/login' import { Route as LoginRouteImport } from './routes/login'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated' import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
@@ -27,6 +28,7 @@ import { Route as AuthenticatedFilesIndexRouteImport } from './routes/_authentic
import { Route as AuthenticatedAccountsIndexRouteImport } from './routes/_authenticated/accounts/index' import { Route as AuthenticatedAccountsIndexRouteImport } from './routes/_authenticated/accounts/index'
import { Route as AuthenticatedRolesNewRouteImport } from './routes/_authenticated/roles/new' import { Route as AuthenticatedRolesNewRouteImport } from './routes/_authenticated/roles/new'
import { Route as AuthenticatedRolesRoleIdRouteImport } from './routes/_authenticated/roles/$roleId' import { Route as AuthenticatedRolesRoleIdRouteImport } from './routes/_authenticated/roles/$roleId'
import { Route as AuthenticatedReportsDailyRouteImport } from './routes/_authenticated/reports/daily'
import { Route as AuthenticatedRepairsTemplatesRouteImport } from './routes/_authenticated/repairs/templates' import { Route as AuthenticatedRepairsTemplatesRouteImport } from './routes/_authenticated/repairs/templates'
import { Route as AuthenticatedRepairsNewRouteImport } from './routes/_authenticated/repairs/new' import { Route as AuthenticatedRepairsNewRouteImport } from './routes/_authenticated/repairs/new'
import { Route as AuthenticatedRepairsTicketIdRouteImport } from './routes/_authenticated/repairs/$ticketId' import { Route as AuthenticatedRepairsTicketIdRouteImport } from './routes/_authenticated/repairs/$ticketId'
@@ -57,6 +59,11 @@ import { Route as AuthenticatedAccountsAccountIdMembersRouteImport } from './rou
import { Route as AuthenticatedAccountsAccountIdEnrollmentsRouteImport } from './routes/_authenticated/accounts/$accountId/enrollments' import { Route as AuthenticatedAccountsAccountIdEnrollmentsRouteImport } from './routes/_authenticated/accounts/$accountId/enrollments'
import { Route as AuthenticatedLessonsScheduleInstructorsInstructorIdRouteImport } from './routes/_authenticated/lessons/schedule/instructors/$instructorId' import { Route as AuthenticatedLessonsScheduleInstructorsInstructorIdRouteImport } from './routes/_authenticated/lessons/schedule/instructors/$instructorId'
const ResetPasswordRoute = ResetPasswordRouteImport.update({
id: '/reset-password',
path: '/reset-password',
getParentRoute: () => rootRouteImport,
} as any)
const PosRoute = PosRouteImport.update({ const PosRoute = PosRouteImport.update({
id: '/pos', id: '/pos',
path: '/pos', path: '/pos',
@@ -152,6 +159,12 @@ const AuthenticatedRolesRoleIdRoute =
path: '/roles/$roleId', path: '/roles/$roleId',
getParentRoute: () => AuthenticatedRoute, getParentRoute: () => AuthenticatedRoute,
} as any) } as any)
const AuthenticatedReportsDailyRoute =
AuthenticatedReportsDailyRouteImport.update({
id: '/reports/daily',
path: '/reports/daily',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedRepairsTemplatesRoute = const AuthenticatedRepairsTemplatesRoute =
AuthenticatedRepairsTemplatesRouteImport.update({ AuthenticatedRepairsTemplatesRouteImport.update({
id: '/repairs/templates', id: '/repairs/templates',
@@ -330,6 +343,7 @@ export interface FileRoutesByFullPath {
'/': typeof AuthenticatedIndexRoute '/': typeof AuthenticatedIndexRoute
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/pos': typeof PosRoute '/pos': typeof PosRoute
'/reset-password': typeof ResetPasswordRoute
'/help': typeof AuthenticatedHelpRoute '/help': typeof AuthenticatedHelpRoute
'/profile': typeof AuthenticatedProfileRoute '/profile': typeof AuthenticatedProfileRoute
'/settings': typeof AuthenticatedSettingsRoute '/settings': typeof AuthenticatedSettingsRoute
@@ -344,6 +358,7 @@ export interface FileRoutesByFullPath {
'/repairs/$ticketId': typeof AuthenticatedRepairsTicketIdRoute '/repairs/$ticketId': typeof AuthenticatedRepairsTicketIdRoute
'/repairs/new': typeof AuthenticatedRepairsNewRoute '/repairs/new': typeof AuthenticatedRepairsNewRoute
'/repairs/templates': typeof AuthenticatedRepairsTemplatesRoute '/repairs/templates': typeof AuthenticatedRepairsTemplatesRoute
'/reports/daily': typeof AuthenticatedReportsDailyRoute
'/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute '/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
'/roles/new': typeof AuthenticatedRolesNewRoute '/roles/new': typeof AuthenticatedRolesNewRoute
'/accounts/': typeof AuthenticatedAccountsIndexRoute '/accounts/': typeof AuthenticatedAccountsIndexRoute
@@ -377,6 +392,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/pos': typeof PosRoute '/pos': typeof PosRoute
'/reset-password': typeof ResetPasswordRoute
'/help': typeof AuthenticatedHelpRoute '/help': typeof AuthenticatedHelpRoute
'/profile': typeof AuthenticatedProfileRoute '/profile': typeof AuthenticatedProfileRoute
'/settings': typeof AuthenticatedSettingsRoute '/settings': typeof AuthenticatedSettingsRoute
@@ -391,6 +407,7 @@ export interface FileRoutesByTo {
'/repairs/$ticketId': typeof AuthenticatedRepairsTicketIdRoute '/repairs/$ticketId': typeof AuthenticatedRepairsTicketIdRoute
'/repairs/new': typeof AuthenticatedRepairsNewRoute '/repairs/new': typeof AuthenticatedRepairsNewRoute
'/repairs/templates': typeof AuthenticatedRepairsTemplatesRoute '/repairs/templates': typeof AuthenticatedRepairsTemplatesRoute
'/reports/daily': typeof AuthenticatedReportsDailyRoute
'/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute '/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
'/roles/new': typeof AuthenticatedRolesNewRoute '/roles/new': typeof AuthenticatedRolesNewRoute
'/accounts': typeof AuthenticatedAccountsIndexRoute '/accounts': typeof AuthenticatedAccountsIndexRoute
@@ -426,6 +443,7 @@ export interface FileRoutesById {
'/_authenticated': typeof AuthenticatedRouteWithChildren '/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute '/login': typeof LoginRoute
'/pos': typeof PosRoute '/pos': typeof PosRoute
'/reset-password': typeof ResetPasswordRoute
'/_authenticated/help': typeof AuthenticatedHelpRoute '/_authenticated/help': typeof AuthenticatedHelpRoute
'/_authenticated/profile': typeof AuthenticatedProfileRoute '/_authenticated/profile': typeof AuthenticatedProfileRoute
'/_authenticated/settings': typeof AuthenticatedSettingsRoute '/_authenticated/settings': typeof AuthenticatedSettingsRoute
@@ -441,6 +459,7 @@ export interface FileRoutesById {
'/_authenticated/repairs/$ticketId': typeof AuthenticatedRepairsTicketIdRoute '/_authenticated/repairs/$ticketId': typeof AuthenticatedRepairsTicketIdRoute
'/_authenticated/repairs/new': typeof AuthenticatedRepairsNewRoute '/_authenticated/repairs/new': typeof AuthenticatedRepairsNewRoute
'/_authenticated/repairs/templates': typeof AuthenticatedRepairsTemplatesRoute '/_authenticated/repairs/templates': typeof AuthenticatedRepairsTemplatesRoute
'/_authenticated/reports/daily': typeof AuthenticatedReportsDailyRoute
'/_authenticated/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute '/_authenticated/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
'/_authenticated/roles/new': typeof AuthenticatedRolesNewRoute '/_authenticated/roles/new': typeof AuthenticatedRolesNewRoute
'/_authenticated/accounts/': typeof AuthenticatedAccountsIndexRoute '/_authenticated/accounts/': typeof AuthenticatedAccountsIndexRoute
@@ -477,6 +496,7 @@ export interface FileRouteTypes {
| '/' | '/'
| '/login' | '/login'
| '/pos' | '/pos'
| '/reset-password'
| '/help' | '/help'
| '/profile' | '/profile'
| '/settings' | '/settings'
@@ -491,6 +511,7 @@ export interface FileRouteTypes {
| '/repairs/$ticketId' | '/repairs/$ticketId'
| '/repairs/new' | '/repairs/new'
| '/repairs/templates' | '/repairs/templates'
| '/reports/daily'
| '/roles/$roleId' | '/roles/$roleId'
| '/roles/new' | '/roles/new'
| '/accounts/' | '/accounts/'
@@ -524,6 +545,7 @@ export interface FileRouteTypes {
to: to:
| '/login' | '/login'
| '/pos' | '/pos'
| '/reset-password'
| '/help' | '/help'
| '/profile' | '/profile'
| '/settings' | '/settings'
@@ -538,6 +560,7 @@ export interface FileRouteTypes {
| '/repairs/$ticketId' | '/repairs/$ticketId'
| '/repairs/new' | '/repairs/new'
| '/repairs/templates' | '/repairs/templates'
| '/reports/daily'
| '/roles/$roleId' | '/roles/$roleId'
| '/roles/new' | '/roles/new'
| '/accounts' | '/accounts'
@@ -572,6 +595,7 @@ export interface FileRouteTypes {
| '/_authenticated' | '/_authenticated'
| '/login' | '/login'
| '/pos' | '/pos'
| '/reset-password'
| '/_authenticated/help' | '/_authenticated/help'
| '/_authenticated/profile' | '/_authenticated/profile'
| '/_authenticated/settings' | '/_authenticated/settings'
@@ -587,6 +611,7 @@ export interface FileRouteTypes {
| '/_authenticated/repairs/$ticketId' | '/_authenticated/repairs/$ticketId'
| '/_authenticated/repairs/new' | '/_authenticated/repairs/new'
| '/_authenticated/repairs/templates' | '/_authenticated/repairs/templates'
| '/_authenticated/reports/daily'
| '/_authenticated/roles/$roleId' | '/_authenticated/roles/$roleId'
| '/_authenticated/roles/new' | '/_authenticated/roles/new'
| '/_authenticated/accounts/' | '/_authenticated/accounts/'
@@ -622,10 +647,18 @@ export interface RootRouteChildren {
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
PosRoute: typeof PosRoute PosRoute: typeof PosRoute
ResetPasswordRoute: typeof ResetPasswordRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/reset-password': {
id: '/reset-password'
path: '/reset-password'
fullPath: '/reset-password'
preLoaderRoute: typeof ResetPasswordRouteImport
parentRoute: typeof rootRouteImport
}
'/pos': { '/pos': {
id: '/pos' id: '/pos'
path: '/pos' path: '/pos'
@@ -752,6 +785,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedRolesRoleIdRouteImport preLoaderRoute: typeof AuthenticatedRolesRoleIdRouteImport
parentRoute: typeof AuthenticatedRoute parentRoute: typeof AuthenticatedRoute
} }
'/_authenticated/reports/daily': {
id: '/_authenticated/reports/daily'
path: '/reports/daily'
fullPath: '/reports/daily'
preLoaderRoute: typeof AuthenticatedReportsDailyRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/repairs/templates': { '/_authenticated/repairs/templates': {
id: '/_authenticated/repairs/templates' id: '/_authenticated/repairs/templates'
path: '/repairs/templates' path: '/repairs/templates'
@@ -1004,6 +1044,7 @@ interface AuthenticatedRouteChildren {
AuthenticatedRepairsTicketIdRoute: typeof AuthenticatedRepairsTicketIdRoute AuthenticatedRepairsTicketIdRoute: typeof AuthenticatedRepairsTicketIdRoute
AuthenticatedRepairsNewRoute: typeof AuthenticatedRepairsNewRoute AuthenticatedRepairsNewRoute: typeof AuthenticatedRepairsNewRoute
AuthenticatedRepairsTemplatesRoute: typeof AuthenticatedRepairsTemplatesRoute AuthenticatedRepairsTemplatesRoute: typeof AuthenticatedRepairsTemplatesRoute
AuthenticatedReportsDailyRoute: typeof AuthenticatedReportsDailyRoute
AuthenticatedRolesRoleIdRoute: typeof AuthenticatedRolesRoleIdRoute AuthenticatedRolesRoleIdRoute: typeof AuthenticatedRolesRoleIdRoute
AuthenticatedRolesNewRoute: typeof AuthenticatedRolesNewRoute AuthenticatedRolesNewRoute: typeof AuthenticatedRolesNewRoute
AuthenticatedAccountsIndexRoute: typeof AuthenticatedAccountsIndexRoute AuthenticatedAccountsIndexRoute: typeof AuthenticatedAccountsIndexRoute
@@ -1047,6 +1088,7 @@ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedRepairsTicketIdRoute: AuthenticatedRepairsTicketIdRoute, AuthenticatedRepairsTicketIdRoute: AuthenticatedRepairsTicketIdRoute,
AuthenticatedRepairsNewRoute: AuthenticatedRepairsNewRoute, AuthenticatedRepairsNewRoute: AuthenticatedRepairsNewRoute,
AuthenticatedRepairsTemplatesRoute: AuthenticatedRepairsTemplatesRoute, AuthenticatedRepairsTemplatesRoute: AuthenticatedRepairsTemplatesRoute,
AuthenticatedReportsDailyRoute: AuthenticatedReportsDailyRoute,
AuthenticatedRolesRoleIdRoute: AuthenticatedRolesRoleIdRoute, AuthenticatedRolesRoleIdRoute: AuthenticatedRolesRoleIdRoute,
AuthenticatedRolesNewRoute: AuthenticatedRolesNewRoute, AuthenticatedRolesNewRoute: AuthenticatedRolesNewRoute,
AuthenticatedAccountsIndexRoute: AuthenticatedAccountsIndexRoute, AuthenticatedAccountsIndexRoute: AuthenticatedAccountsIndexRoute,
@@ -1090,6 +1132,7 @@ const rootRouteChildren: RootRouteChildren = {
AuthenticatedRoute: AuthenticatedRouteWithChildren, AuthenticatedRoute: AuthenticatedRouteWithChildren,
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
PosRoute: PosRoute, PosRoute: PosRoute,
ResetPasswordRoute: ResetPasswordRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)

View File

@@ -1,11 +1,29 @@
import { createRootRoute, Outlet } from '@tanstack/react-router' import { createRootRoute, Outlet } from '@tanstack/react-router'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { useEffect } from 'react'
export const Route = createRootRoute({ export const Route = createRootRoute({
component: RootLayout, component: RootLayout,
}) })
function RootLayout() { function RootLayout() {
useEffect(() => {
fetch('/v1/store/branding')
.then((r) => r.ok ? r.json() : null)
.then((data: { name: string | null; hasLogo: boolean } | null) => {
if (!data) return
if (data.name) document.title = data.name
if (data.hasLogo) {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
?? document.createElement('link')
link.rel = 'icon'
link.href = '/v1/store/logo'
document.head.appendChild(link)
}
})
.catch(() => {})
}, [])
return ( return (
<> <>
<Outlet /> <Outlet />

View File

@@ -1,5 +1,5 @@
import { createFileRoute, Outlet, Link, redirect, useRouter } from '@tanstack/react-router' import { createFileRoute, Outlet, Link, redirect, useRouter } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { queryOptions } from '@tanstack/react-query' import { queryOptions } from '@tanstack/react-query'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { api } from '@/lib/api-client' import { api } from '@/lib/api-client'
@@ -9,6 +9,9 @@ import { moduleListOptions } from '@/api/modules'
import { Avatar } from '@/components/shared/avatar-upload' import { Avatar } from '@/components/shared/avatar-upload'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings, PanelLeftClose, PanelLeft, CalendarDays, GraduationCap, CalendarRange, BookOpen, BookMarked, Package2, Tag, Truck, ShoppingCart } from 'lucide-react' import { Users, UserRound, HelpCircle, Shield, UserCog, LogOut, User, Wrench, Package, ClipboardList, FolderOpen, KeyRound, Settings, PanelLeftClose, PanelLeft, CalendarDays, GraduationCap, CalendarRange, BookOpen, BookMarked, Package2, Tag, Truck, ShoppingCart } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { toast } from 'sonner'
export const Route = createFileRoute('/_authenticated')({ export const Route = createFileRoute('/_authenticated')({
beforeLoad: () => { beforeLoad: () => {
@@ -104,6 +107,58 @@ function NavGroup({ label, children, collapsed }: { label: string; children: Rea
) )
} }
function SetPinModal() {
const queryClient = useQueryClient()
const [pin, setPin] = useState('')
const [confirmPin, setConfirmPin] = useState('')
const [error, setError] = useState('')
const setPinMutation = useMutation({
mutationFn: () => api.post('/v1/auth/set-pin', { pin }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
toast.success('PIN set successfully')
},
onError: (err) => setError(err.message),
})
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (pin.length < 4 || pin.length > 6) { setError('PIN must be 4-6 digits'); return }
if (!/^\d+$/.test(pin)) { setError('PIN must be digits only'); return }
if (pin !== confirmPin) { setError('PINs do not match'); return }
setPinMutation.mutate()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-xl">
<h2 className="text-lg font-semibold mb-1">Set your POS PIN</h2>
<p className="text-sm text-muted-foreground mb-4">
A PIN is required to use the Point of Sale. Choose a 4-6 digit PIN you'll use to unlock the terminal.
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>PIN</Label>
<Input type="password" inputMode="numeric" maxLength={6} value={pin} onChange={(e) => setPin(e.target.value)} placeholder="****" autoFocus />
</div>
<div className="space-y-2">
<Label>Confirm PIN</Label>
<Input type="password" inputMode="numeric" maxLength={6} value={confirmPin} onChange={(e) => setConfirmPin(e.target.value)} placeholder="****" />
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={setPinMutation.isPending}>
{setPinMutation.isPending ? 'Setting...' : 'Set PIN'}
</Button>
</form>
</div>
</div>
)
}
function AuthenticatedLayout() { function AuthenticatedLayout() {
const router = useRouter() const router = useRouter()
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
@@ -112,6 +167,13 @@ function AuthenticatedLayout() {
const setPermissions = useAuthStore((s) => s.setPermissions) const setPermissions = useAuthStore((s) => s.setPermissions)
const permissionsLoaded = useAuthStore((s) => s.permissionsLoaded) const permissionsLoaded = useAuthStore((s) => s.permissionsLoaded)
// Fetch profile for PIN warning
const { data: profile } = useQuery(queryOptions({
queryKey: ['auth', 'me'],
queryFn: () => api.get<{ hasPin: boolean }>('/v1/auth/me'),
enabled: !!useAuthStore.getState().token,
}))
// Fetch permissions on mount // Fetch permissions on mount
const { data: permData } = useQuery({ const { data: permData } = useQuery({
...myPermissionsOptions(), ...myPermissionsOptions(),
@@ -176,7 +238,14 @@ function AuthenticatedLayout() {
<div className="flex-1 overflow-y-auto px-2 space-y-1 scrollbar-thin"> <div className="flex-1 overflow-y-auto px-2 space-y-1 scrollbar-thin">
{isModuleEnabled('pos') && canViewPOS && ( {isModuleEnabled('pos') && canViewPOS && (
<div className="mb-2"> <div className="mb-2">
<NavLink to="/pos" icon={<ShoppingCart className="h-4 w-4" />} label="Point of Sale" collapsed={collapsed} /> <button
onClick={() => { logout(); router.navigate({ to: '/pos' }) }}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent w-full"
title={collapsed ? 'Point of Sale' : undefined}
>
<ShoppingCart className="h-4 w-4" />
{!collapsed && 'Point of Sale'}
</button>
</div> </div>
)} )}
{canViewAccounts && ( {canViewAccounts && (
@@ -256,6 +325,7 @@ function AuthenticatedLayout() {
</div> </div>
</nav> </nav>
<main className="flex-1 p-6 min-h-screen"> <main className="flex-1 p-6 min-h-screen">
{profile && !profile.hasPin && <SetPinModal />}
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View File

@@ -11,10 +11,22 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Sun, Moon, Monitor } from 'lucide-react' import { Sun, Moon, Monitor } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { AvatarUpload } from '@/components/shared/avatar-upload' import { AvatarUpload } from '@/components/shared/avatar-upload'
interface Profile {
id: string
email: string
firstName: string
lastName: string
role: string
employeeNumber: string | null
hasPin: boolean
createdAt: string
}
export const Route = createFileRoute('/_authenticated/profile')({ export const Route = createFileRoute('/_authenticated/profile')({
component: ProfilePage, component: ProfilePage,
}) })
@@ -22,21 +34,56 @@ export const Route = createFileRoute('/_authenticated/profile')({
function profileOptions() { function profileOptions() {
return queryOptions({ return queryOptions({
queryKey: ['auth', 'me'], queryKey: ['auth', 'me'],
queryFn: () => api.get<{ id: string; email: string; firstName: string; lastName: string }>('/v1/auth/me'), queryFn: () => api.get<Profile>('/v1/auth/me'),
}) })
} }
function ProfilePage() { function ProfilePage() {
const { tab } = Route.useSearch() as { tab?: string }
const queryClient = useQueryClient() const queryClient = useQueryClient()
const setAuth = useAuthStore((s) => s.setAuth) const setAuth = useAuthStore((s) => s.setAuth)
const storeUser = useAuthStore((s) => s.user) const storeUser = useAuthStore((s) => s.user)
const storeToken = useAuthStore((s) => s.token) const storeToken = useAuthStore((s) => s.token)
const { mode, colorTheme, setMode, setColorTheme } = useThemeStore()
const { data: profile } = useQuery(profileOptions()) const { data: profile } = useQuery(profileOptions())
const [firstName, setFirstName] = useState('') return (
const [lastName, setLastName] = useState('') <div className="space-y-6 max-w-2xl">
<h1 className="text-2xl font-bold">Profile</h1>
<Tabs defaultValue={tab === 'security' || tab === 'appearance' ? tab : 'account'}>
<TabsList>
<TabsTrigger value="account">Account</TabsTrigger>
<TabsTrigger value="security">Security</TabsTrigger>
<TabsTrigger value="appearance">Appearance</TabsTrigger>
</TabsList>
<TabsContent value="account">
<AccountTab profile={profile} queryClient={queryClient} setAuth={setAuth} storeUser={storeUser} storeToken={storeToken} />
</TabsContent>
<TabsContent value="security">
<SecurityTab profile={profile} queryClient={queryClient} />
</TabsContent>
<TabsContent value="appearance">
<AppearanceTab />
</TabsContent>
</Tabs>
</div>
)
}
function AccountTab({ profile, queryClient, setAuth, storeUser, storeToken }: {
profile: Profile | undefined
queryClient: ReturnType<typeof useQueryClient>
setAuth: (token: string, user: any) => void
storeUser: any
storeToken: string | null
}) {
const [firstName, setFirstName] = useState(profile?.firstName ?? '')
const [lastName, setLastName] = useState(profile?.lastName ?? '')
const [nameLoaded, setNameLoaded] = useState(false) const [nameLoaded, setNameLoaded] = useState(false)
if (profile && !nameLoaded) { if (profile && !nameLoaded) {
@@ -45,12 +92,8 @@ function ProfilePage() {
setNameLoaded(true) setNameLoaded(true)
} }
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const updateProfileMutation = useMutation({ const updateProfileMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => api.patch<{ id: string; email: string; firstName: string; lastName: string }>('/v1/auth/me', data), mutationFn: (data: Record<string, unknown>) => api.patch<Profile>('/v1/auth/me', data),
onSuccess: (updated) => { onSuccess: (updated) => {
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] }) queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
if (storeToken && storeUser) { if (storeToken && storeUser) {
@@ -61,6 +104,59 @@ function ProfilePage() {
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Account</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{profile?.id && (
<div className="flex items-center gap-4">
<AvatarUpload entityType="user" entityId={profile.id} size="lg" />
<div>
<p className="font-medium">{profile.firstName} {profile.lastName}</p>
<p className="text-sm text-muted-foreground">{profile.email}</p>
{profile.employeeNumber && (
<p className="text-xs text-muted-foreground">Employee #{profile.employeeNumber}</p>
)}
</div>
</div>
)}
<div className="space-y-2">
<Label>Email</Label>
<Input value={profile?.email ?? ''} disabled className="opacity-60" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>First Name</Label>
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Last Name</Label>
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
</div>
<Button
onClick={() => updateProfileMutation.mutate({ firstName, lastName })}
disabled={updateProfileMutation.isPending}
>
{updateProfileMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</CardContent>
</Card>
)
}
function SecurityTab({ profile, queryClient }: {
profile: Profile | undefined
queryClient: ReturnType<typeof useQueryClient>
}) {
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [pin, setPin] = useState('')
const [confirmPin, setConfirmPin] = useState('')
const changePasswordMutation = useMutation({ const changePasswordMutation = useMutation({
mutationFn: () => api.post('/v1/auth/change-password', { currentPassword, newPassword }), mutationFn: () => api.post('/v1/auth/change-password', { currentPassword, newPassword }),
onSuccess: () => { onSuccess: () => {
@@ -72,6 +168,26 @@ function ProfilePage() {
onError: (err) => toast.error(err.message), onError: (err) => toast.error(err.message),
}) })
const setPinMutation = useMutation({
mutationFn: () => api.post('/v1/auth/set-pin', { pin }),
onSuccess: () => {
setPin('')
setConfirmPin('')
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
toast.success('PIN set')
},
onError: (err) => toast.error(err.message),
})
const removePinMutation = useMutation({
mutationFn: () => api.del('/v1/auth/pin'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['auth', 'me'] })
toast.success('PIN removed')
},
onError: (err) => toast.error(err.message),
})
function handlePasswordChange() { function handlePasswordChange() {
if (newPassword.length < 12) { if (newPassword.length < 12) {
toast.error('Password must be at least 12 characters') toast.error('Password must be at least 12 characters')
@@ -84,47 +200,24 @@ function ProfilePage() {
changePasswordMutation.mutate() changePasswordMutation.mutate()
} }
function handleSetPin() {
if (pin.length < 4 || pin.length > 6) {
toast.error('PIN must be 4-6 digits')
return
}
if (!/^\d+$/.test(pin)) {
toast.error('PIN must be digits only')
return
}
if (pin !== confirmPin) {
toast.error('PINs do not match')
return
}
setPinMutation.mutate()
}
return ( return (
<div className="space-y-6 max-w-2xl"> <div className="space-y-6">
<h1 className="text-2xl font-bold">Profile</h1>
<Card>
<CardHeader>
<CardTitle className="text-lg">Account</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{profile?.id && (
<div className="flex items-center gap-4">
<AvatarUpload entityType="user" entityId={profile.id} size="lg" />
<div>
<p className="font-medium">{profile.firstName} {profile.lastName}</p>
<p className="text-sm text-muted-foreground">{profile.email}</p>
</div>
</div>
)}
<div className="space-y-2">
<Label>Email</Label>
<Input value={profile?.email ?? ''} disabled className="opacity-60" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>First Name</Label>
<Input value={firstName} onChange={(e) => setFirstName(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Last Name</Label>
<Input value={lastName} onChange={(e) => setLastName(e.target.value)} />
</div>
</div>
<Button
onClick={() => updateProfileMutation.mutate({ firstName, lastName })}
disabled={updateProfileMutation.isPending}
>
{updateProfileMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Change Password</CardTitle> <CardTitle className="text-lg">Change Password</CardTitle>
@@ -150,53 +243,113 @@ function ProfilePage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-lg">Appearance</CardTitle> <CardTitle className="text-lg">POS PIN</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> {profile?.employeeNumber && (
<Label>Mode</Label> <p className="text-sm text-muted-foreground">
<div className="flex gap-2"> Your employee number is <span className="font-mono font-medium text-foreground">{profile.employeeNumber}</span>.
{[ To unlock the POS, enter your employee number followed by your PIN.
{ value: 'light' as const, icon: Sun, label: 'Light' }, </p>
{ value: 'dark' as const, icon: Moon, label: 'Dark' }, )}
{ value: 'system' as const, icon: Monitor, label: 'System' }, {profile?.hasPin ? (
].map((m) => ( <>
<Button <p className="text-sm text-muted-foreground">You have a PIN set. You can change or remove it below.</p>
key={m.value} <div className="grid grid-cols-2 gap-4">
variant={mode === m.value ? 'default' : 'secondary'} <div className="space-y-2">
size="sm" <Label>New PIN (4-6 digits)</Label>
onClick={() => setMode(m.value)} <Input type="password" inputMode="numeric" maxLength={6} value={pin} onChange={(e) => setPin(e.target.value)} placeholder="****" />
> </div>
<m.icon className="mr-2 h-4 w-4" /> <div className="space-y-2">
{m.label} <Label>Confirm PIN</Label>
<Input type="password" inputMode="numeric" maxLength={6} value={confirmPin} onChange={(e) => setConfirmPin(e.target.value)} placeholder="****" />
</div>
</div>
<div className="flex gap-2">
<Button onClick={handleSetPin} disabled={setPinMutation.isPending}>
{setPinMutation.isPending ? 'Saving...' : 'Change PIN'}
</Button> </Button>
))} <Button variant="outline" onClick={() => removePinMutation.mutate()} disabled={removePinMutation.isPending}>
</div> {removePinMutation.isPending ? 'Removing...' : 'Remove PIN'}
</div>
<Separator />
<div className="space-y-2">
<Label>Color Theme</Label>
<div className="flex gap-2 flex-wrap">
{themes.map((t) => (
<Button
key={t.name}
variant={colorTheme === t.name ? 'default' : 'secondary'}
size="sm"
onClick={() => setColorTheme(t.name)}
>
<span
className="mr-2 h-3 w-3 rounded-full border inline-block"
style={{ backgroundColor: `hsl(${t.light.primary})` }}
/>
{t.label}
</Button> </Button>
))} </div>
</div> </>
</div> ) : (
<>
<p className="text-sm text-muted-foreground">Set a PIN to unlock the Point of Sale terminal.</p>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>PIN (4-6 digits)</Label>
<Input type="password" inputMode="numeric" maxLength={6} value={pin} onChange={(e) => setPin(e.target.value)} placeholder="****" />
</div>
<div className="space-y-2">
<Label>Confirm PIN</Label>
<Input type="password" inputMode="numeric" maxLength={6} value={confirmPin} onChange={(e) => setConfirmPin(e.target.value)} placeholder="****" />
</div>
</div>
<Button onClick={handleSetPin} disabled={setPinMutation.isPending}>
{setPinMutation.isPending ? 'Saving...' : 'Set PIN'}
</Button>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
) )
} }
function AppearanceTab() {
const { mode, colorTheme, setMode, setColorTheme } = useThemeStore()
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">Appearance</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Mode</Label>
<div className="flex gap-2">
{[
{ value: 'light' as const, icon: Sun, label: 'Light' },
{ value: 'dark' as const, icon: Moon, label: 'Dark' },
{ value: 'system' as const, icon: Monitor, label: 'System' },
].map((m) => (
<Button
key={m.value}
variant={mode === m.value ? 'default' : 'secondary'}
size="sm"
onClick={() => setMode(m.value)}
>
<m.icon className="mr-2 h-4 w-4" />
{m.label}
</Button>
))}
</div>
</div>
<Separator />
<div className="space-y-2">
<Label>Color Theme</Label>
<div className="flex gap-2 flex-wrap">
{themes.map((t) => (
<Button
key={t.name}
variant={colorTheme === t.name ? 'default' : 'secondary'}
size="sm"
onClick={() => setColorTheme(t.name)}
>
<span
className="mr-2 h-3 w-3 rounded-full border inline-block"
style={{ backgroundColor: `hsl(${t.light.primary})` }}
/>
{t.label}
</Button>
))}
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -6,6 +6,7 @@ import {
repairLineItemListOptions, repairLineItemMutations, repairLineItemKeys, repairLineItemListOptions, repairLineItemMutations, repairLineItemKeys,
repairServiceTemplateListOptions, repairServiceTemplateListOptions,
} from '@/api/repairs' } from '@/api/repairs'
import { api } from '@/lib/api-client'
import { usePagination } from '@/hooks/use-pagination' import { usePagination } from '@/hooks/use-pagination'
import { StatusProgress } from '@/components/repairs/status-progress' import { StatusProgress } from '@/components/repairs/status-progress'
import { TicketPhotos } from '@/components/repairs/ticket-photos' import { TicketPhotos } from '@/components/repairs/ticket-photos'
@@ -157,7 +158,12 @@ function RepairTicketDetailPage() {
} }
const lineItemColumns: Column<RepairLineItem>[] = [ const lineItemColumns: Column<RepairLineItem>[] = [
{ key: 'item_type', header: 'Type', sortable: true, render: (i) => <Badge variant="outline">{i.itemType.replace('_', ' ')}</Badge> }, { key: 'item_type', header: 'Type', sortable: true, render: (i) => (
<div className="flex items-center gap-1">
<Badge variant="outline">{i.itemType.replace('_', ' ')}</Badge>
{i.itemType === 'consumable' && <Badge variant="secondary" className="text-[10px]">Internal</Badge>}
</div>
) },
{ key: 'description', header: 'Description', render: (i) => <>{i.description}</> }, { key: 'description', header: 'Description', render: (i) => <>{i.description}</> },
{ key: 'qty', header: 'Qty', render: (i) => <>{i.qty}</> }, { key: 'qty', header: 'Qty', render: (i) => <>{i.qty}</> },
{ key: 'unit_price', header: 'Unit Price', render: (i) => <>${i.unitPrice}</> }, { key: 'unit_price', header: 'Unit Price', render: (i) => <>${i.unitPrice}</> },
@@ -391,11 +397,27 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
const [qty, setQty] = useState('1') const [qty, setQty] = useState('1')
const [unitPrice, setUnitPrice] = useState('0') const [unitPrice, setUnitPrice] = useState('0')
const [cost, setCost] = useState('') const [cost, setCost] = useState('')
const [productId, setProductId] = useState<string | null>(null)
const [productSearch, setProductSearch] = useState('')
const [showProducts, setShowProducts] = useState(false)
const showProductPicker = itemType === 'part' || itemType === 'consumable'
const { data: templatesData } = useQuery( const { data: templatesData } = useQuery(
repairServiceTemplateListOptions({ page: 1, limit: 20, q: templateSearch || undefined, order: 'asc', sort: 'name' }), repairServiceTemplateListOptions({ page: 1, limit: 20, q: templateSearch || undefined, order: 'asc', sort: 'name' }),
) )
const { data: productsData } = useQuery({
queryKey: ['products', 'repair-picker', productSearch, itemType],
queryFn: () => {
const params: Record<string, string> = { q: productSearch, limit: '10', isActive: 'true' }
if (itemType === 'consumable') params.isConsumable = 'true'
else params.isDualUseRepair = 'true'
return api.get<{ data: { id: string; name: string; sku: string | null; price: string | null; brand: string | null }[] }>('/v1/products', params)
},
enabled: showProductPicker && productSearch.length >= 1,
})
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data: Record<string, unknown>) => repairLineItemMutations.create(ticketId, data), mutationFn: (data: Record<string, unknown>) => repairLineItemMutations.create(ticketId, data),
onSuccess: () => { onSuccess: () => {
@@ -408,7 +430,7 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
}) })
function resetForm() { function resetForm() {
setDescription(''); setQty('1'); setUnitPrice('0'); setCost(''); setItemType('labor'); setTemplateSearch(''); setShowTemplates(false) setDescription(''); setQty('1'); setUnitPrice('0'); setCost(''); setItemType('labor'); setTemplateSearch(''); setShowTemplates(false); setProductId(null); setProductSearch(''); setShowProducts(false)
} }
function selectTemplate(template: { name: string; itemCategory: string | null; size: string | null; itemType: string; defaultPrice: string; defaultCost: string | null }) { function selectTemplate(template: { name: string; itemCategory: string | null; size: string | null; itemType: string; defaultPrice: string; defaultCost: string | null }) {
@@ -416,15 +438,24 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
setDescription(desc); setItemType(template.itemType); setUnitPrice(template.defaultPrice); setCost(template.defaultCost ?? ''); setShowTemplates(false); setTemplateSearch('') setDescription(desc); setItemType(template.itemType); setUnitPrice(template.defaultPrice); setCost(template.defaultCost ?? ''); setShowTemplates(false); setTemplateSearch('')
} }
function selectProduct(product: { id: string; name: string; price: string | null; brand: string | null }) {
setProductId(product.id)
setDescription(product.brand ? `${product.brand} ${product.name}` : product.name)
setUnitPrice(product.price ?? '0')
setProductSearch('')
setShowProducts(false)
}
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
const q = parseFloat(qty) || 1 const q = parseFloat(qty) || 1
const up = parseFloat(unitPrice) || 0 const up = parseFloat(unitPrice) || 0
const c = cost ? parseFloat(cost) : undefined const c = cost ? parseFloat(cost) : undefined
mutation.mutate({ itemType, description, qty: q, unitPrice: up, totalPrice: q * up, cost: c }) mutation.mutate({ itemType, description, qty: q, unitPrice: up, totalPrice: q * up, cost: c, productId: productId ?? undefined })
} }
const templates = templatesData?.data ?? [] const templates = templatesData?.data ?? []
const products = productsData?.data ?? []
return ( return (
<Dialog open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) resetForm() }}> <Dialog open={open} onOpenChange={(o) => { onOpenChange(o); if (!o) resetForm() }}>
@@ -454,8 +485,38 @@ function AddLineItemDialog({ ticketId, open, onOpenChange }: { ticketId: string;
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Type</Label> <Label>Type</Label>
<Select value={itemType} onValueChange={setItemType}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="labor">Labor</SelectItem><SelectItem value="part">Part</SelectItem><SelectItem value="flat_rate">Flat Rate</SelectItem><SelectItem value="misc">Misc</SelectItem></SelectContent></Select> <Select value={itemType} onValueChange={(v) => { setItemType(v); setProductId(null); setProductSearch(''); setShowProducts(false) }}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="labor">Labor</SelectItem><SelectItem value="part">Part</SelectItem><SelectItem value="flat_rate">Flat Rate</SelectItem><SelectItem value="misc">Misc</SelectItem><SelectItem value="consumable">Consumable (internal)</SelectItem></SelectContent></Select>
</div> </div>
{showProductPicker && (
<div className="relative space-y-2">
<Label>Search Inventory</Label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={itemType === 'consumable' ? 'Search consumables...' : 'Search parts...'}
value={productSearch}
onChange={(e) => { setProductSearch(e.target.value); setShowProducts(true) }}
onFocus={() => productSearch && setShowProducts(true)}
className="pl-9"
/>
</div>
{productId && (
<div className="text-xs text-muted-foreground flex items-center gap-1">
Linked to product <button type="button" className="underline text-destructive" onClick={() => setProductId(null)}>clear</button>
</div>
)}
{showProducts && productSearch.length >= 1 && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-48 overflow-auto">
{products.length === 0 ? <div className="p-3 text-sm text-muted-foreground">No products found</div> : products.map((p) => (
<button key={p.id} type="button" className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex justify-between" onClick={() => selectProduct(p)}>
<span>{p.brand ? `${p.brand} ` : ''}{p.name}{p.sku ? ` (${p.sku})` : ''}</span>
{p.price && <span className="text-muted-foreground">${p.price}</span>}
</button>
))}
</div>
)}
</div>
)}
<div className="space-y-2"><Label>Description *</Label><Input value={description} onChange={(e) => setDescription(e.target.value)} required /></div> <div className="space-y-2"><Label>Description *</Label><Input value={description} onChange={(e) => setDescription(e.target.value)} required /></div>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<div className="space-y-2"><Label>Qty</Label><Input type="number" step="0.001" value={qty} onChange={(e) => setQty(e.target.value)} /></div> <div className="space-y-2"><Label>Qty</Label><Input type="number" step="0.001" value={qty} onChange={(e) => setQty(e.target.value)} /></div>

View File

@@ -0,0 +1,180 @@
import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { dailyReportOptions } from '@/api/pos'
import { api } from '@/lib/api-client'
import { queryOptions } from '@tanstack/react-query'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
interface Location {
id: string
name: string
}
function locationsOptions() {
return queryOptions({
queryKey: ['locations'],
queryFn: () => api.get<{ data: Location[] }>('/v1/locations'),
})
}
export const Route = createFileRoute('/_authenticated/reports/daily')({
component: DailyReportPage,
})
const PAYMENT_LABELS: Record<string, string> = {
cash: 'Cash',
card_present: 'Card (Present)',
card_keyed: 'Card (Keyed)',
check: 'Check',
account_charge: 'Account',
}
function DailyReportPage() {
const today = new Date().toISOString().slice(0, 10)
const [date, setDate] = useState(today)
const [locationId, setLocationId] = useState<string | null>(null)
const { data: locationsData } = useQuery(locationsOptions())
const locations = locationsData?.data ?? []
// Auto-select first location
if (!locationId && locations.length > 0) {
setLocationId(locations[0].id)
}
const { data: report, isLoading } = useQuery(dailyReportOptions(locationId, date))
return (
<div className="space-y-6 max-w-3xl">
<h1 className="text-2xl font-bold">Daily Report</h1>
<div className="flex gap-4">
<div className="space-y-1">
<Label className="text-xs">Date</Label>
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} className="w-44" />
</div>
<div className="space-y-1">
<Label className="text-xs">Location</Label>
<Select value={locationId ?? ''} onValueChange={setLocationId}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Select location" />
</SelectTrigger>
<SelectContent>
{locations.map((loc) => (
<SelectItem key={loc.id} value={loc.id}>{loc.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
</div>
) : !report ? (
<p className="text-muted-foreground">Select a location and date to view the report.</p>
) : (
<div className="space-y-4">
{/* Sessions */}
<Card>
<CardHeader>
<CardTitle className="text-base">Drawer Sessions ({report.sessions.length})</CardTitle>
</CardHeader>
<CardContent>
{report.sessions.length === 0 ? (
<p className="text-sm text-muted-foreground">No drawer sessions on this date.</p>
) : (
<div className="space-y-2">
{report.sessions.map((s: any) => (
<div key={s.id} className="flex items-center justify-between p-2 rounded border text-sm">
<div>
<span className="font-medium">{s.register?.name ?? 'Unassigned'}</span>
<span className="text-muted-foreground ml-2">
{new Date(s.openedAt).toLocaleTimeString()} {s.closedAt ? new Date(s.closedAt).toLocaleTimeString() : 'Open'}
</span>
{s.openedBy && <span className="text-muted-foreground ml-2">({s.openedBy.firstName})</span>}
</div>
<div className="flex items-center gap-3">
<span className="tabular-nums text-sm">${s.grossSales.toFixed(2)}</span>
{s.overShort !== null && (
<Badge variant={s.overShort === 0 ? 'default' : 'destructive'} className="text-xs">
{s.overShort === 0 ? 'Balanced' : `${s.overShort > 0 ? '+' : ''}$${s.overShort.toFixed(2)}`}
</Badge>
)}
{s.status === 'open' && <Badge variant="outline" className="text-xs">Open</Badge>}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Sales Summary */}
<Card>
<CardHeader><CardTitle className="text-base">Sales</CardTitle></CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between"><span>Transactions</span><span>{report.sales.transactionCount}</span></div>
<div className="flex justify-between"><span>Gross Sales</span><span className="tabular-nums">${report.sales.grossSales.toFixed(2)}</span></div>
{report.sales.refundTotal > 0 && <div className="flex justify-between text-red-600"><span>Refunds</span><span className="tabular-nums">-${report.sales.refundTotal.toFixed(2)}</span></div>}
<Separator />
<div className="flex justify-between font-semibold"><span>Net Sales</span><span className="tabular-nums">${report.sales.netSales.toFixed(2)}</span></div>
{report.sales.voidCount > 0 && <div className="flex justify-between text-muted-foreground"><span>Voided</span><span>{report.sales.voidCount}</span></div>}
</CardContent>
</Card>
{/* Payment Breakdown */}
<Card>
<CardHeader><CardTitle className="text-base">Payments</CardTitle></CardHeader>
<CardContent className="space-y-2 text-sm">
{Object.entries(report.payments as Record<string, { count: number; total: number }>).map(([method, data]) => (
<div key={method} className="flex justify-between">
<span>{PAYMENT_LABELS[method] ?? method} ({data.count})</span>
<span className="tabular-nums">${data.total.toFixed(2)}</span>
</div>
))}
{Object.keys(report.payments).length === 0 && <p className="text-muted-foreground">No payments</p>}
</CardContent>
</Card>
{/* Discounts */}
{report.discounts.count > 0 && (
<Card>
<CardHeader><CardTitle className="text-base">Discounts</CardTitle></CardHeader>
<CardContent className="text-sm">
<div className="flex justify-between"><span>Total ({report.discounts.count} transactions)</span><span className="tabular-nums text-green-600">-${report.discounts.total.toFixed(2)}</span></div>
</CardContent>
</Card>
)}
{/* Cash Summary */}
<Card>
<CardHeader><CardTitle className="text-base">Cash</CardTitle></CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between"><span>Total Opening</span><span className="tabular-nums">${report.cash.totalOpening.toFixed(2)}</span></div>
<div className="flex justify-between"><span>Cash Sales</span><span className="tabular-nums">${report.cash.totalCashSales.toFixed(2)}</span></div>
{report.cash.totalCashIn > 0 && <div className="flex justify-between text-green-600"><span>Cash In</span><span className="tabular-nums">+${report.cash.totalCashIn.toFixed(2)}</span></div>}
{report.cash.totalCashOut > 0 && <div className="flex justify-between text-red-600"><span>Cash Out</span><span className="tabular-nums">-${report.cash.totalCashOut.toFixed(2)}</span></div>}
<Separator />
<div className="flex justify-between font-medium"><span>Expected Total</span><span className="tabular-nums">${report.cash.totalExpected.toFixed(2)}</span></div>
<div className="flex justify-between"><span>Actual Total</span><span className="tabular-nums">${report.cash.totalActual.toFixed(2)}</span></div>
<div className={`flex justify-between font-bold ${report.cash.totalOverShort === 0 ? 'text-green-600' : 'text-red-600'}`}>
<span>{report.cash.totalOverShort >= 0 ? 'Over' : 'Short'}</span>
<span className="tabular-nums">${Math.abs(report.cash.totalOverShort).toFixed(2)}</span>
</div>
</CardContent>
</Card>
</div>
)}
</div>
)
}

View File

@@ -15,7 +15,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { moduleListOptions, moduleMutations, moduleKeys } from '@/api/modules' import { moduleListOptions, moduleMutations, moduleKeys } from '@/api/modules'
import { Save, Plus, Trash2, MapPin, Building, ImageIcon, Blocks, Lock, Settings2 } from 'lucide-react' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Save, Plus, Trash2, MapPin, Building, ImageIcon, Blocks, Lock, Settings2, Receipt, ShieldCheck } from 'lucide-react'
import { OVERRIDE_ACTIONS, getRequiredOverrides, setRequiredOverrides, getDiscountThreshold, setDiscountThreshold, type OverrideAction } from '@/components/pos/pos-manager-override'
import { toast } from 'sonner' import { toast } from 'sonner'
interface StoreSettings { interface StoreSettings {
@@ -118,128 +120,150 @@ function SettingsPage() {
<div className="space-y-6 max-w-3xl"> <div className="space-y-6 max-w-3xl">
<h1 className="text-2xl font-bold">Settings</h1> <h1 className="text-2xl font-bold">Settings</h1>
{/* Store Info */} <Tabs defaultValue="store">
<Card> <TabsList>
<CardHeader className="flex flex-row items-center justify-between"> <TabsTrigger value="store">Store</TabsTrigger>
<CardTitle className="text-lg flex items-center gap-2"> <TabsTrigger value="locations">Locations</TabsTrigger>
<Building className="h-5 w-5" />Store Information <TabsTrigger value="modules">Modules</TabsTrigger>
</CardTitle> <TabsTrigger value="receipt">Receipt</TabsTrigger>
{!editing && <Button variant="outline" size="sm" onClick={startEdit}>Edit</Button>} <TabsTrigger value="pos">POS Security</TabsTrigger>
{editing && ( <TabsTrigger value="advanced">Advanced</TabsTrigger>
<div className="flex gap-2"> </TabsList>
<Button size="sm" onClick={saveEdit} disabled={updateMutation.isPending}>
<Save className="mr-1 h-3 w-3" />{updateMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditing(false)}>Cancel</Button>
</div>
)}
</CardHeader>
<CardContent className="space-y-6">
{/* Logo upload */}
<div>
<LogoUpload entityId={store.id} category="logo" label="Store Logo" description="Used on PDFs, sidebar, and login screen" />
</div>
{editing ? ( <TabsContent value="store" className="mt-4">
<div className="space-y-4"> <Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <CardHeader className="flex flex-row items-center justify-between">
<div className="space-y-2"> <CardTitle className="text-lg flex items-center gap-2">
<Label>Store Name *</Label> <Building className="h-5 w-5" />Store Information
<Input value={fields.name} onChange={(e) => setFields((p) => ({ ...p, name: e.target.value }))} /> </CardTitle>
</div> {!editing && <Button variant="outline" size="sm" onClick={startEdit}>Edit</Button>}
<div className="space-y-2"> {editing && (
<Label>Timezone</Label> <div className="flex gap-2">
<Input value={fields.timezone} onChange={(e) => setFields((p) => ({ ...p, timezone: e.target.value }))} placeholder="America/Chicago" /> <Button size="sm" onClick={saveEdit} disabled={updateMutation.isPending}>
<Save className="mr-1 h-3 w-3" />{updateMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditing(false)}>Cancel</Button>
</div> </div>
)}
</CardHeader>
<CardContent className="space-y-6">
<div>
<LogoUpload entityId={store.id} category="logo" label="Store Logo" description="Used on PDFs, sidebar, and login screen" />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> {editing ? (
<Label>Phone</Label> <div className="space-y-4">
<Input value={fields.phone} onChange={(e) => setFields((p) => ({ ...p, phone: e.target.value }))} /> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
</div> <div className="space-y-2">
<div className="space-y-2"> <Label>Store Name *</Label>
<Label>Email</Label> <Input value={fields.name} onChange={(e) => setFields((p) => ({ ...p, name: e.target.value }))} />
<Input value={fields.email} onChange={(e) => setFields((p) => ({ ...p, email: e.target.value }))} /> </div>
</div> <div className="space-y-2">
</div> <Label>Timezone</Label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <Input value={fields.timezone} onChange={(e) => setFields((p) => ({ ...p, timezone: e.target.value }))} placeholder="America/Chicago" />
<div className="space-y-2"> </div>
<Label>Street</Label> </div>
<Input value={fields.street} onChange={(e) => setFields((p) => ({ ...p, street: e.target.value }))} /> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
</div> <div className="space-y-2">
<div className="grid grid-cols-3 gap-2"> <Label>Phone</Label>
<div className="space-y-2"> <Input value={fields.phone} onChange={(e) => setFields((p) => ({ ...p, phone: e.target.value }))} />
<Label>City</Label> </div>
<Input value={fields.city} onChange={(e) => setFields((p) => ({ ...p, city: e.target.value }))} /> <div className="space-y-2">
<Label>Email</Label>
<Input value={fields.email} onChange={(e) => setFields((p) => ({ ...p, email: e.target.value }))} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Street</Label>
<Input value={fields.street} onChange={(e) => setFields((p) => ({ ...p, street: e.target.value }))} />
</div>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-2">
<Label>City</Label>
<Input value={fields.city} onChange={(e) => setFields((p) => ({ ...p, city: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>State</Label>
<Input value={fields.state} onChange={(e) => setFields((p) => ({ ...p, state: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>ZIP</Label>
<Input value={fields.zip} onChange={(e) => setFields((p) => ({ ...p, zip: e.target.value }))} />
</div>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>State</Label> <Label>Notes</Label>
<Input value={fields.state} onChange={(e) => setFields((p) => ({ ...p, state: e.target.value }))} /> <Textarea value={fields.notes} onChange={(e) => setFields((p) => ({ ...p, notes: e.target.value }))} rows={2} />
</div>
<div className="space-y-2">
<Label>ZIP</Label>
<Input value={fields.zip} onChange={(e) => setFields((p) => ({ ...p, zip: e.target.value }))} />
</div> </div>
</div> </div>
</div> ) : (
<div className="space-y-2"> <div className="space-y-4">
<Label>Notes</Label> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Textarea value={fields.notes} onChange={(e) => setFields((p) => ({ ...p, notes: e.target.value }))} rows={2} /> <div className="space-y-2 text-sm">
</div> <div className="text-lg font-semibold">{store.name}</div>
</div> <div><span className="text-muted-foreground">Phone:</span> {store.phone ?? '-'}</div>
) : ( <div><span className="text-muted-foreground">Email:</span> {store.email ?? '-'}</div>
<div className="space-y-4"> <div><span className="text-muted-foreground">Timezone:</span> {store.timezone}</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> </div>
<div className="space-y-2 text-sm"> <div className="space-y-2 text-sm">
<div className="text-lg font-semibold">{store.name}</div> {store.address && (store.address.street || store.address.city) ? (
<div><span className="text-muted-foreground">Phone:</span> {store.phone ?? '-'}</div> <>
<div><span className="text-muted-foreground">Email:</span> {store.email ?? '-'}</div> <div className="font-medium flex items-center gap-1"><MapPin className="h-3 w-3" />Address</div>
<div><span className="text-muted-foreground">Timezone:</span> {store.timezone}</div> {store.address.street && <div>{store.address.street}</div>}
<div>{[store.address.city, store.address.state, store.address.zip].filter(Boolean).join(', ')}</div>
</>
) : (
<div className="text-muted-foreground">No address set</div>
)}
{store.notes && <div className="mt-2"><span className="text-muted-foreground">Notes:</span> {store.notes}</div>}
</div>
</div>
</div> </div>
<div className="space-y-2 text-sm"> )}
{store.address && (store.address.street || store.address.city) ? ( </CardContent>
<> </Card>
<div className="font-medium flex items-center gap-1"><MapPin className="h-3 w-3" />Address</div> </TabsContent>
{store.address.street && <div>{store.address.street}</div>}
<div>{[store.address.city, store.address.state, store.address.zip].filter(Boolean).join(', ')}</div> <TabsContent value="locations" className="mt-4">
</> <Card>
) : ( <CardHeader className="flex flex-row items-center justify-between">
<div className="text-muted-foreground">No address set</div> <CardTitle className="text-lg flex items-center gap-2">
)} <MapPin className="h-5 w-5" />Locations
{store.notes && <div className="mt-2"><span className="text-muted-foreground">Notes:</span> {store.notes}</div>} </CardTitle>
<AddLocationDialog open={addLocationOpen} onOpenChange={setAddLocationOpen} />
</CardHeader>
<CardContent>
{locations.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No locations yet add your first store location</p>
) : (
<div className="space-y-3">
{locations.map((loc) => (
<LocationCard key={loc.id} location={loc} />
))}
</div> </div>
</div> )}
</div> </CardContent>
)} </Card>
</CardContent> </TabsContent>
</Card>
{/* Locations */} <TabsContent value="modules" className="mt-4">
<Card> <ModulesCard />
<CardHeader className="flex flex-row items-center justify-between"> </TabsContent>
<CardTitle className="text-lg flex items-center gap-2">
<MapPin className="h-5 w-5" />Locations
</CardTitle>
<AddLocationDialog open={addLocationOpen} onOpenChange={setAddLocationOpen} />
</CardHeader>
<CardContent>
{locations.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No locations yet add your first store location</p>
) : (
<div className="space-y-3">
{locations.map((loc) => (
<LocationCard key={loc.id} location={loc} />
))}
</div>
)}
</CardContent>
</Card>
{/* Modules */} <TabsContent value="receipt" className="mt-4">
<ModulesCard /> <ReceiptSettingsCard />
</TabsContent>
{/* App Configuration */} <TabsContent value="pos" className="mt-4">
<AppConfigCard /> <ManagerOverridesCard />
</TabsContent>
<TabsContent value="advanced" className="mt-4">
<AppConfigCard />
</TabsContent>
</Tabs>
</div> </div>
) )
} }
@@ -376,6 +400,174 @@ function AppConfigCard() {
) )
} }
const RECEIPT_FIELDS = [
{ key: 'receipt_header', label: 'Header Text', placeholder: "e.g. San Antonio's Premier String Shop", multiline: false },
{ key: 'receipt_footer', label: 'Footer Message', placeholder: 'e.g. Thank you for your business!', multiline: false },
{ key: 'receipt_return_policy', label: 'Return Policy', placeholder: 'e.g. Returns accepted within 30 days with receipt.', multiline: true },
{ key: 'receipt_social', label: 'Website / Social', placeholder: 'e.g. www.demostore.com | @demostore', multiline: false },
]
function ReceiptSettingsCard() {
const queryClient = useQueryClient()
const hasPermission = useAuthStore((s) => s.hasPermission)
const canEdit = hasPermission('settings.edit')
const { data: configData, isLoading } = useQuery(configOptions())
const configs = configData?.data ?? []
const [editing, setEditing] = useState(false)
const [fields, setFields] = useState<Record<string, string>>({})
function startEdit() {
const f: Record<string, string> = {}
for (const rf of RECEIPT_FIELDS) {
f[rf.key] = configs.find((c) => c.key === rf.key)?.value ?? ''
}
setFields(f)
setEditing(true)
}
const saveMutation = useMutation({
mutationFn: async () => {
for (const rf of RECEIPT_FIELDS) {
await api.patch(`/v1/config/${rf.key}`, { value: fields[rf.key] || '' })
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['config'] })
toast.success('Receipt settings saved')
setEditing(false)
},
onError: (err) => toast.error(err.message),
})
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Receipt className="h-5 w-5" />Receipt Customization
</CardTitle>
{canEdit && !editing && <Button variant="outline" size="sm" onClick={startEdit}>Edit</Button>}
{editing && (
<div className="flex gap-2">
<Button size="sm" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
<Save className="mr-1 h-3 w-3" />{saveMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button variant="ghost" size="sm" onClick={() => setEditing(false)}>Cancel</Button>
</div>
)}
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-32 w-full" />
) : editing ? (
<div className="space-y-4">
{RECEIPT_FIELDS.map((rf) => (
<div key={rf.key} className="space-y-2">
<Label>{rf.label}</Label>
{rf.multiline ? (
<Textarea
value={fields[rf.key] ?? ''}
onChange={(e) => setFields((p) => ({ ...p, [rf.key]: e.target.value }))}
placeholder={rf.placeholder}
rows={2}
/>
) : (
<Input
value={fields[rf.key] ?? ''}
onChange={(e) => setFields((p) => ({ ...p, [rf.key]: e.target.value }))}
placeholder={rf.placeholder}
/>
)}
</div>
))}
</div>
) : (
<div className="space-y-3">
{RECEIPT_FIELDS.map((rf) => {
const value = configs.find((c) => c.key === rf.key)?.value
return (
<div key={rf.key} className="flex justify-between items-start">
<span className="text-sm text-muted-foreground">{rf.label}</span>
<span className="text-sm text-right max-w-[60%]">{value || <span className="text-muted-foreground/50">Not set</span>}</span>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
)
}
function ManagerOverridesCard() {
const [overrides, setOverrides] = useState<Set<OverrideAction>>(() => getRequiredOverrides())
const [threshold, setThreshold] = useState(() => getDiscountThreshold())
function toggle(action: OverrideAction) {
setOverrides((prev) => {
const next = new Set(prev)
if (next.has(action)) next.delete(action)
else next.add(action)
setRequiredOverrides(next)
return next
})
}
return (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<ShieldCheck className="h-5 w-5" />Manager Overrides
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
When enabled, these actions will require a manager or admin to enter their PIN before proceeding. This setting is stored per device.
</p>
<div className="space-y-2">
{OVERRIDE_ACTIONS.map((action) => (
<div key={action.key} className="flex items-center justify-between p-3 rounded-md border">
<div className="min-w-0">
<span className="font-medium text-sm">{action.label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{action.description}</p>
</div>
<Switch
checked={overrides.has(action.key)}
onCheckedChange={() => toggle(action.key)}
/>
</div>
))}
</div>
<div className="mt-4 p-3 rounded-md border">
<div className="flex items-center justify-between">
<div className="min-w-0">
<span className="font-medium text-sm">Discount Threshold</span>
<p className="text-xs text-muted-foreground mt-0.5">Require manager approval for discounts at or above this percentage. Set to 0 to disable.</p>
</div>
<div className="flex items-center gap-1">
<Input
type="number"
min="0"
max="100"
className="w-20 h-8 text-sm text-right"
value={threshold}
onChange={(e) => {
const v = parseInt(e.target.value) || 0
setThreshold(v)
setDiscountThreshold(v)
}}
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
</div>
</div>
</CardContent>
</Card>
)
}
function LocationCard({ location }: { location: Location }) { function LocationCard({ location }: { location: Location }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)

View File

@@ -1,7 +1,12 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import { createFileRoute, useRouter, redirect } from '@tanstack/react-router' import { createFileRoute, useRouter, redirect } from '@tanstack/react-router'
import { useAuthStore } from '@/stores/auth.store' import { useAuthStore } from '@/stores/auth.store'
import { login } from '@/api/auth' import { login, forgotPassword } from '@/api/auth'
interface Branding {
name: string | null
hasLogo: boolean
}
export const Route = createFileRoute('/login')({ export const Route = createFileRoute('/login')({
beforeLoad: () => { beforeLoad: () => {
@@ -20,6 +25,16 @@ function LoginPage() {
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [branding, setBranding] = useState<Branding | null>(null)
const [forgotMode, setForgotMode] = useState(false)
const [forgotSent, setForgotSent] = useState(false)
useEffect(() => {
fetch('/v1/store/branding')
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setBranding(data) })
.catch(() => {})
}, [])
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
@@ -48,45 +63,128 @@ function LoginPage() {
style={{ backgroundColor: '#131c2e', borderColor: '#1e2d45' }} style={{ backgroundColor: '#131c2e', borderColor: '#1e2d45' }}
> >
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-3xl font-bold" style={{ color: '#d8dfe9' }}>LunarFront</h1> {branding?.hasLogo ? (
<p className="text-sm mt-1" style={{ color: '#6b7a8d' }}>Small Business Management</p> <img src="/v1/store/logo" alt={branding.name ?? 'Store'} className="max-h-14 max-w-[220px] object-contain mx-auto" />
</div> ) : (
<form onSubmit={handleSubmit} className="space-y-4"> <h1 className="text-3xl font-bold" style={{ color: '#d8dfe9' }}>{branding?.name ?? 'LunarFront'}</h1>
<div className="space-y-2">
<label className="text-sm font-medium" style={{ color: '#b0bec5' }}>Email</label>
<input
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" style={{ color: '#b0bec5' }}>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input"
/>
</div>
{error && (
<p className="text-sm" style={{ color: '#e57373' }}>{error}</p>
)} )}
<button {branding?.name ? (
type="submit" <p className="text-[10px] mt-2" style={{ color: '#4a5568' }}>Powered by <span style={{ color: '#6b7a8d' }}>LunarFront</span></p>
disabled={loading} ) : (
className="h-9 w-full rounded-md border text-sm font-medium transition-colors disabled:opacity-50" <p className="text-sm mt-1" style={{ color: '#6b7a8d' }}>Small Business Management</p>
style={{ backgroundColor: 'transparent', color: '#d0d8e0', borderColor: '#3a4a62' }} )}
onMouseEnter={(e) => { (e.target as HTMLElement).style.backgroundColor = '#1e2d45' }} </div>
onMouseLeave={(e) => { (e.target as HTMLElement).style.backgroundColor = 'transparent' }} {forgotMode ? (
> forgotSent ? (
{loading ? 'Signing in...' : 'Sign in'} <div className="text-center space-y-4">
</button> <p className="text-sm" style={{ color: '#b0bec5' }}>If an account exists with that email, you will receive a password reset link.</p>
</form> <button
onClick={() => { setForgotMode(false); setForgotSent(false); setError('') }}
className="text-xs"
style={{ color: '#6b7a8d' }}
>
Back to sign in
</button>
</div>
) : (
<form onSubmit={async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await forgotPassword(email)
setForgotSent(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setLoading(false)
}
}} className="space-y-4">
<p className="text-sm" style={{ color: '#b0bec5' }}>Enter your email and we'll send you a reset link.</p>
<div className="space-y-2">
<label className="text-sm font-medium" style={{ color: '#b0bec5' }}>Email</label>
<input
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input"
/>
</div>
{error && (
<p className="text-sm" style={{ color: '#e57373' }}>{error}</p>
)}
<button
type="submit"
disabled={loading}
className="h-9 w-full rounded-md border text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'transparent', color: '#d0d8e0', borderColor: '#3a4a62' }}
onMouseEnter={(e) => { (e.target as HTMLElement).style.backgroundColor = '#1e2d45' }}
onMouseLeave={(e) => { (e.target as HTMLElement).style.backgroundColor = 'transparent' }}
>
{loading ? 'Sending...' : 'Send reset link'}
</button>
<div className="text-center">
<button
type="button"
onClick={() => { setForgotMode(false); setError('') }}
className="text-xs"
style={{ color: '#6b7a8d' }}
>
Back to sign in
</button>
</div>
</form>
)
) : (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium" style={{ color: '#b0bec5' }}>Email</label>
<input
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" style={{ color: '#b0bec5' }}>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input"
/>
</div>
{error && (
<p className="text-sm" style={{ color: '#e57373' }}>{error}</p>
)}
<button
type="submit"
disabled={loading}
className="h-9 w-full rounded-md border text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'transparent', color: '#d0d8e0', borderColor: '#3a4a62' }}
onMouseEnter={(e) => { (e.target as HTMLElement).style.backgroundColor = '#1e2d45' }}
onMouseLeave={(e) => { (e.target as HTMLElement).style.backgroundColor = 'transparent' }}
>
{loading ? 'Signing in...' : 'Sign in'}
</button>
<div className="text-center">
<button
type="button"
onClick={() => { setForgotMode(true); setError('') }}
className="text-xs"
style={{ color: '#6b7a8d' }}
>
Forgot password?
</button>
</div>
</form>
)}
</div> </div>
</div> </div>
) )

View File

@@ -1,14 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useAuthStore } from '@/stores/auth.store'
import { POSRegister } from '@/components/pos/pos-register' import { POSRegister } from '@/components/pos/pos-register'
export const Route = createFileRoute('/pos')({ export const Route = createFileRoute('/pos')({
beforeLoad: () => {
const { token } = useAuthStore.getState()
if (!token) {
throw redirect({ to: '/login' })
}
},
component: POSPage, component: POSPage,
}) })

View File

@@ -0,0 +1,154 @@
import { useState, useEffect } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { resetPassword } from '@/api/auth'
interface Branding {
name: string | null
hasLogo: boolean
}
export const Route = createFileRoute('/reset-password')({
component: ResetPasswordPage,
})
function ResetPasswordPage() {
const { token } = Route.useSearch() as { token?: string }
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const [branding, setBranding] = useState<Branding | null>(null)
useEffect(() => {
fetch('/v1/store/branding')
.then((r) => r.ok ? r.json() : null)
.then((data) => { if (data) setBranding(data) })
.catch(() => {})
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (!token) {
setError('Missing reset token. Please use the link from your email.')
return
}
if (password !== confirmPassword) {
setError('Passwords do not match')
return
}
if (password.length < 12) {
setError('Password must be at least 12 characters')
return
}
setLoading(true)
try {
await resetPassword(token, password)
setSuccess(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Reset failed. The link may have expired.')
} finally {
setLoading(false)
}
}
return (
<div
className="flex min-h-screen items-center justify-center"
style={{ background: 'linear-gradient(135deg, #0f1724 0%, #142038 100%)' }}
>
<div
className="w-full max-w-sm rounded-xl border p-8 shadow-2xl"
style={{ backgroundColor: '#131c2e', borderColor: '#1e2d45' }}
>
<div className="text-center mb-8">
{branding?.hasLogo ? (
<img src="/v1/store/logo" alt={branding.name ?? 'Store'} className="max-h-14 max-w-[220px] object-contain mx-auto" />
) : (
<h1 className="text-3xl font-bold" style={{ color: '#d8dfe9' }}>{branding?.name ?? 'LunarFront'}</h1>
)}
{branding?.name ? (
<p className="text-[10px] mt-2" style={{ color: '#4a5568' }}>Powered by <span style={{ color: '#6b7a8d' }}>LunarFront</span></p>
) : (
<p className="text-sm mt-1" style={{ color: '#6b7a8d' }}>Small Business Management</p>
)}
</div>
{success ? (
<div className="text-center space-y-4">
<p style={{ color: '#81c784' }}>Password reset successfully.</p>
<Link
to="/login"
className="inline-block h-9 rounded-md border px-4 py-2 text-sm font-medium transition-colors"
style={{ color: '#d0d8e0', borderColor: '#3a4a62' }}
>
Sign in
</Link>
</div>
) : !token ? (
<div className="text-center space-y-4">
<p style={{ color: '#e57373' }}>Invalid reset link. Please request a new one.</p>
<Link
to="/login"
className="inline-block h-9 rounded-md border px-4 py-2 text-sm font-medium transition-colors"
style={{ color: '#d0d8e0', borderColor: '#3a4a62' }}
>
Back to sign in
</Link>
</div>
) : (
<>
<h2 className="text-lg font-semibold text-center mb-4" style={{ color: '#d8dfe9' }}>Set new password</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium" style={{ color: '#b0bec5' }}>New password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={12}
placeholder="At least 12 characters"
className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" style={{ color: '#b0bec5' }}>Confirm password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="h-9 w-full rounded-md border px-3 py-1 text-sm outline-none login-input"
/>
</div>
{error && (
<p className="text-sm" style={{ color: '#e57373' }}>{error}</p>
)}
<button
type="submit"
disabled={loading}
className="h-9 w-full rounded-md border text-sm font-medium transition-colors disabled:opacity-50"
style={{ backgroundColor: 'transparent', color: '#d0d8e0', borderColor: '#3a4a62' }}
onMouseEnter={(e) => { (e.target as HTMLElement).style.backgroundColor = '#1e2d45' }}
onMouseLeave={(e) => { (e.target as HTMLElement).style.backgroundColor = 'transparent' }}
>
{loading ? 'Resetting...' : 'Reset password'}
</button>
<div className="text-center">
<Link to="/login" className="text-xs" style={{ color: '#6b7a8d' }}>
Back to sign in
</Link>
</div>
</form>
</>
)}
</div>
</div>
)
}

View File

@@ -1,21 +1,72 @@
import { create } from 'zustand' import { create } from 'zustand'
interface POSUser {
id: string
email: string
firstName: string
lastName: string
role: string
}
type ReceiptFormat = 'thermal' | 'full'
interface POSState { interface POSState {
currentTransactionId: string | null currentTransactionId: string | null
locationId: string | null locationId: string | null
registerId: string | null
drawerSessionId: string | null drawerSessionId: string | null
locked: boolean
cashier: POSUser | null
token: string | null
lastActivity: number
accountId: string | null
accountName: string | null
accountPhone: string | null
accountEmail: string | null
receiptFormat: ReceiptFormat
setTransaction: (id: string | null) => void setTransaction: (id: string | null) => void
setLocation: (id: string) => void setLocation: (id: string) => void
setRegister: (id: string | null) => void
setDrawerSession: (id: string | null) => void setDrawerSession: (id: string | null) => void
unlock: (user: POSUser, token: string) => void
lock: () => void
touchActivity: () => void
setAccount: (id: string, name: string, phone?: string | null, email?: string | null) => void
clearAccount: () => void
setReceiptFormat: (format: ReceiptFormat) => void
reset: () => void reset: () => void
} }
const RECEIPT_FORMAT_KEY = 'pos_receipt_format'
function getStoredReceiptFormat(): ReceiptFormat {
const stored = localStorage.getItem(RECEIPT_FORMAT_KEY)
return stored === 'full' ? 'full' : 'thermal'
}
export const usePOSStore = create<POSState>((set) => ({ export const usePOSStore = create<POSState>((set) => ({
currentTransactionId: null, currentTransactionId: null,
locationId: null, locationId: null,
drawerSessionId: null, registerId: localStorage.getItem('pos_register_id') ?? null,
drawerSessionId: localStorage.getItem('pos_drawer_session_id') ?? null,
locked: true,
cashier: null,
token: null,
lastActivity: Date.now(),
accountId: null,
accountName: null,
accountPhone: null,
accountEmail: null,
receiptFormat: getStoredReceiptFormat(),
setTransaction: (id) => set({ currentTransactionId: id }), setTransaction: (id) => set({ currentTransactionId: id }),
setLocation: (id) => set({ locationId: id }), setLocation: (id) => set({ locationId: id }),
setDrawerSession: (id) => set({ drawerSessionId: id }), setRegister: (id) => { if (id) localStorage.setItem('pos_register_id', id); else localStorage.removeItem('pos_register_id'); set({ registerId: id }) },
reset: () => set({ currentTransactionId: null }), setDrawerSession: (id) => { if (id) localStorage.setItem('pos_drawer_session_id', id); else localStorage.removeItem('pos_drawer_session_id'); set({ drawerSessionId: id }) },
unlock: (user, token) => set({ locked: false, cashier: user, token, lastActivity: Date.now() }),
lock: () => set({ locked: true, currentTransactionId: null }),
touchActivity: () => set({ lastActivity: Date.now() }),
setAccount: (id, name, phone, email) => set({ accountId: id, accountName: name, accountPhone: phone ?? null, accountEmail: email ?? null }),
clearAccount: () => set({ accountId: null, accountName: null, accountPhone: null, accountEmail: null }),
setReceiptFormat: (format) => { localStorage.setItem(RECEIPT_FORMAT_KEY, format); set({ receiptFormat: format }) },
reset: () => set({ currentTransactionId: null, accountId: null, accountName: null, accountPhone: null, accountEmail: null }),
})) }))

View File

@@ -44,6 +44,7 @@ export interface Product {
isSerialized: boolean isSerialized: boolean
isRental: boolean isRental: boolean
isDualUseRepair: boolean isDualUseRepair: boolean
isConsumable: boolean
price: string | null price: string | null
minPrice: string | null minPrice: string | null
rentalRateMonthly: string | null rentalRateMonthly: string | null

View File

@@ -28,7 +28,7 @@ export interface RepairTicket {
export interface RepairLineItem { export interface RepairLineItem {
id: string id: string
repairTicketId: string repairTicketId: string
itemType: 'labor' | 'part' | 'flat_rate' | 'misc' itemType: 'labor' | 'part' | 'flat_rate' | 'misc' | 'consumable'
description: string description: string
productId: string | null productId: string | null
qty: string qty: string
@@ -82,7 +82,7 @@ export interface RepairServiceTemplate {
itemCategory: string | null itemCategory: string | null
size: string | null size: string | null
description: string | null description: string | null
itemType: 'labor' | 'part' | 'flat_rate' | 'misc' itemType: 'labor' | 'part' | 'flat_rate' | 'misc' | 'consumable'
defaultPrice: string defaultPrice: string
defaultCost: string | null defaultCost: string | null
sortOrder: number sortOrder: number

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from 'bun:test'
import { TaxService } from '../../src/services/tax.service.js'
describe('TaxService.repairItemTypeToTaxCategory — consumable', () => {
it('maps consumable to exempt', () => {
expect(TaxService.repairItemTypeToTaxCategory('consumable')).toBe('exempt')
})
it('maps labor to service', () => {
expect(TaxService.repairItemTypeToTaxCategory('labor')).toBe('service')
})
it('maps part to goods', () => {
expect(TaxService.repairItemTypeToTaxCategory('part')).toBe('goods')
})
it('maps flat_rate to goods', () => {
expect(TaxService.repairItemTypeToTaxCategory('flat_rate')).toBe('goods')
})
it('maps misc to goods', () => {
expect(TaxService.repairItemTypeToTaxCategory('misc')).toBe('goods')
})
it('maps unknown type to goods (default)', () => {
expect(TaxService.repairItemTypeToTaxCategory('anything_else')).toBe('goods')
})
})
describe('TaxService.getRateForLocation — exempt category', () => {
it('returns 0 for exempt tax category without DB call', async () => {
// Passing a fake DB and fake locationId — should short-circuit and return 0
const fakeDb = {} as any
const rate = await TaxService.getRateForLocation(fakeDb, 'fake-id', 'exempt')
expect(rate).toBe(0)
})
})

View File

@@ -114,6 +114,81 @@ suite('POS', { tags: ['pos'] }, (t) => {
t.assert.ok(res.data.pagination) t.assert.ok(res.data.pagination)
}) })
// ─── Drawer Adjustments ─────────────────────────────────────────────────────
t.test('adds cash out adjustment to open drawer', { tags: ['drawer', 'adjustments'] }, async () => {
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 200 })
t.assert.status(drawer, 201)
const res = await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, {
type: 'cash_out',
amount: 50,
reason: 'Bank deposit',
})
t.assert.status(res, 201)
t.assert.equal(res.data.type, 'cash_out')
t.assert.equal(parseFloat(res.data.amount), 50)
// Cleanup
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 150 })
})
t.test('adds cash in adjustment to open drawer', { tags: ['drawer', 'adjustments'] }, async () => {
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
t.assert.status(drawer, 201)
const res = await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, {
type: 'cash_in',
amount: 25,
reason: 'Change from petty cash',
})
t.assert.status(res, 201)
t.assert.equal(res.data.type, 'cash_in')
// Cleanup
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 125 })
})
t.test('lists drawer adjustments', { tags: ['drawer', 'adjustments'] }, async () => {
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, { type: 'cash_out', amount: 30, reason: 'Test out' })
await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, { type: 'cash_in', amount: 10, reason: 'Test in' })
const res = await t.api.get(`/v1/drawer/${drawer.data.id}/adjustments`)
t.assert.status(res, 200)
t.assert.equal(res.data.data.length, 2)
// Cleanup
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 80 })
})
t.test('drawer close includes adjustments in expected balance', { tags: ['drawer', 'adjustments', 'close'] }, async () => {
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 200 })
t.assert.status(drawer, 201)
// Cash out $50, cash in $20 → net adjustment = -$30
await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, { type: 'cash_out', amount: 50, reason: 'Bank drop' })
await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, { type: 'cash_in', amount: 20, reason: 'Extra change' })
// Close — expected = 200 (opening) + 0 (no sales) + 20 (in) - 50 (out) = 170
const closed = await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 170 })
t.assert.status(closed, 200)
t.assert.equal(parseFloat(closed.data.expectedBalance), 170)
t.assert.equal(parseFloat(closed.data.overShort), 0)
})
t.test('rejects adjustment on closed drawer', { tags: ['drawer', 'adjustments', 'validation'] }, async () => {
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 })
const res = await t.api.post(`/v1/drawer/${drawer.data.id}/adjustments`, {
type: 'cash_out',
amount: 10,
reason: 'Should fail',
})
t.assert.status(res, 409)
})
// ─── Transactions ────────────────────────────────────────────────────────── // ─── Transactions ──────────────────────────────────────────────────────────
t.test('creates a sale transaction', { tags: ['transactions', 'create'] }, async () => { t.test('creates a sale transaction', { tags: ['transactions', 'create'] }, async () => {
@@ -620,4 +695,360 @@ suite('POS', { tags: ['pos'] }, (t) => {
t.assert.status(closed, 200) t.assert.status(closed, 200)
t.assert.equal(closed.data.status, 'closed') t.assert.equal(closed.data.status, 'closed')
}) })
// ─── Repair → POS Integration ─────────────────────────────────────────────
t.test('lists ready-for-pickup repair tickets', { tags: ['repair-pos', 'list'] }, async () => {
// Create a repair ticket and move it to 'ready'
const ticket = await t.api.post('/v1/repair-tickets', {
customerName: 'POS Pickup Customer',
customerPhone: '555-0100',
problemDescription: 'Needs pickup test',
})
t.assert.status(ticket, 201)
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'intake' })
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'diagnosing' })
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'in_progress' })
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'ready' })
const res = await t.api.get('/v1/repair-tickets/ready')
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 1)
const found = res.data.data.find((t: any) => t.id === ticket.data.id)
t.assert.ok(found)
t.assert.equal(found.status, 'ready')
})
t.test('searches ready tickets by customer name', { tags: ['repair-pos', 'search'] }, async () => {
const res = await t.api.get('/v1/repair-tickets/ready', { q: 'POS Pickup' })
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 1)
})
t.test('creates repair payment transaction from ticket', { tags: ['repair-pos', 'create'] }, async () => {
// Create ticket with line items
const ticket = await t.api.post('/v1/repair-tickets', {
customerName: 'Repair Checkout Test',
problemDescription: 'Full checkout flow',
locationId: LOCATION_ID,
})
t.assert.status(ticket, 201)
// Add line items — labor (service tax) + part (goods tax) + consumable (excluded)
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, {
itemType: 'labor',
description: 'Diagnostic labor',
qty: 1,
unitPrice: 60,
totalPrice: 60,
})
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, {
itemType: 'part',
description: 'Replacement widget',
qty: 2,
unitPrice: 15,
totalPrice: 30,
})
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, {
itemType: 'consumable',
description: 'Shop supplies',
qty: 1,
unitPrice: 5,
totalPrice: 5,
})
// Move to ready
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'intake' })
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'diagnosing' })
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'in_progress' })
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'ready' })
// Create POS transaction from repair
const txn = await t.api.post(`/v1/transactions/from-repair/${ticket.data.id}`, {
locationId: LOCATION_ID,
})
t.assert.status(txn, 201)
t.assert.equal(txn.data.transactionType, 'repair_payment')
t.assert.equal(txn.data.repairTicketId, ticket.data.id)
// Should have 2 line items (consumable excluded)
t.assert.equal(txn.data.lineItems.length, 2)
// Subtotal should be labor ($60) + parts ($30) = $90
const subtotal = parseFloat(txn.data.subtotal)
t.assert.equal(subtotal, 90)
// Tax should be > 0 (location has both goods and service rates)
const taxTotal = parseFloat(txn.data.taxTotal)
t.assert.greaterThan(taxTotal, 0)
// Verify labor line item has service tax rate (5%)
const laborItem = txn.data.lineItems.find((i: any) => i.description === 'Diagnostic labor')
t.assert.ok(laborItem)
t.assert.equal(parseFloat(laborItem.taxRate), 0.05)
// Verify part line item has goods tax rate (8.25%)
const partItem = txn.data.lineItems.find((i: any) => i.description === 'Replacement widget')
t.assert.ok(partItem)
t.assert.equal(parseFloat(partItem.taxRate), 0.0825)
})
t.test('rejects from-repair for non-ready ticket', { tags: ['repair-pos', 'validation'] }, async () => {
const ticket = await t.api.post('/v1/repair-tickets', {
customerName: 'Not Ready',
problemDescription: 'Still in progress',
})
t.assert.status(ticket, 201)
const res = await t.api.post(`/v1/transactions/from-repair/${ticket.data.id}`, {
locationId: LOCATION_ID,
})
t.assert.status(res, 400)
})
t.test('rejects duplicate pending repair payment', { tags: ['repair-pos', 'validation'] }, async () => {
// Create ready ticket with items
const ticket = await t.api.post('/v1/repair-tickets', {
customerName: 'Duplicate Test',
problemDescription: 'Duplicate check',
locationId: LOCATION_ID,
})
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, {
itemType: 'labor', description: 'Work', qty: 1, unitPrice: 50, totalPrice: 50,
})
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'intake' })
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'diagnosing' })
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'in_progress' })
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'ready' })
// First creation succeeds
const first = await t.api.post(`/v1/transactions/from-repair/${ticket.data.id}`, { locationId: LOCATION_ID })
t.assert.status(first, 201)
// Second creation fails (pending transaction exists)
const second = await t.api.post(`/v1/transactions/from-repair/${ticket.data.id}`, { locationId: LOCATION_ID })
t.assert.status(second, 409)
})
t.test('completing repair payment marks ticket as picked_up', { tags: ['repair-pos', 'complete', 'e2e'] }, async () => {
// Open drawer
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 200 })
t.assert.status(drawer, 201)
// Create ready ticket
const ticket = await t.api.post('/v1/repair-tickets', {
customerName: 'Pickup Complete Test',
problemDescription: 'End to end',
locationId: LOCATION_ID,
})
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/line-items`, {
itemType: 'flat_rate', description: 'Service package', qty: 1, unitPrice: 100, totalPrice: 100,
})
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'intake' })
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'diagnosing' })
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'in_progress' })
await t.api.post(`/v1/repair-tickets/${ticket.data.id}/status`, { status: 'ready' })
// Create transaction from repair
const txn = await t.api.post(`/v1/transactions/from-repair/${ticket.data.id}`, { locationId: LOCATION_ID })
t.assert.status(txn, 201)
// Complete payment
const completed = await t.api.post(`/v1/transactions/${txn.data.id}/complete`, {
paymentMethod: 'card_present',
})
t.assert.status(completed, 200)
t.assert.equal(completed.data.status, 'completed')
// Verify ticket was updated to picked_up
const updatedTicket = await t.api.get(`/v1/repair-tickets/${ticket.data.id}`)
t.assert.status(updatedTicket, 200)
t.assert.equal(updatedTicket.data.status, 'picked_up')
t.assert.ok(updatedTicket.data.completedDate)
// Cleanup
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 200 })
})
// ─── Product isConsumable Filter ──────────────────────────────────────────
t.test('isConsumable filter excludes consumables from product search', { tags: ['repair-pos', 'products'] }, async () => {
// Create a consumable product
const consumable = await t.api.post('/v1/products', {
name: 'Test Shop Supply',
isConsumable: true,
price: 2.50,
})
t.assert.status(consumable, 201)
// Create a normal product
const normal = await t.api.post('/v1/products', {
name: 'Test Normal Product',
isConsumable: false,
price: 25,
})
t.assert.status(normal, 201)
// Search with isConsumable=false should exclude the consumable
const res = await t.api.get('/v1/products', { q: 'Test', isConsumable: 'false' })
t.assert.status(res, 200)
const ids = res.data.data.map((p: any) => p.id)
t.assert.ok(!ids.includes(consumable.data.id))
t.assert.ok(ids.includes(normal.data.id))
// Search with isConsumable=true should only show consumable
const res2 = await t.api.get('/v1/products', { q: 'Test Shop Supply', isConsumable: 'true' })
t.assert.status(res2, 200)
t.assert.ok(res2.data.data.some((p: any) => p.id === consumable.data.id))
})
// ─── Registers ────────────────────────────────────────────────────────────
t.test('creates a register', { tags: ['registers', 'create'] }, async () => {
const res = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Register 1' })
t.assert.status(res, 201)
t.assert.equal(res.data.name, 'Register 1')
t.assert.equal(res.data.locationId, LOCATION_ID)
t.assert.equal(res.data.isActive, true)
})
t.test('lists registers for a location', { tags: ['registers', 'list'] }, async () => {
await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Register 2' })
const res = await t.api.get('/v1/registers', { locationId: LOCATION_ID })
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 2)
t.assert.ok(res.data.pagination)
})
t.test('lists all registers (lookup)', { tags: ['registers', 'list'] }, async () => {
const res = await t.api.get('/v1/registers/all', { locationId: LOCATION_ID })
t.assert.status(res, 200)
t.assert.ok(res.data.data.length >= 2)
})
t.test('updates a register name', { tags: ['registers', 'update'] }, async () => {
const created = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Old Name' })
const res = await t.api.patch(`/v1/registers/${created.data.id}`, { name: 'New Name' })
t.assert.status(res, 200)
t.assert.equal(res.data.name, 'New Name')
})
t.test('deactivates a register', { tags: ['registers', 'delete'] }, async () => {
const created = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Delete Me' })
const res = await t.api.del(`/v1/registers/${created.data.id}`)
t.assert.status(res, 200)
t.assert.equal(res.data.isActive, false)
})
// ─── Drawer Reports (X/Z) ────────────────────────────────────────────────
t.test('cleanup: close any open drawers for report tests', { tags: ['reports', 'setup'] }, async () => {
const current = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
if (current.status === 200 && current.data?.id) {
await t.api.post(`/v1/drawer/${current.data.id}/close`, { closingBalance: 0 })
}
})
t.test('drawer report returns correct data for a session with transactions', { tags: ['reports', 'drawer'] }, async () => {
// Open drawer
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
t.assert.status(drawer, 201)
// Make a cash sale
const txn1 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
await t.api.post(`/v1/transactions/${txn1.data.id}/line-items`, { description: 'Report Item 1', qty: 1, unitPrice: 50 })
await t.api.post(`/v1/transactions/${txn1.data.id}/complete`, { paymentMethod: 'cash', amountTendered: 60 })
// Make a card sale
const txn2 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
await t.api.post(`/v1/transactions/${txn2.data.id}/line-items`, { description: 'Report Item 2', qty: 1, unitPrice: 30 })
await t.api.post(`/v1/transactions/${txn2.data.id}/complete`, { paymentMethod: 'card_present' })
// Void a transaction
const txn3 = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
await t.api.post(`/v1/transactions/${txn3.data.id}/line-items`, { description: 'Voided Item', qty: 1, unitPrice: 10 })
await t.api.post(`/v1/transactions/${txn3.data.id}/void`)
// Get X report (drawer still open)
const xReport = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`)
t.assert.status(xReport, 200)
t.assert.equal(xReport.data.sales.transactionCount, 2)
t.assert.greaterThan(xReport.data.sales.grossSales, 0)
// Voided transactions don't go through complete() so drawerSessionId isn't set
// They won't appear in the drawer report — this is correct behavior
t.assert.ok(xReport.data.payments.cash)
t.assert.ok(xReport.data.payments.card_present)
t.assert.equal(xReport.data.cash.actualBalance, null) // not closed yet
// Close drawer
const closingAmount = 100 + xReport.data.cash.cashSales
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: closingAmount })
// Get Z report (drawer closed)
const zReport = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`)
t.assert.status(zReport, 200)
t.assert.ok(zReport.data.session.closedAt)
t.assert.ok(zReport.data.cash.actualBalance !== null)
t.assert.ok(typeof zReport.data.cash.overShort === 'number')
})
t.test('drawerSessionId is populated on completed transactions', { tags: ['reports', 'drawer-session-id'] }, async () => {
// Cleanup any open drawer
const cur = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
if (cur.status === 200 && cur.data?.id) await t.api.post(`/v1/drawer/${cur.data.id}/close`, { closingBalance: 0 })
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, openingBalance: 100 })
t.assert.status(drawer, 201)
const txn = await t.api.post('/v1/transactions', { transactionType: 'sale', locationId: LOCATION_ID })
await t.api.post(`/v1/transactions/${txn.data.id}/line-items`, { description: 'Session ID Test', qty: 1, unitPrice: 20 })
await t.api.post(`/v1/transactions/${txn.data.id}/complete`, { paymentMethod: 'card_present' })
const completed = await t.api.get(`/v1/transactions/${txn.data.id}`)
t.assert.status(completed, 200)
t.assert.equal(completed.data.drawerSessionId, drawer.data.id)
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 })
})
// ─── Daily Report ─────────────────────────────────────────────────────────
t.test('daily report aggregates across sessions', { tags: ['reports', 'daily'] }, async () => {
const today = new Date().toISOString().slice(0, 10)
const res = await t.api.get('/v1/reports/daily', { locationId: LOCATION_ID, date: today })
t.assert.status(res, 200)
t.assert.equal(res.data.date, today)
t.assert.ok(res.data.location)
t.assert.ok(Array.isArray(res.data.sessions))
t.assert.ok(typeof res.data.sales.grossSales === 'number')
t.assert.ok(typeof res.data.payments === 'object')
t.assert.ok(typeof res.data.cash.totalExpected === 'number')
})
t.test('daily report rejects missing params', { tags: ['reports', 'daily', 'validation'] }, async () => {
const res = await t.api.get('/v1/reports/daily', {})
t.assert.status(res, 400)
})
t.test('opens drawer with register', { tags: ['registers', 'drawer'] }, async () => {
// Cleanup any open drawer
const cur = await t.api.get('/v1/drawer/current', { locationId: LOCATION_ID })
if (cur.status === 200 && cur.data?.id) await t.api.post(`/v1/drawer/${cur.data.id}/close`, { closingBalance: 0 })
const reg = await t.api.post('/v1/registers', { locationId: LOCATION_ID, name: 'Report Register' })
t.assert.status(reg, 201)
const drawer = await t.api.post('/v1/drawer/open', { locationId: LOCATION_ID, registerId: reg.data.id, openingBalance: 100 })
t.assert.status(drawer, 201)
// Get report to check register info
const report = await t.api.get(`/v1/reports/drawer/${drawer.data.id}`)
t.assert.status(report, 200)
t.assert.ok(report.data.session.register)
t.assert.equal(report.data.session.register.name, 'Report Register')
// Cleanup
await t.api.post(`/v1/drawer/${drawer.data.id}/close`, { closingBalance: 100 })
})
}) })

View File

@@ -4,7 +4,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "bun --watch run src/main.ts", "dev": "bun --env-file=../../.env --watch run src/main.ts",
"start": "bun run src/main.ts", "start": "bun run src/main.ts",
"test": "bun test || true", "test": "bun test || true",
"test:watch": "bun test --watch", "test:watch": "bun test --watch",

View File

@@ -0,0 +1,14 @@
DO $$ BEGIN
CREATE TYPE "adjustment_type" AS ENUM ('cash_in', 'cash_out');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE TABLE IF NOT EXISTS "drawer_adjustment" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"drawer_session_id" uuid NOT NULL REFERENCES "drawer_session"("id"),
"type" "adjustment_type" NOT NULL,
"amount" numeric(10, 2) NOT NULL,
"reason" text NOT NULL,
"created_by" uuid NOT NULL REFERENCES "user"("id"),
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);

View File

@@ -0,0 +1,20 @@
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "pin_hash" varchar(255);
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "employee_number" varchar(20) UNIQUE;
-- Auto-assign employee numbers to existing users
DO $$ DECLARE r RECORD; num INT := 1001;
BEGIN
FOR r IN (SELECT id FROM "user" WHERE employee_number IS NULL ORDER BY created_at) LOOP
UPDATE "user" SET employee_number = num::text WHERE id = r.id;
num := num + 1;
END LOOP;
END $$;
-- Seed POS config
INSERT INTO "app_config" ("key", "value", "description") VALUES
('pos_lock_timeout', '15', 'POS auto-lock timeout in minutes (0 to disable)'),
('receipt_header', '', 'Text shown below logo on receipts'),
('receipt_footer', '', 'Thank you message at bottom of receipt'),
('receipt_return_policy', '', 'Return policy text on receipt (blank to hide)'),
('receipt_social', '', 'Website or social media shown on receipt')
ON CONFLICT ("key") DO NOTHING;

View File

@@ -0,0 +1,5 @@
-- Add 'consumable' to repair_line_item_type enum
ALTER TYPE repair_line_item_type ADD VALUE IF NOT EXISTS 'consumable';
-- Add is_consumable flag to product table
ALTER TABLE product ADD COLUMN IF NOT EXISTS is_consumable boolean NOT NULL DEFAULT false;

View File

@@ -0,0 +1,12 @@
-- Named registers for POS terminals
CREATE TABLE IF NOT EXISTS register (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
location_id UUID NOT NULL REFERENCES location(id),
name VARCHAR(100) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Link drawer sessions to registers
ALTER TABLE drawer_session ADD COLUMN IF NOT EXISTS register_id UUID REFERENCES register(id);

View File

@@ -0,0 +1,31 @@
-- Auto-assign employee_number on user insert if not provided
CREATE OR REPLACE FUNCTION assign_employee_number()
RETURNS TRIGGER AS $$
DECLARE next_num INT;
BEGIN
IF NEW.employee_number IS NULL OR NEW.employee_number = '' THEN
SELECT COALESCE(MAX(employee_number::int), 1000) + 1
INTO next_num
FROM "user"
WHERE employee_number ~ '^\d+$';
NEW.employee_number := next_num::text;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_assign_employee_number ON "user";
CREATE TRIGGER trg_assign_employee_number
BEFORE INSERT ON "user"
FOR EACH ROW
EXECUTE FUNCTION assign_employee_number();
-- Backfill any users missing an employee number
DO $$ DECLARE r RECORD; num INT;
BEGIN
SELECT COALESCE(MAX(employee_number::int), 1000) INTO num FROM "user" WHERE employee_number ~ '^\d+$';
FOR r IN (SELECT id FROM "user" WHERE employee_number IS NULL OR employee_number = '' ORDER BY created_at) LOOP
num := num + 1;
UPDATE "user" SET employee_number = num::text WHERE id = r.id;
END LOOP;
END $$;

View File

@@ -295,6 +295,41 @@
"when": 1775580000000, "when": 1775580000000,
"tag": "0041_app_settings", "tag": "0041_app_settings",
"breakpoints": true "breakpoints": true
},
{
"idx": 42,
"version": "7",
"when": 1775590000000,
"tag": "0042_drawer-adjustments",
"breakpoints": true
},
{
"idx": 43,
"version": "7",
"when": 1775600000000,
"tag": "0043_user-pin",
"breakpoints": true
},
{
"idx": 44,
"version": "7",
"when": 1775680000000,
"tag": "0044_repair-pos-consumable",
"breakpoints": true
},
{
"idx": 45,
"version": "7",
"when": 1775770000000,
"tag": "0045_registers-reports",
"breakpoints": true
},
{
"idx": 46,
"version": "7",
"when": 1775860000000,
"tag": "0046_auto-employee-number",
"breakpoints": true
} }
] ]
} }

View File

@@ -57,6 +57,7 @@ export const products = pgTable('product', {
isSerialized: boolean('is_serialized').notNull().default(false), isSerialized: boolean('is_serialized').notNull().default(false),
isRental: boolean('is_rental').notNull().default(false), isRental: boolean('is_rental').notNull().default(false),
isDualUseRepair: boolean('is_dual_use_repair').notNull().default(false), isDualUseRepair: boolean('is_dual_use_repair').notNull().default(false),
isConsumable: boolean('is_consumable').notNull().default(false),
taxCategory: taxCategoryEnum('tax_category').notNull().default('goods'), taxCategory: taxCategoryEnum('tax_category').notNull().default('goods'),
price: numeric('price', { precision: 10, scale: 2 }), price: numeric('price', { precision: 10, scale: 2 }),
minPrice: numeric('min_price', { precision: 10, scale: 2 }), minPrice: numeric('min_price', { precision: 10, scale: 2 }),

View File

@@ -68,9 +68,23 @@ export const discounts = pgTable('discount', {
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}) })
export const adjustmentTypeEnum = pgEnum('adjustment_type', ['cash_in', 'cash_out'])
export const registers = pgTable('register', {
id: uuid('id').primaryKey().defaultRandom(),
locationId: uuid('location_id')
.notNull()
.references(() => locations.id),
name: varchar('name', { length: 100 }).notNull(),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
export const drawerSessions = pgTable('drawer_session', { export const drawerSessions = pgTable('drawer_session', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
locationId: uuid('location_id').references(() => locations.id), locationId: uuid('location_id').references(() => locations.id),
registerId: uuid('register_id').references(() => registers.id),
openedBy: uuid('opened_by') openedBy: uuid('opened_by')
.notNull() .notNull()
.references(() => users.id), .references(() => users.id),
@@ -86,6 +100,20 @@ export const drawerSessions = pgTable('drawer_session', {
closedAt: timestamp('closed_at', { withTimezone: true }), closedAt: timestamp('closed_at', { withTimezone: true }),
}) })
export const drawerAdjustments = pgTable('drawer_adjustment', {
id: uuid('id').primaryKey().defaultRandom(),
drawerSessionId: uuid('drawer_session_id')
.notNull()
.references(() => drawerSessions.id),
type: adjustmentTypeEnum('type').notNull(),
amount: numeric('amount', { precision: 10, scale: 2 }).notNull(),
reason: text('reason').notNull(),
createdBy: uuid('created_by')
.notNull()
.references(() => users.id),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
})
export const transactions = pgTable('transaction', { export const transactions = pgTable('transaction', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
locationId: uuid('location_id').references(() => locations.id), locationId: uuid('location_id').references(() => locations.id),

View File

@@ -36,6 +36,7 @@ export const repairLineItemTypeEnum = pgEnum('repair_line_item_type', [
'part', 'part',
'flat_rate', 'flat_rate',
'misc', 'misc',
'consumable',
]) ])
export const repairConditionInEnum = pgEnum('repair_condition_in', [ export const repairConditionInEnum = pgEnum('repair_condition_in', [

View File

@@ -15,6 +15,8 @@ export const users = pgTable('user', {
firstName: varchar('first_name', { length: 100 }).notNull(), firstName: varchar('first_name', { length: 100 }).notNull(),
lastName: varchar('last_name', { length: 100 }).notNull(), lastName: varchar('last_name', { length: 100 }).notNull(),
role: userRoleEnum('role').notNull().default('staff'), role: userRoleEnum('role').notNull().default('staff'),
employeeNumber: varchar('employee_number', { length: 20 }).unique(),
pinHash: varchar('pin_hash', { length: 255 }),
isActive: boolean('is_active').notNull().default(true), isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),

View File

@@ -44,7 +44,8 @@ async function seed() {
if (!adminUser) { if (!adminUser) {
const bcrypt = await import('bcryptjs') const bcrypt = await import('bcryptjs')
const hashedPw = await bcrypt.hash(adminPassword, 10) const hashedPw = await bcrypt.hash(adminPassword, 10)
const [user] = await sql`INSERT INTO "user" (email, password_hash, first_name, last_name, role) VALUES ('admin@lunarfront.dev', ${hashedPw}, 'Admin', 'User', 'admin') RETURNING id` const pinHash = await bcrypt.hash('1234', 10)
const [user] = await sql`INSERT INTO "user" (email, password_hash, first_name, last_name, role, employee_number, pin_hash) VALUES ('admin@lunarfront.dev', ${hashedPw}, 'Admin', 'User', 'admin', '1001', ${pinHash}) RETURNING id`
const [adminRole] = await sql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1` const [adminRole] = await sql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1`
if (adminRole) { if (adminRole) {
await sql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${user.id}, ${adminRole.id}) ON CONFLICT DO NOTHING` await sql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${user.id}, ${adminRole.id}) ON CONFLICT DO NOTHING`
@@ -171,6 +172,64 @@ async function seed() {
console.log(` Batch: Lincoln High School — 5 items`) console.log(` Batch: Lincoln High School — 5 items`)
} }
// --- Sample POS Transactions tied to accounts ---
const [adminUsr] = await sql`SELECT id FROM "user" WHERE email = 'admin@lunarfront.dev'`
const [mainLoc] = await sql`SELECT id FROM location WHERE name = 'Main Store'`
if (adminUsr && mainLoc) {
const existingTxns = await sql`SELECT id FROM transaction WHERE account_id IS NOT NULL LIMIT 1`
if (existingTxns.length === 0) {
const salesData = [
{ account: 'Smith Family', items: [
{ desc: 'Violin Strings — Dominant 4/4 Set', qty: 1, price: '59.99', tax: '4.95' },
{ desc: 'Rosin — Pirastro Goldflex', qty: 1, price: '12.00', tax: '0.99' },
]},
{ account: 'Smith Family', items: [
{ desc: 'Shoulder Rest — Kun Original 4/4', qty: 1, price: '35.00', tax: '2.89' },
]},
{ account: 'Johnson Family', items: [
{ desc: 'Cello Strings — Jargar Superior Set', qty: 1, price: '89.99', tax: '7.42' },
{ desc: 'Bow Grip — Leather Cello/Bass', qty: 2, price: '6.00', tax: '0.99' },
]},
{ account: 'Garcia Workshop', items: [
{ desc: 'Bridge — Violin 4/4 Blank', qty: 3, price: '18.00', tax: '4.46' },
{ desc: 'Bow Hair — Mongolian White (hank)', qty: 2, price: '18.00', tax: '2.97' },
{ desc: 'Bow Tip Plate — Violin Ivory-Style', qty: 5, price: '7.00', tax: '2.89' },
]},
{ account: 'Emily Chen', items: [
{ desc: 'Violin Strings — Dominant 4/4 Set', qty: 1, price: '59.99', tax: '4.95' },
{ desc: 'Chin Rest — Guarneri Ebony 4/4', qty: 1, price: '28.00', tax: '2.31' },
]},
]
for (const sale of salesData) {
const acctId = acctIds[sale.account]
if (!acctId) continue
const subtotal = sale.items.reduce((s, i) => s + parseFloat(i.price) * i.qty, 0)
const taxTotal = sale.items.reduce((s, i) => s + parseFloat(i.tax), 0)
const total = Math.round((subtotal + taxTotal) * 100) / 100
const txnNum = `TXN-SEED-${String(Math.floor(1000 + Math.random() * 9000))}`
const [txn] = await sql`INSERT INTO transaction (
transaction_number, transaction_type, status, location_id, account_id, processed_by,
subtotal, discount_total, tax_total, total, payment_method, completed_at
) VALUES (
${txnNum}, 'sale', 'completed', ${mainLoc.id}, ${acctId}, ${adminUsr.id},
${subtotal.toFixed(2)}, '0.00', ${taxTotal.toFixed(2)}, ${total.toFixed(2)}, 'card_present', NOW()
) RETURNING id`
for (const item of sale.items) {
const lineTotal = Math.round((parseFloat(item.price) * item.qty + parseFloat(item.tax)) * 100) / 100
await sql`INSERT INTO transaction_line_item (
transaction_id, description, qty, unit_price, tax_rate, tax_amount, line_total
) VALUES (
${txn.id}, ${item.desc}, ${item.qty}, ${item.price}, '0.0825', ${item.tax}, ${lineTotal.toFixed(2)}
)`
}
console.log(` Transaction: ${txnNum}${sale.account}$${total.toFixed(2)}`)
}
}
}
console.log('\nDev seed complete!') console.log('\nDev seed complete!')
await sql.end() await sql.end()
} }

View File

@@ -15,16 +15,139 @@ const sql = postgres(DB_URL)
async function seed() { async function seed() {
console.log('Seeding music store data...') console.log('Seeding music store data...')
// Verify company exists (dev-seed must run first) // --- Bootstrap: company, location, RBAC, admin user (idempotent) ---
const [company] = await sql`SELECT id FROM company WHERE id = ${COMPANY_ID}` const [existingCompany] = await sql`SELECT id FROM company WHERE id = ${COMPANY_ID}`
if (!company) { if (!existingCompany) {
console.error('Company not found — run dev-seed first: bun run db:seed-dev') await sql`INSERT INTO company (id, name, timezone, phone, email, address) VALUES (${COMPANY_ID}, 'Harmony Music Shop', 'America/Chicago', '555-555-1234', 'info@harmonymusic.com', '{"street":"123 Main St","city":"San Antonio","state":"TX","zip":"78205"}'::jsonb)`
process.exit(1) await sql`INSERT INTO location (id, name, tax_rate, service_tax_rate, phone, email, address) VALUES ('a0000000-0000-4000-8000-000000000002', 'Main Store', '0.0825', '0.0825', '555-555-1234', 'info@harmonymusic.com', '{"street":"123 Main St","city":"San Antonio","state":"TX","zip":"78205"}'::jsonb)`
console.log(' Created company and location')
} else {
await sql`UPDATE company SET name = 'Harmony Music Shop', phone = '555-555-1234', email = 'info@harmonymusic.com', address = '{"street":"123 Main St","city":"San Antonio","state":"TX","zip":"78205"}'::jsonb WHERE id = ${COMPANY_ID}`
console.log(' Updated company: Harmony Music Shop')
} }
// Update company name to music store // RBAC
await sql`UPDATE company SET name = 'Harmony Music Shop' WHERE id = ${COMPANY_ID}` const { SYSTEM_PERMISSIONS, DEFAULT_ROLES } = await import('../seeds/rbac.js')
console.log(' Updated company name: Harmony Music Shop') for (const p of SYSTEM_PERMISSIONS) {
await sql`INSERT INTO permission (slug, domain, action, description) VALUES (${p.slug}, ${p.domain}, ${p.action}, ${p.description}) ON CONFLICT (slug) DO NOTHING`
}
const permRows = await sql`SELECT id, slug FROM permission`
const permMap = new Map(permRows.map((r: any) => [r.slug, r.id]))
for (const roleDef of DEFAULT_ROLES) {
const [existingRole] = await sql`SELECT id FROM role WHERE slug = ${roleDef.slug}`
if (existingRole) continue
const [role] = await sql`INSERT INTO role (name, slug, description, is_system) VALUES (${roleDef.name}, ${roleDef.slug}, ${roleDef.description}, true) RETURNING id`
for (const permSlug of roleDef.permissions) {
const permId = permMap.get(permSlug)
if (permId) await sql`INSERT INTO role_permission (role_id, permission_id) VALUES (${role.id}, ${permId}) ON CONFLICT DO NOTHING`
}
}
console.log(' RBAC seeded')
// Admin user with POS PIN
const [existingAdmin] = await sql`SELECT id FROM "user" WHERE email = 'admin@harmonymusic.com'`
if (!existingAdmin) {
const bcrypt = await import('bcryptjs')
const hashedPw = await bcrypt.hash('admin1234', 10)
const pinHash = await bcrypt.hash('1234', 10)
const [user] = await sql`INSERT INTO "user" (email, password_hash, first_name, last_name, role, employee_number, pin_hash) VALUES ('admin@harmonymusic.com', ${hashedPw}, 'Admin', 'User', 'admin', '1001', ${pinHash}) RETURNING id`
const [adminRole] = await sql`SELECT id FROM role WHERE slug = 'admin' LIMIT 1`
if (adminRole) {
await sql`INSERT INTO user_role_assignment (user_id, role_id) VALUES (${user.id}, ${adminRole.id}) ON CONFLICT DO NOTHING`
}
console.log(' Created admin: admin@harmonymusic.com / admin1234 (POS: 10011234)')
}
// Default modules
const modules = [
{ slug: 'inventory', name: 'Inventory', description: 'Product catalog and stock tracking', enabled: true },
{ slug: 'pos', name: 'Point of Sale', description: 'Sales, drawer, receipts', enabled: true },
{ slug: 'repairs', name: 'Repairs', description: 'Repair tickets and service', enabled: true },
{ slug: 'lessons', name: 'Lessons', description: 'Scheduling and instruction', enabled: true },
{ slug: 'files', name: 'Files', description: 'File storage', enabled: true },
{ slug: 'vault', name: 'Vault', description: 'Password manager', enabled: true },
{ slug: 'reports', name: 'Reports', description: 'Business reports', enabled: true },
{ slug: 'rentals', name: 'Rentals', description: 'Rental agreements', enabled: false },
{ slug: 'email', name: 'Email', description: 'Email campaigns', enabled: false },
]
for (const m of modules) {
await sql`INSERT INTO module_config (slug, name, description, licensed, enabled) VALUES (${m.slug}, ${m.name}, ${m.description}, true, ${m.enabled}) ON CONFLICT (slug) DO NOTHING`
}
console.log(' Modules configured')
// Default register
await sql`INSERT INTO register (location_id, name) SELECT 'a0000000-0000-4000-8000-000000000002', 'Register 1' WHERE NOT EXISTS (SELECT 1 FROM register WHERE name = 'Register 1' AND location_id = 'a0000000-0000-4000-8000-000000000002')`
console.log(' Default register created')
// --- Accounts & Members ---
const accounts = [
{ name: 'Smith Family', email: 'smith@example.com', phone: '555-0101', type: 'family' },
{ name: 'Johnson Family', email: 'johnson@example.com', phone: '555-0102', type: 'family' },
{ name: 'Garcia Workshop', email: 'carlos@studio.com', phone: '555-0103', type: 'business' },
{ name: 'Mike Thompson', email: 'mike.t@email.com', phone: '555-0104', type: 'individual' },
{ name: 'Emily Chen', email: 'emily.chen@email.com', phone: '555-0105', type: 'individual' },
{ name: 'Lincoln High School', email: 'band@lincoln.edu', phone: '555-0200', type: 'business' },
{ name: 'Westside Community Orchestra', email: 'info@westsideorch.org', phone: '555-0300', type: 'business' },
{ name: 'Rivera Family', email: 'rivera@email.com', phone: '555-0106', type: 'family' },
{ name: 'Patricia Williams', email: 'pwilliams@email.com', phone: '555-0107', type: 'individual' },
{ name: 'Oak Elementary PTA', email: 'pta@oakelementary.edu', phone: '555-0201', type: 'business' },
]
const acctIds: Record<string, string> = {}
for (const a of accounts) {
const [existing] = await sql`SELECT id FROM account WHERE name = ${a.name}`
if (existing) { acctIds[a.name] = existing.id; continue }
const num = String(Math.floor(100000 + Math.random() * 900000))
const [row] = await sql`INSERT INTO account (name, email, phone, account_number, billing_mode) VALUES (${a.name}, ${a.email}, ${a.phone}, ${num}, 'consolidated') RETURNING id`
acctIds[a.name] = row.id
}
console.log(` Created ${accounts.length} accounts`)
const members = [
// Smith Family — parent + 2 kids learning strings
{ account: 'Smith Family', firstName: 'David', lastName: 'Smith', email: 'david@example.com', phone: '555-0101' },
{ account: 'Smith Family', firstName: 'Sarah', lastName: 'Smith', email: 'sarah@example.com' },
{ account: 'Smith Family', firstName: 'Tommy', lastName: 'Smith', isMinor: true, dob: '2015-03-12' },
{ account: 'Smith Family', firstName: 'Lily', lastName: 'Smith', isMinor: true, dob: '2017-08-25' },
// Johnson Family — parent + kid learning viola
{ account: 'Johnson Family', firstName: 'Lisa', lastName: 'Johnson', email: 'lisa.j@example.com', phone: '555-0102' },
{ account: 'Johnson Family', firstName: 'Jake', lastName: 'Johnson', isMinor: true, dob: '2013-11-05' },
// Individual musicians
{ account: 'Mike Thompson', firstName: 'Mike', lastName: 'Thompson', email: 'mike.t@email.com', phone: '555-0104' },
{ account: 'Emily Chen', firstName: 'Emily', lastName: 'Chen', email: 'emily.chen@email.com', phone: '555-0105' },
{ account: 'Garcia Workshop', firstName: 'Carlos', lastName: 'Garcia', email: 'carlos@studio.com', phone: '555-0103' },
{ account: 'Patricia Williams', firstName: 'Patricia', lastName: 'Williams', email: 'pwilliams@email.com', phone: '555-0107' },
// Rivera Family — cellist family
{ account: 'Rivera Family', firstName: 'Maria', lastName: 'Rivera', email: 'rivera@email.com', phone: '555-0106' },
{ account: 'Rivera Family', firstName: 'Sofia', lastName: 'Rivera', isMinor: true, dob: '2014-06-18' },
{ account: 'Rivera Family', firstName: 'Diego', lastName: 'Rivera', isMinor: true, dob: '2016-01-30' },
// School contacts
{ account: 'Lincoln High School', firstName: 'Robert', lastName: 'Hayes', email: 'rhayes@lincoln.edu', phone: '555-0200' },
{ account: 'Oak Elementary PTA', firstName: 'Jennifer', lastName: 'Park', email: 'jpark@oakelementary.edu', phone: '555-0201' },
// Orchestra contact
{ account: 'Westside Community Orchestra', firstName: 'Margaret', lastName: 'Foster', email: 'mfoster@westsideorch.org', phone: '555-0300' },
]
const memberMap: Record<string, { id: string }> = {}
for (const m of members) {
const accountId = acctIds[m.account]
if (!accountId) continue
const [existing] = await sql`SELECT id FROM member WHERE first_name = ${m.firstName} AND last_name = ${m.lastName} AND account_id = ${accountId}`
if (existing) { memberMap[`${m.firstName} ${m.lastName}`] = existing; continue }
const num = String(Math.floor(100000 + Math.random() * 900000))
const [row] = await sql`
INSERT INTO member (account_id, first_name, last_name, email, phone, member_number, is_minor, date_of_birth)
VALUES (${accountId}, ${m.firstName}, ${m.lastName}, ${m.email ?? null}, ${m.phone ?? null}, ${num}, ${m.isMinor ?? false}, ${m.dob ?? null})
RETURNING id
`
memberMap[`${m.firstName} ${m.lastName}`] = row
}
console.log(` Created ${members.length} members`)
// --- Music Repair Service Templates --- // --- Music Repair Service Templates ---
const templates = [ const templates = [
@@ -55,42 +178,17 @@ async function seed() {
{ name: 'Bridge Setup', itemCategory: 'Bass', size: null, itemType: 'flat_rate', price: '65.00', cost: '20.00' }, { name: 'Bridge Setup', itemCategory: 'Bass', size: null, itemType: 'flat_rate', price: '65.00', cost: '20.00' },
{ name: 'String Change', itemCategory: 'Bass', size: null, itemType: 'flat_rate', price: '60.00', cost: '25.00' }, { name: 'String Change', itemCategory: 'Bass', size: null, itemType: 'flat_rate', price: '60.00', cost: '25.00' },
// Guitar // Additional string services
{ name: 'String Change', itemCategory: 'Guitar', size: 'Acoustic', itemType: 'flat_rate', price: '25.00', cost: '8.00' }, { name: 'Fingerboard Planing', itemCategory: 'Violin', size: null, itemType: 'labor', price: '85.00', cost: null },
{ name: 'String Change', itemCategory: 'Guitar', size: 'Electric', itemType: 'flat_rate', price: '25.00', cost: '7.00' }, { name: 'Fingerboard Planing', itemCategory: 'Viola', size: null, itemType: 'labor', price: '95.00', cost: null },
{ name: 'String Change', itemCategory: 'Guitar', size: 'Classical', itemType: 'flat_rate', price: '25.00', cost: '10.00' }, { name: 'Fingerboard Planing', itemCategory: 'Cello', size: null, itemType: 'labor', price: '120.00', cost: null },
{ name: 'Full Setup', itemCategory: 'Guitar', size: 'Acoustic', itemType: 'flat_rate', price: '65.00', cost: '5.00' }, { name: 'Varnish Touch-Up', itemCategory: 'Violin', size: null, itemType: 'labor', price: '55.00', cost: null },
{ name: 'Full Setup', itemCategory: 'Guitar', size: 'Electric', itemType: 'flat_rate', price: '65.00', cost: '5.00' }, { name: 'Varnish Touch-Up', itemCategory: 'Cello', size: null, itemType: 'labor', price: '75.00', cost: null },
{ name: 'Fret Level & Crown', itemCategory: 'Guitar', size: null, itemType: 'labor', price: '150.00', cost: null }, { name: 'Neck Reset', itemCategory: 'Violin', size: null, itemType: 'labor', price: '200.00', cost: null },
{ name: 'Pickup Installation', itemCategory: 'Guitar', size: null, itemType: 'labor', price: '45.00', cost: null }, { name: 'Neck Reset', itemCategory: 'Cello', size: null, itemType: 'labor', price: '350.00', cost: null },
{ name: 'Nut Replacement', itemCategory: 'Guitar', size: null, itemType: 'flat_rate', price: '35.00', cost: '8.00' }, { name: 'Bass Bar Replacement', itemCategory: 'Violin', size: null, itemType: 'labor', price: '300.00', cost: null },
{ name: 'Tuning Machine Replacement', itemCategory: 'Guitar', size: null, itemType: 'flat_rate', price: '40.00', cost: '15.00' }, { name: 'Tailgut Replacement', itemCategory: 'Violin', size: null, itemType: 'flat_rate', price: '20.00', cost: '5.00' },
{ name: 'Tailgut Replacement', itemCategory: 'Cello', size: null, itemType: 'flat_rate', price: '25.00', cost: '8.00' },
// Brass
{ name: 'Valve Overhaul', itemCategory: 'Trumpet', size: null, itemType: 'labor', price: '85.00', cost: null },
{ name: 'Chemical Cleaning', itemCategory: 'Trumpet', size: null, itemType: 'flat_rate', price: '55.00', cost: '10.00' },
{ name: 'Dent Removal', itemCategory: 'Trumpet', size: null, itemType: 'labor', price: '50.00', cost: null },
{ name: 'Slide Repair', itemCategory: 'Trombone', size: null, itemType: 'labor', price: '75.00', cost: null },
{ name: 'Chemical Cleaning', itemCategory: 'Trombone', size: null, itemType: 'flat_rate', price: '65.00', cost: '12.00' },
{ name: 'Dent Removal', itemCategory: 'Trombone', size: null, itemType: 'labor', price: '60.00', cost: null },
{ name: 'Valve Overhaul', itemCategory: 'French Horn', size: null, itemType: 'labor', price: '120.00', cost: null },
{ name: 'Chemical Cleaning', itemCategory: 'French Horn', size: null, itemType: 'flat_rate', price: '75.00', cost: '15.00' },
{ name: 'Valve Overhaul', itemCategory: 'Tuba', size: null, itemType: 'labor', price: '150.00', cost: null },
// Woodwinds
{ name: 'Pad Replacement', itemCategory: 'Clarinet', size: null, itemType: 'flat_rate', price: '120.00', cost: '30.00' },
{ name: 'Cork Replacement', itemCategory: 'Clarinet', size: null, itemType: 'flat_rate', price: '45.00', cost: '5.00' },
{ name: 'Key Adjustment', itemCategory: 'Clarinet', size: null, itemType: 'labor', price: '35.00', cost: null },
{ name: 'Pad Replacement', itemCategory: 'Flute', size: null, itemType: 'flat_rate', price: '110.00', cost: '25.00' },
{ name: 'Headjoint Cork', itemCategory: 'Flute', size: null, itemType: 'flat_rate', price: '30.00', cost: '5.00' },
{ name: 'Key Adjustment', itemCategory: 'Flute', size: null, itemType: 'labor', price: '35.00', cost: null },
{ name: 'Pad Replacement', itemCategory: 'Saxophone', size: 'Alto', itemType: 'flat_rate', price: '150.00', cost: '40.00' },
{ name: 'Pad Replacement', itemCategory: 'Saxophone', size: 'Tenor', itemType: 'flat_rate', price: '175.00', cost: '50.00' },
{ name: 'Cork & Felt Replacement', itemCategory: 'Saxophone', size: null, itemType: 'flat_rate', price: '65.00', cost: '10.00' },
{ name: 'Neck Cork', itemCategory: 'Saxophone', size: null, itemType: 'flat_rate', price: '20.00', cost: '3.00' },
{ name: 'Pad Replacement', itemCategory: 'Oboe', size: null, itemType: 'flat_rate', price: '200.00', cost: '60.00' },
{ name: 'Reed Adjustment', itemCategory: 'Oboe', size: null, itemType: 'labor', price: '15.00', cost: null },
{ name: 'Pad Replacement', itemCategory: 'Bassoon', size: null, itemType: 'flat_rate', price: '250.00', cost: '80.00' },
// General // General
{ name: 'General Cleaning', itemCategory: null, size: null, itemType: 'flat_rate', price: '30.00', cost: '5.00' }, { name: 'General Cleaning', itemCategory: null, size: null, itemType: 'flat_rate', price: '30.00', cost: '5.00' },
@@ -112,19 +210,32 @@ async function seed() {
// First clear any generic tickets // First clear any generic tickets
await sql`DELETE FROM repair_ticket WHERE id NOT IN (SELECT DISTINCT repair_ticket_id FROM repair_note)` await sql`DELETE FROM repair_ticket WHERE id NOT IN (SELECT DISTINCT repair_ticket_id FROM repair_note)`
const acctRows = await sql`SELECT id, name FROM account` const tickets: { customer: string; item: string; serial: string | null; problem: string; condition: string; status: string; estimate: string | null; lineItems: { type: string; desc: string; qty: number; price: number; cost?: number }[] }[] = [
const acctIds: Record<string, string> = {} { customer: 'Mike Thompson', item: 'Jay Haide Cello 4/4', serial: 'JH-C44-1892', problem: 'Endpin mechanism worn, slips during playing. Seam opening near lower bout.', condition: 'fair', status: 'in_progress', estimate: '95.00', lineItems: [
for (const a of acctRows) acctIds[a.name] = a.id { type: 'labor', desc: 'Endpin repair — remove and refit mechanism', qty: 1, price: 45 },
{ type: 'labor', desc: 'Seam repair — lower bout', qty: 1, price: 45 },
const tickets = [ { type: 'consumable', desc: 'Hide glue', qty: 1, price: 5, cost: 5 },
{ customer: 'Mike Thompson', item: 'Fender Stratocaster', serial: 'US22-045891', problem: 'Fret buzz on 3rd and 5th fret, needs full setup', condition: 'good', status: 'in_progress', estimate: '65.00' }, ]},
{ customer: 'Emily Chen', item: 'Yamaha YTR-2330 Trumpet', serial: 'YTR-78432', problem: 'Stuck 2nd valve, sluggish action on all valves', condition: 'fair', status: 'pending_approval', estimate: '85.00' }, { customer: 'Emily Chen', item: 'Scott Cao Viola 16"', serial: 'SC-VA16-0547', problem: 'Bridge warped, soundpost has shifted. Needs full setup.', condition: 'fair', status: 'pending_approval', estimate: '120.00', lineItems: [
{ customer: 'David Smith', item: 'German Workshop Violin 4/4', serial: null, problem: 'Bow needs rehair, bridge slightly warped', condition: 'fair', status: 'ready', estimate: '105.00' }, { type: 'flat_rate', desc: 'Bridge replacement — Viola', qty: 1, price: 75, cost: 18 },
{ customer: 'Carlos Garcia', item: 'Martin D-28 Acoustic Guitar', serial: 'M2284563', problem: 'Broken tuning peg, needs replacement', condition: 'good', status: 'new', estimate: null }, { type: 'labor', desc: 'Soundpost adjustment', qty: 1, price: 25 },
{ customer: 'Lisa Johnson', item: 'Yamaha YCL-255 Clarinet', serial: null, problem: 'Several pads worn, keys sticking', condition: 'poor', status: 'diagnosing', estimate: null }, { type: 'flat_rate', desc: 'String change — Viola', qty: 1, price: 35, cost: 32 },
{ customer: 'Walk-In Customer', item: 'Gemeinhardt 2SP Flute', serial: null, problem: 'Squeaks on high notes, headjoint cork may need replacing', condition: 'fair', status: 'intake', estimate: null }, ]},
{ customer: 'Smith Family', item: 'Suzuki Student Violin 1/2', serial: null, problem: 'Pegs slipping, bridge leaning forward', condition: 'fair', status: 'new', estimate: null }, { customer: 'David Smith', item: 'German Workshop Violin 4/4', serial: null, problem: 'Bow needs rehair, bridge slightly warped', condition: 'fair', status: 'ready', estimate: '105.00', lineItems: [
{ customer: 'Johnson Family', item: 'Selmer AS500 Alto Saxophone', serial: 'AS-99231', problem: 'Neck cork loose, low notes not speaking', condition: 'good', status: 'in_progress', estimate: '85.00' }, { type: 'flat_rate', desc: 'Bow rehair — Violin 4/4', qty: 1, price: 65, cost: 15 },
{ type: 'flat_rate', desc: 'Bridge setup — Violin 4/4', qty: 1, price: 40, cost: 10 },
{ type: 'consumable', desc: 'Bow hair — Mongolian white', qty: 1, price: 18, cost: 18 },
]},
{ customer: 'Carlos Garcia', item: 'Eastman VL305 Violin 4/4', serial: 'EA-V305-X42', problem: 'Fingerboard wear near 3rd position, open seam on top plate', condition: 'good', status: 'new', estimate: null, lineItems: [] },
{ customer: 'Patricia Williams', item: 'Shen SB100 Bass 3/4', serial: 'SH-B34-0891', problem: 'Bridge feet not fitting soundboard, wolf tone on G string', condition: 'good', status: 'diagnosing', estimate: null, lineItems: [] },
{ customer: 'Walk-In Customer', item: 'Student Violin 3/4', serial: null, problem: 'Pegs slipping, E string buzzing against fingerboard', condition: 'fair', status: 'intake', estimate: null, lineItems: [] },
{ customer: 'Smith Family', item: 'Suzuki Student Violin 1/2', serial: null, problem: 'Pegs slipping, bridge leaning forward', condition: 'fair', status: 'new', estimate: null, lineItems: [] },
{ customer: 'Rivera Family', item: 'Eastman VC80 Cello 3/4', serial: 'EA-VC80-3Q-X01', problem: 'A string peg cracked, needs replacement. Bow rehair overdue.', condition: 'good', status: 'in_progress', estimate: '85.00', lineItems: [
{ type: 'part', desc: 'Pegs — Cello Boxwood Set (4)', qty: 1, price: 28, cost: 10 },
{ type: 'labor', desc: 'Peg fitting — Cello', qty: 1, price: 35 },
{ type: 'flat_rate', desc: 'Bow rehair — Cello', qty: 1, price: 75, cost: 18 },
{ type: 'consumable', desc: 'Peg compound', qty: 1, price: 6, cost: 6 },
]},
] ]
for (const t of tickets) { for (const t of tickets) {
@@ -132,25 +243,29 @@ async function seed() {
if (existing.length > 0) continue if (existing.length > 0) continue
const num = String(Math.floor(100000 + Math.random() * 900000)) const num = String(Math.floor(100000 + Math.random() * 900000))
const acctId = acctIds[t.customer] ?? null const acctId = acctIds[t.customer] ?? null
await sql`INSERT INTO repair_ticket (ticket_number, customer_name, account_id, item_description, serial_number, problem_description, condition_in, status, estimated_cost) VALUES (${num}, ${t.customer}, ${acctId}, ${t.item}, ${t.serial}, ${t.problem}, ${t.condition}, ${t.status}, ${t.estimate})` const [ticket] = await sql`INSERT INTO repair_ticket (ticket_number, customer_name, account_id, item_description, serial_number, problem_description, condition_in, status, estimated_cost) VALUES (${num}, ${t.customer}, ${acctId}, ${t.item}, ${t.serial}, ${t.problem}, ${t.condition}, ${t.status}, ${t.estimate}) RETURNING id`
console.log(` Ticket: ${t.customer}${t.item} [${t.status}]`) for (const li of t.lineItems) {
const total = (li.qty * li.price).toFixed(2)
await sql`INSERT INTO repair_line_item (repair_ticket_id, item_type, description, qty, unit_price, total_price, cost) VALUES (${ticket.id}, ${li.type}, ${li.desc}, ${li.qty}, ${li.price.toFixed(2)}, ${total}, ${li.cost?.toFixed(2) ?? null})`
}
console.log(` Ticket: ${t.customer}${t.item} [${t.status}]${t.lineItems.length > 0 ? ` (${t.lineItems.length} items)` : ''}`)
} }
// --- School Band Batch --- // --- School Orchestra Batch ---
const batchExists = await sql`SELECT id FROM repair_batch WHERE contact_name = 'Mr. Williams'` const batchExists = await sql`SELECT id FROM repair_batch WHERE contact_name = 'Ms. Park'`
if (batchExists.length === 0) { if (batchExists.length === 0) {
const schoolId = acctIds['Lincoln High School'] const schoolId = acctIds['Oak Elementary PTA']
if (schoolId) { if (schoolId) {
const batchNum = String(Math.floor(100000 + Math.random() * 900000)) const batchNum = String(Math.floor(100000 + Math.random() * 900000))
const [batch] = await sql`INSERT INTO repair_batch (batch_number, account_id, contact_name, contact_phone, contact_email, item_count, notes, status) VALUES (${batchNum}, ${schoolId}, 'Mr. Williams', '555-0210', 'williams@lincoln.edu', 6, 'Annual band instrument checkup — 6 instruments for fall semester', 'intake') RETURNING id` const [batch] = await sql`INSERT INTO repair_batch (batch_number, account_id, contact_name, contact_phone, contact_email, item_count, notes, status) VALUES (${batchNum}, ${schoolId}, 'Ms. Park', '555-0201', 'jpark@oakelementary.edu', 6, 'School orchestra summer maintenance — 6 string instruments before fall semester', 'intake') RETURNING id`
const batchTickets = [ const batchTickets = [
{ item: 'Student Flute — Gemeinhardt 2SP', problem: 'Pads worn, headjoint cork needs check', condition: 'fair' }, { item: 'Student Violin 4/4 — Eastman', problem: 'Bridge warped, pegs slipping, needs full setup', condition: 'fair' },
{ item: 'Student Clarinet — Yamaha YCL-255', problem: 'Keys sticking, barrel cork dried out', condition: 'fair' }, { item: 'Student Violin 3/4 — Shen', problem: 'Open seam on lower bout, fingerboard wear', condition: 'fair' },
{ item: 'Student Clarinet — Buffet B12', problem: 'Barrel crack, needs assessment', condition: 'poor' }, { item: 'Student Violin 1/2 — Eastman', problem: 'Tailpiece gut broken, fine tuners corroded', condition: 'poor' },
{ item: 'Student Trumpet — Bach TR300', problem: 'Valve alignment off, general cleaning needed', condition: 'good' }, { item: 'Student Viola 15" — Eastman', problem: 'Chin rest loose, soundpost leaning', condition: 'good' },
{ item: 'Student Trombone — Yamaha YSL-354', problem: 'Slide dent near bell, sluggish movement', condition: 'fair' }, { item: 'Student Cello 3/4 — Eastman', problem: 'Endpin stuck, bridge feet not fitting', condition: 'fair' },
{ item: 'Student Alto Sax — Selmer AS500', problem: 'Neck cork loose, octave key sticky', condition: 'fair' }, { item: 'Student Cello 1/2 — Shen', problem: 'Bow rehair needed, strings old and fraying', condition: 'fair' },
] ]
for (const bt of batchTickets) { for (const bt of batchTickets) {
@@ -158,7 +273,7 @@ async function seed() {
await sql`INSERT INTO repair_ticket (ticket_number, customer_name, account_id, repair_batch_id, item_description, problem_description, condition_in, status) VALUES (${num}, 'Lincoln High School', ${schoolId}, ${batch.id}, ${bt.item}, ${bt.problem}, ${bt.condition}, 'new')` await sql`INSERT INTO repair_ticket (ticket_number, customer_name, account_id, repair_batch_id, item_description, problem_description, condition_in, status) VALUES (${num}, 'Lincoln High School', ${schoolId}, ${batch.id}, ${bt.item}, ${bt.problem}, ${bt.condition}, 'new')`
console.log(` Batch ticket: ${bt.item}`) console.log(` Batch ticket: ${bt.item}`)
} }
console.log(' Batch: Lincoln High School — 6 instruments') console.log(' Batch: Oak Elementary — 6 string instruments')
} }
} }

View File

@@ -23,6 +23,8 @@ import { repairRoutes } from './routes/v1/repairs.js'
import { lessonRoutes } from './routes/v1/lessons.js' import { lessonRoutes } from './routes/v1/lessons.js'
import { transactionRoutes } from './routes/v1/transactions.js' import { transactionRoutes } from './routes/v1/transactions.js'
import { drawerRoutes } from './routes/v1/drawer.js' import { drawerRoutes } from './routes/v1/drawer.js'
import { registerRoutes } from './routes/v1/register.js'
import { reportRoutes } from './routes/v1/reports.js'
import { discountRoutes } from './routes/v1/discounts.js' import { discountRoutes } from './routes/v1/discounts.js'
import { taxRoutes } from './routes/v1/tax.js' import { taxRoutes } from './routes/v1/tax.js'
import { storageRoutes } from './routes/v1/storage.js' import { storageRoutes } from './routes/v1/storage.js'
@@ -35,22 +37,66 @@ import { RbacService } from './services/rbac.service.js'
import { ModuleService } from './services/module.service.js' import { ModuleService } from './services/module.service.js'
import { AppConfigService } from './services/config.service.js' import { AppConfigService } from './services/config.service.js'
import { SettingsService } from './services/settings.service.js' import { SettingsService } from './services/settings.service.js'
import { eq } from 'drizzle-orm'
import { users } from './db/schema/users.js' import { users } from './db/schema/users.js'
import { companies } from './db/schema/stores.js'
import { roles, userRoles } from './db/schema/rbac.js'
import { EmailService } from './services/email.service.js'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
async function seedInitialUser(app: Awaited<ReturnType<typeof buildApp>>) { async function seedInitialUser(app: Awaited<ReturnType<typeof buildApp>>) {
const email = process.env.INITIAL_USER_EMAIL const email = process.env.INITIAL_USER_EMAIL
const password = process.env.INITIAL_USER_PASSWORD
const firstName = process.env.INITIAL_USER_FIRST_NAME const firstName = process.env.INITIAL_USER_FIRST_NAME
const lastName = process.env.INITIAL_USER_LAST_NAME const lastName = process.env.INITIAL_USER_LAST_NAME
if (!email || !password || !firstName || !lastName) return if (!email || !firstName || !lastName) return
const existing = await app.db.select({ id: users.id }).from(users).limit(1) const existing = await app.db.select({ id: users.id }).from(users).limit(1)
if (existing.length > 0) return if (existing.length > 0) return
const passwordHash = await bcrypt.hash(password, 10) // Create user with a random password — they'll set their real one via the welcome email
await app.db.insert(users).values({ email, passwordHash, firstName, lastName, role: 'admin' }) const tempPassword = crypto.randomUUID()
const passwordHash = await bcrypt.hash(tempPassword, 10)
const [user] = await app.db.insert(users).values({ email, passwordHash, firstName, lastName, role: 'admin' }).returning({ id: users.id })
// Assign the Admin RBAC role
const [adminRole] = await app.db.select({ id: roles.id }).from(roles).where(eq(roles.name, 'Admin')).limit(1)
if (adminRole) {
await app.db.insert(userRoles).values({ userId: user.id, roleId: adminRole.id })
}
app.log.info({ email }, 'Initial admin user created') app.log.info({ email }, 'Initial admin user created')
// Send welcome email with password setup link
try {
const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '4h' })
const appUrl = process.env.APP_URL ?? `https://${process.env.HOSTNAME ?? 'localhost'}`
const resetLink = `${appUrl}/reset-password?token=${resetToken}`
const [store] = await app.db.select({ name: companies.name }).from(companies).limit(1)
const storeName = store?.name ?? process.env.BUSINESS_NAME ?? 'LunarFront'
await EmailService.send(app.db, {
to: email,
subject: `Welcome to ${storeName} — Set your password`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
<h2 style="color: #1a1a2e; margin-bottom: 8px;">${storeName}</h2>
<p style="color: #555; margin-bottom: 24px;">Hi ${firstName},</p>
<p style="color: #555;">Your account has been created. Click the button below to set your password and get started:</p>
<div style="text-align: center; margin: 32px 0;">
<a href="${resetLink}" style="background-color: #1a1a2e; color: #fff; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 500;">Set Your Password</a>
</div>
<p style="color: #888; font-size: 13px;">This link expires in 4 hours. If it expires, you can request a new one from the login page.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
<p style="color: #aaa; font-size: 11px;">Powered by LunarFront</p>
</div>
`,
text: `Hi ${firstName}, welcome to ${storeName}! Set your password here: ${resetLink} — This link expires in 4 hours.`,
})
app.log.info({ email }, 'Welcome email sent to initial user')
} catch (err) {
app.log.error({ email, error: (err as Error).message }, 'Failed to send welcome email — user can use forgot password')
}
} }
async function seedEmailSettings(app: Awaited<ReturnType<typeof buildApp>>) { async function seedEmailSettings(app: Awaited<ReturnType<typeof buildApp>>) {
@@ -71,6 +117,17 @@ async function seedEmailSettings(app: Awaited<ReturnType<typeof buildApp>>) {
app.log.info('Email settings seeded from environment') app.log.info('Email settings seeded from environment')
} }
async function seedCompany(app: Awaited<ReturnType<typeof buildApp>>) {
const name = process.env.BUSINESS_NAME
if (!name) return
const existing = await app.db.select({ id: companies.id }).from(companies).limit(1)
if (existing.length > 0) return
await app.db.insert(companies).values({ name })
app.log.info({ name }, 'Company seeded from environment')
}
export async function buildApp() { export async function buildApp() {
const app = Fastify({ const app = Fastify({
logger: { logger: {
@@ -156,6 +213,8 @@ export async function buildApp() {
await app.register(withModule('lessons', lessonRoutes), { prefix: '/v1' }) await app.register(withModule('lessons', lessonRoutes), { prefix: '/v1' })
await app.register(withModule('pos', transactionRoutes), { prefix: '/v1' }) await app.register(withModule('pos', transactionRoutes), { prefix: '/v1' })
await app.register(withModule('pos', drawerRoutes), { prefix: '/v1' }) await app.register(withModule('pos', drawerRoutes), { prefix: '/v1' })
await app.register(withModule('pos', registerRoutes), { prefix: '/v1' })
await app.register(withModule('pos', reportRoutes), { prefix: '/v1' })
await app.register(withModule('pos', discountRoutes), { prefix: '/v1' }) await app.register(withModule('pos', discountRoutes), { prefix: '/v1' })
await app.register(withModule('pos', taxRoutes), { prefix: '/v1' }) await app.register(withModule('pos', taxRoutes), { prefix: '/v1' })
await app.register(withModule('vault', vaultRoutes), { prefix: '/v1' }) await app.register(withModule('vault', vaultRoutes), { prefix: '/v1' })
@@ -205,6 +264,11 @@ export async function buildApp() {
} catch (err) { } catch (err) {
app.log.error({ err }, 'Failed to seed email settings') app.log.error({ err }, 'Failed to seed email settings')
} }
try {
await seedCompany(app)
} catch (err) {
app.log.error({ err }, 'Failed to seed company')
}
}) })
return app return app

View File

@@ -1,8 +1,10 @@
import type { FastifyPluginAsync } from 'fastify' import type { FastifyPluginAsync } from 'fastify'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import bcrypt from 'bcryptjs' import bcrypt from 'bcryptjs'
import { RegisterSchema, LoginSchema } from '@lunarfront/shared/schemas' import { RegisterSchema, LoginSchema, PinLoginSchema, SetPinSchema, ForgotPasswordSchema, ResetPasswordSchema } from '@lunarfront/shared/schemas'
import { users } from '../../db/schema/users.js' import { users } from '../../db/schema/users.js'
import { companies } from '../../db/schema/stores.js'
import { EmailService } from '../../services/email.service.js'
const SALT_ROUNDS = 10 const SALT_ROUNDS = 10
@@ -151,24 +153,22 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const [user] = await app.db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1) const [user] = await app.db.select({ id: users.id, email: users.email }).from(users).where(eq(users.id, userId)).limit(1)
if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } }) if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } })
// Generate a signed reset token that expires in 1 hour const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '4h' })
const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '1h' })
const resetLink = `${process.env.APP_URL ?? 'http://localhost:5173'}/reset-password?token=${resetToken}` const resetLink = `${process.env.APP_URL ?? 'http://localhost:5173'}/reset-password?token=${resetToken}`
request.log.info({ userId, generatedBy: request.user.id }, 'Password reset link generated') request.log.info({ userId, generatedBy: request.user.id }, 'Password reset link generated')
return reply.send({ resetLink, expiresIn: '1 hour' }) return reply.send({ resetLink, expiresIn: '4 hours' })
}) })
// Reset password with token // Reset password with token
app.post('/auth/reset-password', async (request, reply) => { app.post('/auth/reset-password', async (request, reply) => {
const { token, newPassword } = request.body as { token?: string; newPassword?: string } const parsed = ResetPasswordSchema.safeParse(request.body)
if (!token || !newPassword) { if (!parsed.success) {
return reply.status(400).send({ error: { message: 'token and newPassword are required', statusCode: 400 } }) return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
if (newPassword.length < 12) {
return reply.status(400).send({ error: { message: 'Password must be at least 12 characters', statusCode: 400 } })
} }
const { token, newPassword } = parsed.data
try { try {
const payload = app.jwt.verify<{ userId: string; purpose: string }>(token) const payload = app.jwt.verify<{ userId: string; purpose: string }>(token)
if (payload.purpose !== 'password-reset') { if (payload.purpose !== 'password-reset') {
@@ -185,6 +185,86 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
} }
}) })
// Forgot password / resend welcome — public, always returns success (no user enumeration)
// Pass ?type=welcome for welcome emails, defaults to reset
app.post('/auth/forgot-password', rateLimitConfig, async (request, reply) => {
const parsed = ForgotPasswordSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const { email } = parsed.data
const isWelcome = (request.query as { type?: string }).type === 'welcome'
// Rate limit per email — max 3 emails per hour
const emailKey = `pwd-reset:${email.toLowerCase()}`
const count = await app.redis.incr(emailKey)
if (count === 1) await app.redis.expire(emailKey, 3600)
if (count > 3) {
return reply.send({ message: 'If an account exists with that email, you will receive an email.' })
}
// Always return success — don't reveal whether the email exists
const [user] = await app.db.select({ id: users.id, firstName: users.firstName }).from(users).where(eq(users.email, email)).limit(1)
if (user) {
try {
const resetToken = app.jwt.sign({ userId: user.id, purpose: 'password-reset' } as any, { expiresIn: '4h' })
const appUrl = process.env.APP_URL ?? 'http://localhost:5173'
const resetLink = `${appUrl}/reset-password?token=${resetToken}`
const [store] = await app.db.select({ name: companies.name }).from(companies).limit(1)
const storeName = store?.name ?? 'LunarFront'
if (isWelcome) {
await EmailService.send(app.db, {
to: email,
subject: `Welcome to ${storeName} — Set your password`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
<h2 style="color: #1a1a2e; margin-bottom: 8px;">${storeName}</h2>
<p style="color: #555; margin-bottom: 24px;">Hi ${user.firstName},</p>
<p style="color: #555;">Your account has been created. Click the button below to set your password and get started:</p>
<div style="text-align: center; margin: 32px 0;">
<a href="${resetLink}" style="background-color: #1a1a2e; color: #fff; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 500;">Set Your Password</a>
</div>
<p style="color: #888; font-size: 13px;">This link expires in 4 hours. If it expires, you can request a new one from the login page.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
<p style="color: #aaa; font-size: 11px;">Powered by LunarFront</p>
</div>
`,
text: `Hi ${user.firstName}, welcome to ${storeName}! Set your password here: ${resetLink} — This link expires in 4 hours.`,
})
} else {
await EmailService.send(app.db, {
to: email,
subject: `Reset your password — ${storeName}`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 480px; margin: 0 auto; padding: 40px 20px;">
<h2 style="color: #1a1a2e; margin-bottom: 8px;">${storeName}</h2>
<p style="color: #555; margin-bottom: 24px;">Hi ${user.firstName},</p>
<p style="color: #555;">We received a request to reset your password. Click the button below to choose a new one:</p>
<div style="text-align: center; margin: 32px 0;">
<a href="${resetLink}" style="background-color: #1a1a2e; color: #fff; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 500;">Reset Password</a>
</div>
<p style="color: #888; font-size: 13px;">This link expires in 4 hours. If you didn't request this, you can safely ignore this email.</p>
<hr style="border: none; border-top: 1px solid #eee; margin: 32px 0;" />
<p style="color: #aaa; font-size: 11px;">Powered by LunarFront</p>
</div>
`,
text: `Hi ${user.firstName}, reset your password here: ${resetLink} — This link expires in 4 hours.`,
})
}
request.log.info({ userId: user.id, type: isWelcome ? 'welcome' : 'reset' }, 'Password email sent')
} catch (err) {
request.log.error({ email, error: (err as Error).message }, 'Failed to send password email')
}
}
return reply.send({ message: 'If an account exists with that email, you will receive a password reset link.' })
})
// Get current user profile // Get current user profile
app.get('/auth/me', { preHandler: [app.authenticate] }, async (request, reply) => { app.get('/auth/me', { preHandler: [app.authenticate] }, async (request, reply) => {
const [user] = await app.db const [user] = await app.db
@@ -194,6 +274,8 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
firstName: users.firstName, firstName: users.firstName,
lastName: users.lastName, lastName: users.lastName,
role: users.role, role: users.role,
employeeNumber: users.employeeNumber,
pinHash: users.pinHash,
createdAt: users.createdAt, createdAt: users.createdAt,
}) })
.from(users) .from(users)
@@ -201,7 +283,16 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
.limit(1) .limit(1)
if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } }) if (!user) return reply.status(404).send({ error: { message: 'User not found', statusCode: 404 } })
return reply.send(user) return reply.send({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
employeeNumber: user.employeeNumber,
hasPin: !!user.pinHash,
createdAt: user.createdAt,
})
}) })
// Update current user profile // Update current user profile
@@ -226,4 +317,71 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
return reply.send(user) return reply.send(user)
}) })
// PIN login — for POS unlock, no JWT required to call
app.post('/auth/pin-login', rateLimitConfig, async (request, reply) => {
const parsed = PinLoginSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const { code } = parsed.data
// First 4 digits = employee number, rest = PIN
const employeeNumber = code.slice(0, 4)
const pin = code.slice(4)
if (!pin) {
return reply.status(401).send({ error: { message: 'Invalid code', statusCode: 401 } })
}
const [user] = await app.db
.select()
.from(users)
.where(eq(users.employeeNumber, employeeNumber))
.limit(1)
if (!user || !user.isActive || !user.pinHash) {
return reply.status(401).send({ error: { message: 'Invalid code', statusCode: 401 } })
}
const match = await bcrypt.compare(pin, user.pinHash)
if (!match) {
return reply.status(401).send({ error: { message: 'Invalid code', statusCode: 401 } })
}
const token = app.jwt.sign({ id: user.id, role: user.role }, { expiresIn: '8h' })
request.log.info({ userId: user.id }, 'PIN login')
return reply.send({
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
},
token,
})
})
// Set PIN — requires full auth
app.post('/auth/set-pin', { preHandler: [app.authenticate] }, async (request, reply) => {
const parsed = SetPinSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const pinHash = await bcrypt.hash(parsed.data.pin, SALT_ROUNDS)
await app.db.update(users).set({ pinHash, updatedAt: new Date() }).where(eq(users.id, request.user.id))
request.log.info({ userId: request.user.id }, 'POS PIN set')
return reply.send({ message: 'PIN set' })
})
// Remove PIN — requires full auth
app.delete('/auth/pin', { preHandler: [app.authenticate] }, async (request, reply) => {
await app.db.update(users).set({ pinHash: null, updatedAt: new Date() }).where(eq(users.id, request.user.id))
request.log.info({ userId: request.user.id }, 'POS PIN removed')
return reply.send({ message: 'PIN removed' })
})
} }

View File

@@ -1,5 +1,5 @@
import type { FastifyPluginAsync } from 'fastify' import type { FastifyPluginAsync } from 'fastify'
import { PaginationSchema, DrawerOpenSchema, DrawerCloseSchema } from '@lunarfront/shared/schemas' import { PaginationSchema, DrawerOpenSchema, DrawerCloseSchema, DrawerAdjustmentSchema } from '@lunarfront/shared/schemas'
import { DrawerService } from '../../services/drawer.service.js' import { DrawerService } from '../../services/drawer.service.js'
export const drawerRoutes: FastifyPluginAsync = async (app) => { export const drawerRoutes: FastifyPluginAsync = async (app) => {
@@ -35,6 +35,23 @@ export const drawerRoutes: FastifyPluginAsync = async (app) => {
return reply.send(session) return reply.send(session)
}) })
app.post('/drawer/:id/adjustments', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = DrawerAdjustmentSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const adjustment = await DrawerService.addAdjustment(app.db, id, parsed.data, request.user.id)
request.log.info({ drawerSessionId: id, type: parsed.data.type, amount: parsed.data.amount, userId: request.user.id }, 'Drawer adjustment')
return reply.status(201).send(adjustment)
})
app.get('/drawer/:id/adjustments', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const adjustments = await DrawerService.getAdjustments(app.db, id)
return reply.send({ data: adjustments })
})
app.get('/drawer/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { app.get('/drawer/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const { id } = request.params as { id: string } const { id } = request.params as { id: string }
const session = await DrawerService.getById(app.db, id) const session = await DrawerService.getById(app.db, id)

View File

@@ -41,6 +41,7 @@ export const productRoutes: FastifyPluginAsync = async (app) => {
isSerialized: q.isSerialized === 'true' ? true : q.isSerialized === 'false' ? false : undefined, isSerialized: q.isSerialized === 'true' ? true : q.isSerialized === 'false' ? false : undefined,
isRental: q.isRental === 'true' ? true : q.isRental === 'false' ? false : undefined, isRental: q.isRental === 'true' ? true : q.isRental === 'false' ? false : undefined,
isDualUseRepair: q.isDualUseRepair === 'true' ? true : q.isDualUseRepair === 'false' ? false : undefined, isDualUseRepair: q.isDualUseRepair === 'true' ? true : q.isDualUseRepair === 'false' ? false : undefined,
isConsumable: q.isConsumable === 'true' ? true : q.isConsumable === 'false' ? false : undefined,
lowStock: q.lowStock === 'true', lowStock: q.lowStock === 'true',
} }
const result = await ProductService.list(app.db, params, filters) const result = await ProductService.list(app.db, params, filters)

View File

@@ -0,0 +1,50 @@
import type { FastifyPluginAsync } from 'fastify'
import { PaginationSchema, RegisterCreateSchema, RegisterUpdateSchema } from '@lunarfront/shared/schemas'
import { RegisterService } from '../../services/register.service.js'
export const registerRoutes: FastifyPluginAsync = async (app) => {
app.post('/registers', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const parsed = RegisterCreateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const register = await RegisterService.create(app.db, parsed.data)
return reply.status(201).send(register)
})
app.get('/registers', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const query = request.query as Record<string, string | undefined>
const params = PaginationSchema.parse(query)
const result = await RegisterService.list(app.db, params, { locationId: query.locationId })
return reply.send(result)
})
app.get('/registers/all', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const query = request.query as Record<string, string | undefined>
const data = await RegisterService.listAll(app.db, query.locationId)
return reply.send({ data })
})
app.get('/registers/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const register = await RegisterService.getById(app.db, id)
if (!register) return reply.status(404).send({ error: { message: 'Register not found', statusCode: 404 } })
return reply.send(register)
})
app.patch('/registers/:id', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const parsed = RegisterUpdateSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const register = await RegisterService.update(app.db, id, parsed.data)
return reply.send(register)
})
app.delete('/registers/:id', { preHandler: [app.authenticate, app.requirePermission('pos.admin')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const register = await RegisterService.delete(app.db, id)
return reply.send(register)
})
}

View File

@@ -27,6 +27,13 @@ export const repairRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(ticket) return reply.status(201).send(ticket)
}) })
app.get('/repair-tickets/ready', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const query = request.query as Record<string, string | undefined>
const params = PaginationSchema.parse(query)
const result = await RepairTicketService.listReadyForPickup(app.db, params)
return reply.send(result)
})
app.get('/repair-tickets', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => { app.get('/repair-tickets', { preHandler: [app.authenticate, app.requirePermission('repairs.view')] }, async (request, reply) => {
const query = request.query as Record<string, string | undefined> const query = request.query as Record<string, string | undefined>
const params = PaginationSchema.parse(query) const params = PaginationSchema.parse(query)

View File

@@ -0,0 +1,27 @@
import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
import { ReportService } from '../../services/report.service.js'
const DailyReportQuerySchema = z.object({
locationId: z.string().uuid(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
})
export const reportRoutes: FastifyPluginAsync = async (app) => {
// X or Z report for a drawer session
app.get('/reports/drawer/:id', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const { id } = request.params as { id: string }
const report = await ReportService.getDrawerReport(app.db, id)
return reply.send(report)
})
// Daily rollup for a location
app.get('/reports/daily', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const parsed = DailyReportQuerySchema.safeParse(request.query)
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed — locationId and date (YYYY-MM-DD) are required', details: parsed.error.flatten(), statusCode: 400 } })
}
const report = await ReportService.getDailyReport(app.db, parsed.data.locationId, parsed.data.date)
return reply.send(report)
})
}

View File

@@ -1,9 +1,43 @@
import type { FastifyPluginAsync } from 'fastify' import type { FastifyPluginAsync } from 'fastify'
import { eq } from 'drizzle-orm' import { eq } from 'drizzle-orm'
import { companies, locations } from '../../db/schema/stores.js' import { companies, locations } from '../../db/schema/stores.js'
import { files } from '../../db/schema/files.js'
import { ValidationError } from '../../lib/errors.js' import { ValidationError } from '../../lib/errors.js'
export const storeRoutes: FastifyPluginAsync = async (app) => { export const storeRoutes: FastifyPluginAsync = async (app) => {
// --- Public branding (no auth — used on login page) ---
app.get('/store/branding', async (_request, reply) => {
const [store] = await app.db.select({
name: companies.name,
logoFileId: companies.logoFileId,
}).from(companies).limit(1)
if (!store) return reply.send({ name: null, hasLogo: false })
return reply.send({ name: store.name, hasLogo: !!store.logoFileId })
})
app.get('/store/logo', async (_request, reply) => {
const [store] = await app.db.select({ logoFileId: companies.logoFileId }).from(companies).limit(1)
if (!store?.logoFileId) return reply.status(404).send({ error: { message: 'No logo configured', statusCode: 404 } })
const [file] = await app.db.select().from(files).where(eq(files.id, store.logoFileId)).limit(1)
if (!file) return reply.status(404).send({ error: { message: 'Logo file not found', statusCode: 404 } })
try {
const data = await app.storage.get(file.path)
const ext = file.path.split('.').pop()?.toLowerCase()
const contentTypeMap: Record<string, string> = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', svg: 'image/svg+xml',
}
return reply
.header('Content-Type', contentTypeMap[ext ?? ''] ?? 'application/octet-stream')
.header('Cache-Control', 'public, max-age=3600')
.send(data)
} catch {
return reply.status(404).send({ error: { message: 'Logo file not readable', statusCode: 404 } })
}
})
// --- Company / Store Settings --- // --- Company / Store Settings ---
app.get('/store', { preHandler: [app.authenticate, app.requirePermission('settings.view')] }, async (request, reply) => { app.get('/store', { preHandler: [app.authenticate, app.requirePermission('settings.view')] }, async (request, reply) => {

View File

@@ -1,4 +1,5 @@
import type { FastifyPluginAsync } from 'fastify' import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
import { import {
PaginationSchema, PaginationSchema,
TransactionCreateSchema, TransactionCreateSchema,
@@ -8,6 +9,10 @@ import {
} from '@lunarfront/shared/schemas' } from '@lunarfront/shared/schemas'
import { TransactionService } from '../../services/transaction.service.js' import { TransactionService } from '../../services/transaction.service.js'
const FromRepairBodySchema = z.object({
locationId: z.string().uuid().optional(),
})
export const transactionRoutes: FastifyPluginAsync = async (app) => { export const transactionRoutes: FastifyPluginAsync = async (app) => {
app.post('/transactions', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => { app.post('/transactions', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const parsed = TransactionCreateSchema.safeParse(request.body) const parsed = TransactionCreateSchema.safeParse(request.body)
@@ -19,6 +24,17 @@ export const transactionRoutes: FastifyPluginAsync = async (app) => {
return reply.status(201).send(txn) return reply.status(201).send(txn)
}) })
app.post('/transactions/from-repair/:ticketId', { preHandler: [app.authenticate, app.requirePermission('pos.edit')] }, async (request, reply) => {
const { ticketId } = request.params as { ticketId: string }
const parsed = FromRepairBodySchema.safeParse(request.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
}
const txn = await TransactionService.createFromRepairTicket(app.db, ticketId, parsed.data.locationId, request.user.id)
request.log.info({ transactionId: txn.id, ticketId, userId: request.user.id }, 'Repair payment transaction created')
return reply.status(201).send(txn)
})
app.get('/transactions', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => { app.get('/transactions', { preHandler: [app.authenticate, app.requirePermission('pos.view')] }, async (request, reply) => {
const query = request.query as Record<string, string | undefined> const query = request.query as Record<string, string | undefined>
const params = PaginationSchema.parse(query) const params = PaginationSchema.parse(query)
@@ -27,6 +43,8 @@ export const transactionRoutes: FastifyPluginAsync = async (app) => {
status: query.status, status: query.status,
transactionType: query.transactionType, transactionType: query.transactionType,
locationId: query.locationId, locationId: query.locationId,
accountId: query.accountId,
itemSearch: query.itemSearch,
} }
const result = await TransactionService.list(app.db, params, filters) const result = await TransactionService.list(app.db, params, filters)

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bun
/**
* Force-reset a user's password from the command line.
*
* Usage:
* bun run packages/backend/src/scripts/reset-password.ts <email> <new-password>
*
* From a customer pod:
* kubectl exec -n customer-tvs deploy/customer-tvs-backend -- \
* bun run src/scripts/reset-password.ts user@example.com NewPassword123!
*/
import postgres from 'postgres'
import { drizzle } from 'drizzle-orm/postgres-js'
import { eq } from 'drizzle-orm'
import bcrypt from 'bcryptjs'
import { users } from '../db/schema/users.js'
const [email, newPassword] = process.argv.slice(2)
if (!email || !newPassword) {
console.error('Usage: bun run reset-password.ts <email> <new-password>')
process.exit(1)
}
if (newPassword.length < 12) {
console.error('Error: Password must be at least 12 characters')
process.exit(1)
}
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
console.error('Error: DATABASE_URL is not set')
process.exit(1)
}
const sql = postgres(databaseUrl)
const db = drizzle(sql)
const [user] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.email, email)).limit(1)
if (!user) {
console.error(`Error: No user found with email "${email}"`)
await sql.end()
process.exit(1)
}
const hash = await bcrypt.hash(newPassword, 10)
await db.update(users).set({ passwordHash: hash, updatedAt: new Date() }).where(eq(users.id, user.id))
console.log(`Password reset for ${email} (user ${user.id})`)
await sql.end()

View File

@@ -1,8 +1,8 @@
import { eq, and, count, sum, type Column } from 'drizzle-orm' import { eq, and, count, sum, sql, type Column } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { drawerSessions, transactions } from '../db/schema/pos.js' import { drawerSessions, drawerAdjustments, transactions } from '../db/schema/pos.js'
import { ConflictError, NotFoundError } from '../lib/errors.js' import { ConflictError, NotFoundError } from '../lib/errors.js'
import type { DrawerOpenInput, DrawerCloseInput, PaginationInput } from '@lunarfront/shared/schemas' import type { DrawerOpenInput, DrawerCloseInput, DrawerAdjustmentInput, PaginationInput } from '@lunarfront/shared/schemas'
import { withPagination, withSort, paginatedResponse } from '../utils/pagination.js' import { withPagination, withSort, paginatedResponse } from '../utils/pagination.js'
export const DrawerService = { export const DrawerService = {
@@ -19,6 +19,7 @@ export const DrawerService = {
.insert(drawerSessions) .insert(drawerSessions)
.values({ .values({
locationId: input.locationId, locationId: input.locationId,
registerId: input.registerId,
openedBy, openedBy,
openingBalance: input.openingBalance.toString(), openingBalance: input.openingBalance.toString(),
}) })
@@ -47,9 +48,20 @@ export const DrawerService = {
) )
) )
const cashIn = parseFloat(cashTotals?.total ?? '0') + parseFloat(cashTotals?.rounding ?? '0') // Calculate net drawer adjustments (cash_in adds, cash_out subtracts)
const [adjTotals] = await db
.select({
cashIn: sql<string>`coalesce(sum(case when ${drawerAdjustments.type} = 'cash_in' then ${drawerAdjustments.amount} else 0 end), 0)`,
cashOut: sql<string>`coalesce(sum(case when ${drawerAdjustments.type} = 'cash_out' then ${drawerAdjustments.amount} else 0 end), 0)`,
})
.from(drawerAdjustments)
.where(eq(drawerAdjustments.drawerSessionId, sessionId))
const salesCashIn = parseFloat(cashTotals?.total ?? '0') + parseFloat(cashTotals?.rounding ?? '0')
const adjCashIn = parseFloat(adjTotals?.cashIn ?? '0')
const adjCashOut = parseFloat(adjTotals?.cashOut ?? '0')
const openingBalance = parseFloat(session.openingBalance) const openingBalance = parseFloat(session.openingBalance)
const expectedBalance = openingBalance + cashIn const expectedBalance = openingBalance + salesCashIn + adjCashIn - adjCashOut
const closingBalance = input.closingBalance const closingBalance = input.closingBalance
const overShort = closingBalance - expectedBalance const overShort = closingBalance - expectedBalance
@@ -93,6 +105,31 @@ export const DrawerService = {
return session ?? null return session ?? null
}, },
async addAdjustment(db: PostgresJsDatabase<any>, sessionId: string, input: DrawerAdjustmentInput, createdBy: string, _approvedBy?: string) {
const session = await this.getById(db, sessionId)
if (!session) throw new NotFoundError('Drawer session')
if (session.status === 'closed') throw new ConflictError('Cannot adjust a closed drawer')
const [adjustment] = await db
.insert(drawerAdjustments)
.values({
drawerSessionId: sessionId,
type: input.type,
amount: input.amount.toString(),
reason: input.reason,
createdBy,
})
.returning()
return adjustment
},
async getAdjustments(db: PostgresJsDatabase<any>, sessionId: string) {
return db
.select()
.from(drawerAdjustments)
.where(eq(drawerAdjustments.drawerSessionId, sessionId))
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput) { async list(db: PostgresJsDatabase<any>, params: PaginationInput) {
const sortableColumns: Record<string, Column> = { const sortableColumns: Record<string, Column> = {
opened_at: drawerSessions.openedAt, opened_at: drawerSessions.openedAt,

View File

@@ -49,6 +49,7 @@ export const ProductService = {
isSerialized?: boolean isSerialized?: boolean
isRental?: boolean isRental?: boolean
isDualUseRepair?: boolean isDualUseRepair?: boolean
isConsumable?: boolean
lowStock?: boolean lowStock?: boolean
}) { }) {
const conditions = [eq(products.isActive, filters?.isActive ?? true)] const conditions = [eq(products.isActive, filters?.isActive ?? true)]
@@ -68,6 +69,9 @@ export const ProductService = {
if (filters?.isDualUseRepair !== undefined) { if (filters?.isDualUseRepair !== undefined) {
conditions.push(eq(products.isDualUseRepair, filters.isDualUseRepair)) conditions.push(eq(products.isDualUseRepair, filters.isDualUseRepair))
} }
if (filters?.isConsumable !== undefined) {
conditions.push(eq(products.isConsumable, filters.isConsumable))
}
if (filters?.lowStock) { if (filters?.lowStock) {
// qty_on_hand <= qty_reorder_point (and reorder point is set) OR qty_on_hand = 0 // qty_on_hand <= qty_reorder_point (and reorder point is set) OR qty_on_hand = 0
conditions.push( conditions.push(

View File

@@ -0,0 +1,84 @@
import { eq, and, count, type Column } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { registers } from '../db/schema/pos.js'
import { NotFoundError } from '../lib/errors.js'
import type { RegisterCreateInput, RegisterUpdateInput, PaginationInput } from '@lunarfront/shared/schemas'
import { withPagination, withSort, buildSearchCondition, paginatedResponse } from '../utils/pagination.js'
export const RegisterService = {
async create(db: PostgresJsDatabase<any>, input: RegisterCreateInput) {
const [register] = await db
.insert(registers)
.values({
locationId: input.locationId,
name: input.name,
})
.returning()
return register
},
async getById(db: PostgresJsDatabase<any>, id: string) {
const [register] = await db
.select()
.from(registers)
.where(eq(registers.id, id))
.limit(1)
return register ?? null
},
async list(db: PostgresJsDatabase<any>, params: PaginationInput, filters?: { locationId?: string }) {
const conditions = [eq(registers.isActive, true)]
if (params.q) {
conditions.push(buildSearchCondition(params.q, [registers.name])!)
}
if (filters?.locationId) {
conditions.push(eq(registers.locationId, filters.locationId))
}
const where = conditions.length === 1 ? conditions[0] : and(...conditions)
const sortableColumns: Record<string, Column> = {
name: registers.name,
created_at: registers.createdAt,
}
let query = db.select().from(registers).where(where).$dynamic()
query = withSort(query, params.sort, params.order, sortableColumns, registers.name)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(registers).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async listAll(db: PostgresJsDatabase<any>, locationId?: string) {
const conditions = [eq(registers.isActive, true)]
if (locationId) conditions.push(eq(registers.locationId, locationId))
const where = conditions.length === 1 ? conditions[0] : and(...conditions)
return db.select().from(registers).where(where)
},
async update(db: PostgresJsDatabase<any>, id: string, input: RegisterUpdateInput) {
const [updated] = await db
.update(registers)
.set({ ...input, updatedAt: new Date() })
.where(eq(registers.id, id))
.returning()
if (!updated) throw new NotFoundError('Register')
return updated
},
async delete(db: PostgresJsDatabase<any>, id: string) {
const [deleted] = await db
.update(registers)
.set({ isActive: false, updatedAt: new Date() })
.where(eq(registers.id, id))
.returning()
if (!deleted) throw new NotFoundError('Register')
return deleted
},
}

View File

@@ -174,6 +174,25 @@ export const RepairTicketService = {
return paginatedResponse(data, total, params.page, params.limit) return paginatedResponse(data, total, params.page, params.limit)
}, },
async listReadyForPickup(db: PostgresJsDatabase<any>, params: PaginationInput) {
const baseWhere = eq(repairTickets.status, 'ready')
const searchCondition = params.q
? buildSearchCondition(params.q, [repairTickets.ticketNumber, repairTickets.customerName, repairTickets.customerPhone])
: undefined
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
let query = db.select().from(repairTickets).where(where).$dynamic()
query = withSort(query, params.sort, params.order, { ticket_number: repairTickets.ticketNumber, customer_name: repairTickets.customerName, created_at: repairTickets.createdAt }, repairTickets.createdAt)
query = withPagination(query, params.page, params.limit)
const [data, [{ total }]] = await Promise.all([
query,
db.select({ total: count() }).from(repairTickets).where(where),
])
return paginatedResponse(data, total, params.page, params.limit)
},
async update(db: PostgresJsDatabase<any>, id: string, input: RepairTicketUpdateInput) { async update(db: PostgresJsDatabase<any>, id: string, input: RepairTicketUpdateInput) {
const values: Record<string, unknown> = { ...input, updatedAt: new Date() } const values: Record<string, unknown> = { ...input, updatedAt: new Date() }
if (input.estimatedCost !== undefined) values.estimatedCost = input.estimatedCost.toString() if (input.estimatedCost !== undefined) values.estimatedCost = input.estimatedCost.toString()

View File

@@ -0,0 +1,292 @@
import { eq, and, gte, lt } from 'drizzle-orm'
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import { transactions, drawerSessions, drawerAdjustments, registers } from '../db/schema/pos.js'
import { locations } from '../db/schema/stores.js'
import { users } from '../db/schema/users.js'
import { NotFoundError } from '../lib/errors.js'
interface PaymentBreakdown {
count: number
total: number
}
interface DrawerReport {
session: {
id: string
openedAt: string
closedAt: string | null
openingBalance: string
closingBalance: string | null
expectedBalance: string | null
overShort: string | null
status: string
notes: string | null
denominations: Record<string, number> | null
register: { id: string; name: string } | null
openedBy: { id: string; firstName: string; lastName: string } | null
closedBy: { id: string; firstName: string; lastName: string } | null
}
sales: {
grossSales: number
netSales: number
transactionCount: number
voidCount: number
refundTotal: number
}
payments: Record<string, PaymentBreakdown>
discounts: {
total: number
count: number
}
cash: {
openingBalance: number
cashSales: number
cashIn: number
cashOut: number
expectedBalance: number
actualBalance: number | null
overShort: number | null
}
adjustments: { id: string; type: string; amount: string; reason: string; createdAt: string }[]
}
export const ReportService = {
async getDrawerReport(db: PostgresJsDatabase<any>, drawerSessionId: string): Promise<DrawerReport> {
// Fetch session with register and user info
const [session] = await db
.select()
.from(drawerSessions)
.where(eq(drawerSessions.id, drawerSessionId))
.limit(1)
if (!session) throw new NotFoundError('Drawer session')
// Fetch register info
let register: { id: string; name: string } | null = null
if (session.registerId) {
const [reg] = await db.select({ id: registers.id, name: registers.name }).from(registers).where(eq(registers.id, session.registerId)).limit(1)
register = reg ?? null
}
// Fetch user info
const [openedByUser] = await db.select({ id: users.id, firstName: users.firstName, lastName: users.lastName }).from(users).where(eq(users.id, session.openedBy)).limit(1)
let closedByUser = null
if (session.closedBy) {
const [u] = await db.select({ id: users.id, firstName: users.firstName, lastName: users.lastName }).from(users).where(eq(users.id, session.closedBy)).limit(1)
closedByUser = u ?? null
}
// Aggregate transaction data for this drawer session
const txns = await db
.select({
status: transactions.status,
transactionType: transactions.transactionType,
paymentMethod: transactions.paymentMethod,
total: transactions.total,
discountTotal: transactions.discountTotal,
roundingAdjustment: transactions.roundingAdjustment,
})
.from(transactions)
.where(eq(transactions.drawerSessionId, drawerSessionId))
// Calculate sales
let grossSales = 0
let refundTotal = 0
let transactionCount = 0
let voidCount = 0
let discountTotalSum = 0
let discountCount = 0
const payments: Record<string, PaymentBreakdown> = {}
for (const txn of txns) {
if (txn.status === 'voided') {
voidCount++
continue
}
if (txn.status !== 'completed') continue
const total = parseFloat(txn.total ?? '0')
const discAmt = parseFloat(txn.discountTotal ?? '0')
if (txn.transactionType === 'refund') {
refundTotal += total
} else {
grossSales += total
transactionCount++
}
if (discAmt > 0) {
discountTotalSum += discAmt
discountCount++
}
const method = txn.paymentMethod ?? 'unknown'
if (!payments[method]) payments[method] = { count: 0, total: 0 }
payments[method].count++
payments[method].total += total
}
// Cash accountability
const cashPayment = payments['cash'] ?? { count: 0, total: 0 }
const cashRounding = txns
.filter((t) => t.status === 'completed' && t.paymentMethod === 'cash')
.reduce((sum, t) => sum + parseFloat(t.roundingAdjustment ?? '0'), 0)
const cashSales = cashPayment.total + cashRounding
// Adjustments
const adjRows = await db
.select()
.from(drawerAdjustments)
.where(eq(drawerAdjustments.drawerSessionId, drawerSessionId))
let cashIn = 0
let cashOut = 0
for (const adj of adjRows) {
if (adj.type === 'cash_in') cashIn += parseFloat(adj.amount)
else cashOut += parseFloat(adj.amount)
}
const openingBalance = parseFloat(session.openingBalance)
const expectedBalance = openingBalance + cashSales + cashIn - cashOut
const actualBalance = session.closingBalance ? parseFloat(session.closingBalance) : null
const overShort = actualBalance !== null ? Math.round((actualBalance - expectedBalance) * 100) / 100 : null
return {
session: {
id: session.id,
openedAt: session.openedAt.toISOString(),
closedAt: session.closedAt?.toISOString() ?? null,
openingBalance: session.openingBalance,
closingBalance: session.closingBalance,
expectedBalance: session.expectedBalance,
overShort: session.overShort,
status: session.status,
notes: session.notes,
denominations: session.denominations,
register,
openedBy: openedByUser ?? null,
closedBy: closedByUser,
},
sales: {
grossSales: Math.round(grossSales * 100) / 100,
netSales: Math.round((grossSales - refundTotal) * 100) / 100,
transactionCount,
voidCount,
refundTotal: Math.round(refundTotal * 100) / 100,
},
payments,
discounts: {
total: Math.round(discountTotalSum * 100) / 100,
count: discountCount,
},
cash: {
openingBalance,
cashSales: Math.round(cashSales * 100) / 100,
cashIn: Math.round(cashIn * 100) / 100,
cashOut: Math.round(cashOut * 100) / 100,
expectedBalance: Math.round(expectedBalance * 100) / 100,
actualBalance,
overShort,
},
adjustments: adjRows.map((a) => ({
id: a.id,
type: a.type,
amount: a.amount,
reason: a.reason,
createdAt: a.createdAt.toISOString(),
})),
}
},
async getDailyReport(db: PostgresJsDatabase<any>, locationId: string, date: string) {
// Get location info
const [location] = await db
.select({ id: locations.id, name: locations.name, timezone: locations.timezone })
.from(locations)
.where(eq(locations.id, locationId))
.limit(1)
if (!location) throw new NotFoundError('Location')
// Find all drawer sessions opened at this location on the given date
const dayStart = new Date(`${date}T00:00:00`)
const dayEnd = new Date(`${date}T00:00:00`)
dayEnd.setDate(dayEnd.getDate() + 1)
const sessions = await db
.select()
.from(drawerSessions)
.where(and(
eq(drawerSessions.locationId, locationId),
gte(drawerSessions.openedAt, dayStart),
lt(drawerSessions.openedAt, dayEnd),
))
// Get individual reports for each session
const sessionReports = await Promise.all(
sessions.map((s) => this.getDrawerReport(db, s.id))
)
// Aggregate
const sales = { grossSales: 0, netSales: 0, transactionCount: 0, voidCount: 0, refundTotal: 0 }
const payments: Record<string, PaymentBreakdown> = {}
const discounts = { total: 0, count: 0 }
const cash = { totalOpening: 0, totalCashSales: 0, totalCashIn: 0, totalCashOut: 0, totalExpected: 0, totalActual: 0, totalOverShort: 0 }
for (const report of sessionReports) {
sales.grossSales += report.sales.grossSales
sales.netSales += report.sales.netSales
sales.transactionCount += report.sales.transactionCount
sales.voidCount += report.sales.voidCount
sales.refundTotal += report.sales.refundTotal
for (const [method, data] of Object.entries(report.payments)) {
if (!payments[method]) payments[method] = { count: 0, total: 0 }
payments[method].count += data.count
payments[method].total += data.total
}
discounts.total += report.discounts.total
discounts.count += report.discounts.count
cash.totalOpening += report.cash.openingBalance
cash.totalCashSales += report.cash.cashSales
cash.totalCashIn += report.cash.cashIn
cash.totalCashOut += report.cash.cashOut
cash.totalExpected += report.cash.expectedBalance
if (report.cash.actualBalance !== null) cash.totalActual += report.cash.actualBalance
if (report.cash.overShort !== null) cash.totalOverShort += report.cash.overShort
}
// Round all aggregated values
for (const key of Object.keys(sales) as (keyof typeof sales)[]) {
sales[key] = Math.round(sales[key] * 100) / 100
}
for (const data of Object.values(payments)) {
data.total = Math.round(data.total * 100) / 100
}
discounts.total = Math.round(discounts.total * 100) / 100
for (const key of Object.keys(cash) as (keyof typeof cash)[]) {
cash[key] = Math.round(cash[key] * 100) / 100
}
return {
date,
location: { id: location.id, name: location.name },
sessions: sessionReports.map((r) => ({
id: r.session.id,
register: r.session.register,
openedBy: r.session.openedBy,
openedAt: r.session.openedAt,
closedAt: r.session.closedAt,
status: r.session.status,
overShort: r.cash.overShort,
grossSales: r.sales.grossSales,
})),
sales,
payments,
discounts,
cash,
}
},
}

View File

@@ -63,6 +63,8 @@ export const TaxService = {
switch (itemType) { switch (itemType) {
case 'labor': case 'labor':
return 'service' return 'service'
case 'consumable':
return 'exempt'
case 'part': case 'part':
case 'flat_rate': case 'flat_rate':
case 'misc': case 'misc':

View File

@@ -8,6 +8,7 @@ import {
drawerSessions, drawerSessions,
} from '../db/schema/pos.js' } from '../db/schema/pos.js'
import { products, inventoryUnits } from '../db/schema/inventory.js' import { products, inventoryUnits } from '../db/schema/inventory.js'
import { repairTickets, repairLineItems } from '../db/schema/repairs.js'
import { companies, locations } from '../db/schema/stores.js' import { companies, locations } from '../db/schema/stores.js'
import { NotFoundError, ValidationError, ConflictError } from '../lib/errors.js' import { NotFoundError, ValidationError, ConflictError } from '../lib/errors.js'
import { TaxService } from './tax.service.js' import { TaxService } from './tax.service.js'
@@ -47,6 +48,101 @@ export const TransactionService = {
return txn return txn
}, },
async createFromRepairTicket(db: PostgresJsDatabase<any>, ticketId: string, locationId: string | undefined, processedBy: string) {
// Validate ticket exists and is eligible before entering transaction
const [ticket] = await db
.select()
.from(repairTickets)
.where(eq(repairTickets.id, ticketId))
.limit(1)
if (!ticket) throw new NotFoundError('Repair ticket')
if (!['ready', 'approved', 'in_progress'].includes(ticket.status)) {
throw new ValidationError('Ticket must be in ready, approved, or in_progress status to check out')
}
// Check for existing pending repair_payment for this ticket
const [existing] = await db
.select({ id: transactions.id })
.from(transactions)
.where(and(
eq(transactions.repairTicketId, ticketId),
eq(transactions.status, 'pending'),
))
.limit(1)
if (existing) throw new ConflictError('A pending transaction already exists for this ticket')
// Fetch non-consumable line items
const items = await db
.select()
.from(repairLineItems)
.where(eq(repairLineItems.repairTicketId, ticketId))
const billableItems = items.filter((i) => i.itemType !== 'consumable')
if (billableItems.length === 0) throw new ValidationError('No billable line items on this ticket')
const resolvedLocationId = locationId ?? ticket.locationId
// Wrap creation + line items in a DB transaction for atomicity
return db.transaction(async (tx) => {
const transactionNumber = await generateTransactionNumber(tx)
const [txn] = await tx
.insert(transactions)
.values({
transactionNumber,
transactionType: 'repair_payment',
locationId: resolvedLocationId,
accountId: ticket.accountId,
repairTicketId: ticketId,
processedBy,
})
.returning()
for (const item of billableItems) {
const taxCategory = TaxService.repairItemTypeToTaxCategory(item.itemType)
let taxRate = 0
if (resolvedLocationId) {
taxRate = await TaxService.getRateForLocation(tx, resolvedLocationId, taxCategory)
}
const unitPrice = parseFloat(item.unitPrice) || 0
const qty = Math.max(1, Math.round(parseFloat(item.qty) || 1))
const lineSubtotal = unitPrice * qty
const taxAmount = TaxService.calculateTax(lineSubtotal, taxRate)
const lineTotal = lineSubtotal + taxAmount
await tx.insert(transactionLineItems).values({
transactionId: txn.id,
productId: item.productId,
description: item.description,
qty,
unitPrice: unitPrice.toString(),
taxRate: taxRate.toString(),
taxAmount: taxAmount.toString(),
lineTotal: lineTotal.toString(),
})
}
await this.recalculateTotals(tx, txn.id)
// Return full transaction with line items
const lineItemRows = await tx
.select()
.from(transactionLineItems)
.where(eq(transactionLineItems.transactionId, txn.id))
// Re-read the transaction to get updated totals
const [updated] = await tx
.select()
.from(transactions)
.where(eq(transactions.id, txn.id))
.limit(1)
return { ...updated, lineItems: lineItemRows }
})
},
async addLineItem(db: PostgresJsDatabase<any>, transactionId: string, input: TransactionLineItemCreateInput) { async addLineItem(db: PostgresJsDatabase<any>, transactionId: string, input: TransactionLineItemCreateInput) {
const txn = await this.getById(db, transactionId) const txn = await this.getById(db, transactionId)
if (!txn) throw new NotFoundError('Transaction') if (!txn) throw new NotFoundError('Transaction')
@@ -233,6 +329,7 @@ export const TransactionService = {
if (txn.status !== 'pending') throw new ConflictError('Transaction is not pending') if (txn.status !== 'pending') throw new ConflictError('Transaction is not pending')
// Require an open drawer session at the transaction's location // Require an open drawer session at the transaction's location
let drawerSessionId: string | null = null
if (txn.locationId) { if (txn.locationId) {
const [openDrawer] = await db const [openDrawer] = await db
.select({ id: drawerSessions.id }) .select({ id: drawerSessions.id })
@@ -242,6 +339,7 @@ export const TransactionService = {
if (!openDrawer) { if (!openDrawer) {
throw new ValidationError('Cannot complete transaction without an open drawer at this location') throw new ValidationError('Cannot complete transaction without an open drawer at this location')
} }
drawerSessionId = openDrawer.id
} }
// Validate cash payment (with optional nickel rounding) // Validate cash payment (with optional nickel rounding)
@@ -270,46 +368,56 @@ export const TransactionService = {
changeGiven = (input.amountTendered - total).toString() changeGiven = (input.amountTendered - total).toString()
} }
// Update inventory for each line item // Wrap inventory updates, transaction completion, and repair status in a DB transaction
const lineItems = await db return db.transaction(async (tx) => {
.select() const lineItems = await tx
.from(transactionLineItems) .select()
.where(eq(transactionLineItems.transactionId, transactionId)) .from(transactionLineItems)
.where(eq(transactionLineItems.transactionId, transactionId))
for (const item of lineItems) { for (const item of lineItems) {
if (item.inventoryUnitId) { if (item.inventoryUnitId) {
// Serialized item — mark as sold await tx
await db .update(inventoryUnits)
.update(inventoryUnits) .set({ status: 'sold' })
.set({ status: 'sold' }) .where(eq(inventoryUnits.id, item.inventoryUnitId))
.where(eq(inventoryUnits.id, item.inventoryUnitId)) } else if (item.productId) {
} else if (item.productId) { await tx
// Non-serialized — decrement qty_on_hand .update(products)
await db .set({
.update(products) qtyOnHand: sql`${products.qtyOnHand} - ${item.qty}`,
.set({ updatedAt: new Date(),
qtyOnHand: sql`${products.qtyOnHand} - ${item.qty}`, })
updatedAt: new Date(), .where(eq(products.id, item.productId))
}) }
.where(eq(products.id, item.productId))
} }
}
const [completed] = await db const [completed] = await tx
.update(transactions) .update(transactions)
.set({ .set({
status: 'completed', status: 'completed',
paymentMethod: input.paymentMethod, paymentMethod: input.paymentMethod,
amountTendered: input.amountTendered?.toString(), amountTendered: input.amountTendered?.toString(),
changeGiven, changeGiven,
roundingAdjustment: roundingAdjustment.toString(), roundingAdjustment: roundingAdjustment.toString(),
checkNumber: input.checkNumber, checkNumber: input.checkNumber,
completedAt: new Date(), drawerSessionId,
updatedAt: new Date(), completedAt: new Date(),
}) updatedAt: new Date(),
.where(eq(transactions.id, transactionId)) })
.returning() .where(eq(transactions.id, transactionId))
return completed .returning()
// If this is a repair payment, update ticket status to picked_up
if (completed.transactionType === 'repair_payment' && completed.repairTicketId) {
await tx
.update(repairTickets)
.set({ status: 'picked_up', completedDate: new Date(), updatedAt: new Date() })
.where(eq(repairTickets.id, completed.repairTicketId))
}
return completed
})
}, },
async void(db: PostgresJsDatabase<any>, transactionId: string, _voidedBy: string) { async void(db: PostgresJsDatabase<any>, transactionId: string, _voidedBy: string) {
@@ -390,12 +498,20 @@ export const TransactionService = {
status?: string status?: string
transactionType?: string transactionType?: string
locationId?: string locationId?: string
accountId?: string
itemSearch?: string
}) { }) {
const conditions: ReturnType<typeof eq>[] = [] const conditions: ReturnType<typeof eq>[] = []
if (params.q) { if (params.q) {
conditions.push(buildSearchCondition(params.q, [transactions.transactionNumber])!) conditions.push(buildSearchCondition(params.q, [transactions.transactionNumber])!)
} }
if (filters?.itemSearch) {
const term = `%${filters.itemSearch}%`
conditions.push(
sql`EXISTS (SELECT 1 FROM ${transactionLineItems} WHERE ${transactionLineItems.transactionId} = ${transactions.id} AND ${transactionLineItems.description} ILIKE ${term})`
)
}
if (filters?.status) { if (filters?.status) {
conditions.push(eq(transactions.status, filters.status as any)) conditions.push(eq(transactions.status, filters.status as any))
} }
@@ -405,6 +521,9 @@ export const TransactionService = {
if (filters?.locationId) { if (filters?.locationId) {
conditions.push(eq(transactions.locationId, filters.locationId)) conditions.push(eq(transactions.locationId, filters.locationId))
} }
if (filters?.accountId) {
conditions.push(eq(transactions.accountId, filters.accountId))
}
const where = conditions.length === 0 ? undefined : conditions.length === 1 ? conditions[0] : and(...conditions) const where = conditions.length === 0 ? undefined : conditions.length === 1 ? conditions[0] : and(...conditions)

View File

@@ -17,3 +17,24 @@ export const LoginSchema = z.object({
password: z.string().min(1), password: z.string().min(1),
}) })
export type LoginInput = z.infer<typeof LoginSchema> export type LoginInput = z.infer<typeof LoginSchema>
export const PinLoginSchema = z.object({
code: z.string().min(8).max(10).regex(/^\d+$/, 'Code must be digits only'),
})
export type PinLoginInput = z.infer<typeof PinLoginSchema>
export const SetPinSchema = z.object({
pin: z.string().min(4).max(6).regex(/^\d+$/, 'PIN must be digits only'),
})
export type SetPinInput = z.infer<typeof SetPinSchema>
export const ForgotPasswordSchema = z.object({
email: z.string().email(),
})
export type ForgotPasswordInput = z.infer<typeof ForgotPasswordSchema>
export const ResetPasswordSchema = z.object({
token: z.string().min(1),
newPassword: z.string().min(12, 'Password must be at least 12 characters').max(128),
})
export type ResetPasswordInput = z.infer<typeof ResetPasswordSchema>

View File

@@ -1,8 +1,8 @@
export { PaginationSchema } from './pagination.schema.js' export { PaginationSchema } from './pagination.schema.js'
export type { PaginationInput, PaginatedResponse } from './pagination.schema.js' export type { PaginationInput, PaginatedResponse } from './pagination.schema.js'
export { UserRole, RegisterSchema, LoginSchema } from './auth.schema.js' export { UserRole, RegisterSchema, LoginSchema, PinLoginSchema, SetPinSchema, ForgotPasswordSchema, ResetPasswordSchema } from './auth.schema.js'
export type { RegisterInput, LoginInput } from './auth.schema.js' export type { RegisterInput, LoginInput, PinLoginInput, SetPinInput, ForgotPasswordInput, ResetPasswordInput } from './auth.schema.js'
export { export {
BillingMode, BillingMode,
@@ -180,6 +180,9 @@ export {
DiscountUpdateSchema, DiscountUpdateSchema,
DrawerOpenSchema, DrawerOpenSchema,
DrawerCloseSchema, DrawerCloseSchema,
DrawerAdjustmentSchema,
RegisterCreateSchema,
RegisterUpdateSchema,
} from './pos.schema.js' } from './pos.schema.js'
export type { export type {
TransactionCreateInput, TransactionCreateInput,
@@ -190,6 +193,9 @@ export type {
DiscountUpdateInput, DiscountUpdateInput,
DrawerOpenInput, DrawerOpenInput,
DrawerCloseInput, DrawerCloseInput,
DrawerAdjustmentInput,
RegisterCreateInput,
RegisterUpdateInput,
} from './pos.schema.js' } from './pos.schema.js'
export { LogLevel, AppConfigUpdateSchema } from './config.schema.js' export { LogLevel, AppConfigUpdateSchema } from './config.schema.js'

View File

@@ -61,6 +61,7 @@ export const ProductCreateSchema = z.object({
isSerialized: z.boolean().default(false), isSerialized: z.boolean().default(false),
isRental: z.boolean().default(false), isRental: z.boolean().default(false),
isDualUseRepair: z.boolean().default(false), isDualUseRepair: z.boolean().default(false),
isConsumable: z.boolean().default(false),
price: z.number().min(0).optional(), price: z.number().min(0).optional(),
minPrice: z.number().min(0).optional(), minPrice: z.number().min(0).optional(),
rentalRateMonthly: z.number().min(0).optional(), rentalRateMonthly: z.number().min(0).optional(),

View File

@@ -100,10 +100,18 @@ export type DiscountUpdateInput = z.infer<typeof DiscountUpdateSchema>
export const DrawerOpenSchema = z.object({ export const DrawerOpenSchema = z.object({
locationId: opt(z.string().uuid()), locationId: opt(z.string().uuid()),
registerId: opt(z.string().uuid()),
openingBalance: z.coerce.number().min(0), openingBalance: z.coerce.number().min(0),
}) })
export type DrawerOpenInput = z.infer<typeof DrawerOpenSchema> export type DrawerOpenInput = z.infer<typeof DrawerOpenSchema>
export const DrawerAdjustmentSchema = z.object({
type: z.enum(['cash_in', 'cash_out']),
amount: z.coerce.number().min(0.01),
reason: z.string().min(1),
})
export type DrawerAdjustmentInput = z.infer<typeof DrawerAdjustmentSchema>
export const DrawerCloseSchema = z.object({ export const DrawerCloseSchema = z.object({
closingBalance: z.coerce.number().min(0), closingBalance: z.coerce.number().min(0),
denominations: z denominations: z
@@ -112,3 +120,17 @@ export const DrawerCloseSchema = z.object({
notes: opt(z.string()), notes: opt(z.string()),
}) })
export type DrawerCloseInput = z.infer<typeof DrawerCloseSchema> export type DrawerCloseInput = z.infer<typeof DrawerCloseSchema>
// --- Register schemas ---
export const RegisterCreateSchema = z.object({
locationId: z.string().uuid(),
name: z.string().min(1).max(100),
})
export type RegisterCreateInput = z.infer<typeof RegisterCreateSchema>
export const RegisterUpdateSchema = z.object({
name: z.string().min(1).max(100).optional(),
isActive: z.boolean().optional(),
})
export type RegisterUpdateInput = z.infer<typeof RegisterUpdateSchema>

View File

@@ -13,7 +13,7 @@ export const RepairTicketStatus = z.enum([
]) ])
export type RepairTicketStatus = z.infer<typeof RepairTicketStatus> export type RepairTicketStatus = z.infer<typeof RepairTicketStatus>
export const RepairLineItemType = z.enum(['labor', 'part', 'flat_rate', 'misc']) export const RepairLineItemType = z.enum(['labor', 'part', 'flat_rate', 'misc', 'consumable'])
export type RepairLineItemType = z.infer<typeof RepairLineItemType> export type RepairLineItemType = z.infer<typeof RepairLineItemType>
export const RepairConditionIn = z.enum(['excellent', 'good', 'fair', 'poor']) export const RepairConditionIn = z.enum(['excellent', 'good', 'fair', 'poor'])