193 Commits

Author SHA1 Message Date
abe75fb1fd Merge pull request 'feat: email receipts and repair estimates' (#13) from feature/email-receipts into main
All checks were successful
Build & Release / build (push) Successful in 1m17s
Reviewed-on: #13
2026-04-05 20:34:59 +00:00
ryan
45fd6d34eb feat: email receipts and repair estimates
All checks were successful
CI / ci (pull_request) Successful in 24s
CI / e2e (pull_request) Successful in 1m2s
Backend:
- Server-side HTML email templates (receipt + estimate) with inline CSS
- POST /v1/transactions/:id/email-receipt with per-transaction rate limiting
- POST /v1/repair-tickets/:id/email-estimate with per-ticket rate limiting
- customerEmail field added to receipt and ticket detail responses
- Test email provider for API tests (logs instead of sending)

Frontend:
- POS payment dialog Email button enabled with inline email input
- Pre-fills customer email from linked account
- Repair ticket detail page has Email Estimate button with dialog
- Pre-fills from account email

Tests:
- 12 unit tests for email template renderers
- 8 API tests for email receipt/estimate endpoints and validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 20:32:52 +00:00
30233b430f Merge pull request 'fix: move PIN warning banner to authenticated layout for all pages' (#12) from feature/user-profile into main
All checks were successful
Build & Release / build (push) Successful in 1m16s
Reviewed-on: #12
2026-04-05 20:03:56 +00:00
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
7bca854058 Merge pull request 'feat: tabbed profile page with PIN setup and auto employee numbers' (#11) from feature/user-profile into main
All checks were successful
Build & Release / build (push) Successful in 1m19s
Reviewed-on: #11
2026-04-05 19:43:53 +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
Ryan Moon
24ddb17ca8 fix: rename migration 0039_app_settings to 0041 to avoid conflict with 0039_cash-rounding
All checks were successful
Build & Release / build (push) Successful in 17s
2026-04-05 10:58:59 -05:00
Ryan Moon
bd2252e426 feat: add email, encryption, and initial user env vars to backend chart
All checks were successful
Build & Release / build (push) Successful in 17s
2026-04-05 10:47:25 -05:00
Ryan Moon
254fe0e5d5 fix: move ts-expect-error inside navigate object to suppress search type error
All checks were successful
Build & Release / build (push) Successful in 1m6s
2026-04-05 10:42:00 -05:00
Ryan Moon
5750af83d8 fix: suppress navigate search type error in usePagination; fix setBillingUnit cast
Some checks failed
Build & Release / build (push) Failing after 35s
- use-pagination.ts: ts-expect-error on navigate call — search type resolves as never without route context, safe with strict:false
- $enrollmentId.tsx: wrap onValueChange to cast string to billingUnit union type

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:40:23 -05:00
Ryan Moon
505f8fd4e4 fix: correct TanStack Router search types for all navigate/Link calls
Some checks failed
Build & Release / build (push) Failing after 33s
Each destination route's search must match its validateSearch shape exactly:
- Detail pages (tab-based): { tab: '...' }
- List pages with extra filters: include status, instructorId, view, categoryId etc.
- Form pages (enrollments/new, repairs/new): include only their specific fields
- use-pagination.ts: fix search reducer to use (prev: any) instead of invalid cast

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:37:34 -05:00
Ryan Moon
a84530e80e fix: replace invalid TanStack Router search casts with typed defaults
Some checks failed
Build & Release / build (push) Failing after 32s
Newer TanStack Router enforces strict types on search params — 'search: {} as Record<string, unknown>' no longer satisfies routes with validateSearch. Replace all occurrences with the correct search shape for each destination route (pagination defaults for list routes, tab/field defaults for detail routes).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:33:36 -05:00
Ryan Moon
b8e39369f1 feat: add app settings table, encryption utility, and generic email service
Some checks failed
Build & Release / build (push) Failing after 35s
- app_settings table with encrypted field support (AES-256-GCM, key from ENCRYPTION_KEY env)
- SettingsService for transparent encrypt/decrypt on get/set
- EmailService factory with Resend and SendGrid providers (SMTP stub) — provider config lives in app_settings
- Seeds initial admin user and email settings from env vars on first startup if not already present
- Migration 0039_app_settings.sql

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 10:27:20 -05:00
ryan
81d37a2c68 fix: add lsof and iproute2 to devpod base image
Some checks failed
Build Devpod / build (push) Successful in 2m30s
Build & Release / build (push) Failing after 31s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:45:34 +00:00
ryan
70a924cfba fix: add tini init to devpod to reap zombie bun processes
Some checks failed
Build & Release / build (push) Has been cancelled
Build Devpod / build (push) Has been cancelled
bun --watch spawns new processes on file changes but code-server (PID 1)
doesn't reap orphans, causing zombie accumulation and port conflicts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 23:43:12 +00:00
2b9e99bbd6 Merge pull request 'feat: POS register screen with touch-optimized layout' (#6) from feature/pos-register into main
Some checks failed
Build & Release / build (push) Failing after 34s
Reviewed-on: #6
2026-04-04 20:14:21 +00:00
ryan
a0be16d848 fix: resolve all frontend lint errors and warnings
All checks were successful
CI / ci (pull_request) Successful in 21s
CI / e2e (pull_request) Successful in 59s
Replace all `any` types with proper types across 36 files:
- TanStack Router search params: `{} as Record<string, unknown>`
- API response pagination: proper typed interface
- DataTable column casts: remove unnecessary `as any`
- Function params and event handlers: use specific types
- Remove unused imports and variables in POS components

Frontend lint now passes with 0 errors and 0 warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 20:12:17 +00:00
ryan
1673e18fe8 fix: require open drawer to complete transactions, fix product price field
Some checks failed
CI / ci (pull_request) Failing after 20s
CI / e2e (pull_request) Has been skipped
- Backend enforces open drawer at location before completing any transaction
- Frontend disables payment buttons when drawer is closed with warning message
- Fix product price field name (price, not sellingPrice) in POS API types
- Fix seed UUIDs to use valid UUID v4 format (version nibble must be 1-8)
- Fix Vite allowedHosts for dev.lunarfront.tech access
- Add e2e test for drawer enforcement (39 POS tests now pass)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:54:07 +00:00
ryan
bd3a25aa1c feat: add POS register screen with full-screen touch-optimized layout
Standalone register at /pos bypassing the admin sidebar layout:
- Two-panel layout: product search/grid (60%) + cart/payment (40%)
- Product search with barcode scan support (UPC lookup on Enter)
- Custom item entry dialog for ad-hoc items
- Cart with line items, tax, totals, and remove-item support
- Payment dialogs: cash (quick amounts + change calc), card, check
- Drawer open/close with balance reconciliation and over/short
- Auto-creates pending transaction on first item added
- POS link added to admin sidebar nav (module-gated)
- Zustand store for POS session state, React Query for server data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:29:37 +00:00
bd5f0ca511 Merge pull request 'feat: add app_config table with runtime log level control and POS structured logging' (#5) from feature/structured-logging into main
All checks were successful
Build & Release / build (push) Successful in 58s
Reviewed-on: #5
2026-04-04 19:08:48 +00:00
ryan
aa5b53920d feat: add app configuration UI to settings page
All checks were successful
CI / ci (pull_request) Successful in 17s
CI / e2e (pull_request) Successful in 47s
Log level dropdown on the settings page lets admins change the application
log verbosity at runtime without restarting. Uses the new /v1/config API
with the existing settings.view/settings.edit permissions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:04:31 +00:00
ryan
772d5578ad feat: add app_config table with runtime log level control and POS structured logging
All checks were successful
CI / ci (pull_request) Successful in 20s
CI / e2e (pull_request) Successful in 56s
- New app_config key-value table for system settings, with in-memory cache (mirrors ModuleService pattern)
- GET/PATCH /v1/config endpoints for reading and updating config (settings.view/settings.edit permissions)
- Runtime log level: PATCH /v1/config/log_level applies immediately, persists across restarts
- Startup loads log level from DB in onReady hook (env var is default, DB overrides)
- Add structured request.log.info() to POS routes: transaction create/complete/void, drawer open/close, discount create/update/delete

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:56:21 +00:00
51e7902ee2 Merge pull request 'feature/pos-core' (#4) from feature/pos-core into main
All checks were successful
Build & Release / build (push) Successful in 57s
Reviewed-on: #4
2026-04-04 18:30:16 +00:00
ryan
8256380cd1 feat: add cash rounding, POS test suite, and fix test harness port cleanup
All checks were successful
CI / ci (pull_request) Successful in 20s
CI / e2e (pull_request) Successful in 50s
- Add Swedish rounding (nearest nickel) for cash payments at locations with cash_rounding enabled
- Add rounding_adjustment column to transactions, cash_rounding to locations
- Add POS schema to database plugin for relational query support
- Complete/void routes now return full transaction with line items via getById
- Test harness killPort falls back to fuser when lsof unavailable (fixes stale process bug)
- Add 35-test POS API suite covering discounts, drawer, transactions, tax, rounding, e2e flow
- Add unit tests for tax service and POS Zod schemas

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:23:05 +00:00
Ryan Moon
199b9ab3b3 fix: install bun then move binary to /usr/local/bin
All checks were successful
Build Devpod / build (push) Successful in 2m10s
Build & Release / build (push) Successful in 17s
2026-04-04 11:38:06 -05:00
Ryan Moon
2b141d45f3 fix: bootstrap Claude Code on first boot since it installs to /root (PVC)
Some checks failed
Build Devpod / build (push) Failing after 6s
Build & Release / build (push) Successful in 16s
2026-04-04 11:31:51 -05:00
Ryan Moon
1c56023491 fix: install bun to /usr/local/bin so it persists when /root is PVC-mounted
Some checks failed
Build Devpod / build (push) Failing after 50s
Build & Release / build (push) Has been cancelled
2026-04-04 11:30:54 -05:00
ryan
7b15f18e59 feat: add core POS module — transactions, discounts, drawer, tax
Phase 3a backend API for point-of-sale. Includes:

Schema (packages/backend/src/db/schema/pos.ts):
- pgEnums: transaction_type, transaction_status, payment_method,
  discount_type, discount_applies_to, drawer_status
- Tables: transaction, transaction_line_item, discount,
  discount_audit, drawer_session
- Transaction links to accounts, repair_tickets, repair_batches
- Line items link to products and inventory_units

Tax system:
- tax_rate + service_tax_rate columns on location
- tax_category enum (goods/service/exempt) on product
- Tax resolves per line item: goods→tax_rate, service→service_tax_rate,
  exempt→0. Repair line items map: part→goods, labor→service
- GET /tax/lookup/:zip stubbed for future API integration (TAX_API_KEY)

Services (export const pattern, matching existing codebase):
- TransactionService: create, addLineItem, removeLineItem, applyDiscount,
  recalculateTotals, complete (decrements inventory), void, getReceipt
- DiscountService: CRUD + listAll for dropdowns
- DrawerService: open/close with expected balance + over/short calc
- TaxService: getRateForLocation (by tax category), calculateTax

Routes:
- POST/GET /transactions, GET /transactions/:id, GET /transactions/:id/receipt
- POST /transactions/:id/line-items, DELETE /transactions/:id/line-items/:id
- POST /transactions/:id/discounts, /complete, /void
- POST /drawer/open, POST /drawer/:id/close, GET /drawer/current, GET /drawer
- CRUD /discounts + GET /discounts/all
- GET /products/lookup/upc/:upc (barcode scanner support)

All routes gated by pos.view/pos.edit/pos.admin + withModule('pos').
POS module already seeded in migration 0026.

Still needed: bun install, drizzle-kit generate + migrate, tests, lint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 16:26:38 +00:00
Ryan Moon
93450a1eb7 feat: add nano, vim, htop, zip, netcat, dnsutils, ping to devpod
All checks were successful
Build Devpod / build (push) Successful in 3m9s
Build & Release / build (push) Successful in 16s
2026-04-04 10:47:20 -05:00
Ryan Moon
7aa81c4e7c docs: add infrastructure, build pipeline, and dev box sections to CLAUDE.md
All checks were successful
Build & Release / build (push) Successful in 15s
2026-04-04 10:30:40 -05:00
Ryan Moon
b318345fdf fix: use haproxy to strip PROXY protocol before sshd — nginx sends PROXY headers on all TCP
All checks were successful
Build Devpod / build (push) Successful in 3m0s
Build & Release / build (push) Successful in 17s
2026-04-04 10:04:42 -05:00
Ryan Moon
cc5ab41da4 fix: enable PermitRootLogin for SSH key access
All checks were successful
Build Devpod / build (push) Successful in 8s
Build & Release / build (push) Successful in 16s
2026-04-04 09:49:29 -05:00
Ryan Moon
c54069ad99 fix: switch code-server to no-auth, Cloudflare Access handles authentication
All checks were successful
Build Devpod / build (push) Successful in 9s
Build & Release / build (push) Successful in 16s
2026-04-04 09:10:48 -05:00
Ryan Moon
04420dbd12 fix: push versioned devpod tag per build to avoid DOCR tag caching
All checks were successful
Build & Release / build (push) Successful in 18s
2026-04-04 09:04:55 -05:00
Ryan Moon
f538c60f3d fix: bootstrap .profile and .gitconfig on fresh PVC
All checks were successful
Build Devpod / build (push) Successful in 8s
Build & Release / build (push) Successful in 16s
2026-04-04 08:56:20 -05:00
Ryan Moon
1524153cfb fix: bootstrap .bashrc and PATH on fresh PVC mount
All checks were successful
Build Devpod / build (push) Successful in 7s
Build & Release / build (push) Successful in 15s
2026-04-04 08:55:26 -05:00
Ryan Moon
4c31357428 fix: create .ssh dir before writing authorized_keys
All checks were successful
Build Devpod / build (push) Successful in 9s
Build & Release / build (push) Successful in 16s
2026-04-04 08:54:52 -05:00
Ryan Moon
b3e67d1483 fix: set workdir and code-server root to /root for persistent home
All checks were successful
Build Devpod / build (push) Successful in 19s
Build & Release / build (push) Successful in 16s
2026-04-04 08:43:36 -05:00
Ryan Moon
3d081ee01f fix: push devpod image to manager repo as devpod-latest tag
All checks were successful
Build & Release / build (push) Successful in 18s
2026-04-04 08:36:08 -05:00
Ryan Moon
9942a5638f feat: add psql, redis-cli, helm, k9s to devpod image
Some checks failed
Build & Release / build (push) Failing after 8s
Build Devpod / build (push) Failing after 6s
2026-04-04 07:14:12 -05:00
Ryan Moon
b40bab0391 feat: add devpod image — code-server, Claude Code, bun, kubectl
Some checks failed
Build & Release / build (push) Successful in 22s
Build Devpod / build (push) Failing after 5s
2026-04-04 06:56:56 -05:00
Ryan Moon
de70fb47f9 fix: switch from compiled bun binary to bun run to fix Fastify plugin name crash
All checks were successful
Build & Release / build (push) Successful in 1m11s
2026-04-03 21:52:50 -05:00
Ryan Moon
c5d618698b fix: remove untagged manifest cleanup, deletes tagged chart by shared digest
All checks were successful
Build & Release / build (push) Successful in 26s
2026-04-03 21:46:41 -05:00
Ryan Moon
54943ea1b7 fix: clean up untagged helm OCI manifests after chart push
All checks were successful
Build & Release / build (push) Successful in 30s
2026-04-03 21:40:21 -05:00
Ryan Moon
ba1c385937 fix: simplify CI to use run number for versioning, remove commit-back step
All checks were successful
Build & Release / build (push) Successful in 28s
2026-04-03 21:36:23 -05:00
lunarfront-bot
2231f06234 chore: bump version to v0.1.1
All checks were successful
Build & Release / build (push) Has been skipped
2026-04-04 02:31:04 +00:00
Ryan Moon
fe29e548fb revert: remove incorrect /api ingress rule, frontend nginx handles /v1 proxy
Some checks failed
Build & Release / build (push) Has been cancelled
2026-04-03 21:29:19 -05:00
Ryan Moon
3d72f04b14 fix: add /api ingress path routing to backend 2026-04-03 21:29:19 -05:00
lunarfront-bot
36eb377583 chore: bump version to v0.1.0
All checks were successful
Build & Release / build (push) Has been skipped
2026-04-04 01:08:32 +00:00
Ryan Moon
b8f0c7ecba feat: add Spaces env vars to backend deployment
All checks were successful
Build & Release / build (push) Successful in 18s
2026-04-03 20:07:32 -05:00
lunarfront-bot
0034e0b8b3 chore: bump version to v0.0.29
All checks were successful
Build & Release / build (push) Has been skipped
2026-04-04 00:49:46 +00:00
Ryan Moon
d9a7409f9c chore: remove valkey chart templates
Some checks failed
Build & Release / build (push) Has been cancelled
2026-04-03 19:48:48 -05:00
Ryan Moon
358e07b1d5 feat: remove per-customer valkey, use managed Valkey with REDIS_KEY_PREFIX 2026-04-03 19:48:48 -05:00
lunarfront-bot
5df914a40f chore: bump version to v0.0.28
All checks were successful
Build & Release / build (push) Has been skipped
2026-04-04 00:47:43 +00:00
Ryan Moon
1f8002629f fix: add libstdc++ to runtime image for bun compiled binary
All checks were successful
Build & Release / build (push) Successful in 1m52s
2026-04-03 19:45:48 -05:00
lunarfront-bot
ff2e4586f3 chore: bump version to v0.0.27
All checks were successful
Build & Release / build (push) Has been skipped
2026-04-04 00:36:04 +00:00
Ryan Moon
019867f1fa fix: update GIT_REMOTE to git.lunarfront.tech
Some checks failed
Build & Release / build (push) Has been cancelled
2026-04-03 19:35:45 -05:00
Ryan Moon
48d49a068a fix: include chart files in version bump commit to prevent rebase conflict
Some checks failed
Build & Release / build (push) Failing after 16s
2026-04-03 19:34:31 -05:00
11f81cfd8e Merge pull request 'feat: add Helm chart and switch image builds to DOCR' (#3) from fix/ci-only-on-pr into main
Some checks failed
Build & Release / build (push) Failing after 16s
Reviewed-on: #3
2026-04-03 23:56:55 +00:00
Ryan Moon
1601e0f849 feat: add Helm chart and switch image builds to DOCR
All checks were successful
CI / ci (pull_request) Successful in 18s
CI / e2e (pull_request) Successful in 1m1s
- Add chart/ with backend, frontend, valkey deployments, services, and ingress
- Update nginx.conf to use BACKEND_URL env var via envsubst
- Update Dockerfile.frontend to use nginx template mechanism
- Build.yml: switch Docker registry from Gitea to DOCR, add helm package+push step
2026-04-03 18:53:47 -05:00
lunarfront-bot
0e3a8d7504 chore: bump version to v0.0.26
All checks were successful
Build & Release / build (push) Has been skipped
2026-04-03 01:36:38 +00:00
Ryan Moon
87456e3aac fix: add DOCKER_HOST to build job env
Some checks failed
Build & Release / build (push) Has been cancelled
2026-04-02 20:28:27 -05:00
d7249088e9 Merge pull request 'fix: use REGISTRY and GIT_REMOTE vars, point to git2.lunarfront.tech' (#2) from fix/ci-only-on-pr into main
Some checks failed
Build & Release / build (push) Failing after 8s
Reviewed-on: https://git2.lunarfront.tech/ryan/lunarfront-app/pulls/2
2026-04-03 01:27:02 +00:00
Ryan Moon
59ea9557d1 fix: use REGISTRY and GIT_REMOTE vars, point to git2.lunarfront.tech
All checks were successful
CI / ci (pull_request) Successful in 1m27s
CI / e2e (pull_request) Successful in 1m18s
2026-04-02 19:50:53 -05:00
628c090dfd Merge pull request 'fix/ci-only-on-pr' (#1) from fix/ci-only-on-pr into main
Some checks failed
Build & Release / build (push) Failing after 7s
Reviewed-on: https://git2.lunarfront.tech/ryan/lunarfront-app/pulls/1
2026-04-03 00:47:43 +00:00
Ryan Moon
9fc42b7881 fix: set DOCKER_HOST for e2e job to use dind TCP endpoint
All checks were successful
CI / ci (pull_request) Successful in 29s
CI / e2e (pull_request) Successful in 58s
2026-04-02 19:09:30 -05:00
Ryan Moon
ce3ac3dfc0 fix: allow backend package.json through frontend dockerignore for workspace resolution 2026-04-02 07:01:21 -05:00
Ryan Moon
75bc10fe3c fix: add concurrency group to prevent build runs from cancelling each other 2026-04-02 06:53:24 -05:00
Ryan Moon
d821302439 fix: copy backend package.json in frontend Dockerfile for workspace resolution 2026-04-01 22:16:23 -05:00
Ryan Moon
8eb116a9a1 fix: move Docker builds before version bump commit to prevent self-cancellation 2026-04-01 22:14:10 -05:00
Ryan Moon
038ea22068 fix: exclude admin src from Docker context but keep package.json for workspace resolution 2026-04-01 22:10:54 -05:00
Ryan Moon
c236059ce1 fix: use --filter to install only backend workspace, avoiding missing admin package.json 2026-04-01 22:10:14 -05:00
Ryan Moon
67f8881b3c fix: copy admin package.json in backend Dockerfile for workspace resolution 2026-04-01 22:04:29 -05:00
Ryan Moon
1df4bb15a8 fix: remove redundant Docker CLI install — catthehacker image includes it 2026-04-01 21:55:58 -05:00
Ryan Moon
ddabcf19d1 fix: rebase before pushing version bump to avoid race with CI 2026-04-01 21:52:37 -05:00
Ryan Moon
9c8ceba461 fix: only run CI on pull requests, not on push to main 2026-04-01 21:47:20 -05:00
lunarfront-bot
384f985a77 chore: bump version to v0.0.25
Some checks failed
Build & Release / build (push) Has been skipped
CI / ci (push) Successful in 1m37s
CI / e2e (push) Failing after 5s
2026-04-02 02:45:21 +00:00
ryan
5b56a2c219 Merge pull request 'feat/ci-cd-pipeline' (#5) from feat/ci-cd-pipeline into main
Reviewed-on: #5
2026-04-02 02:45:05 +00:00
Ryan Moon
4ef7f1977c fix: start postgres and valkey via docker run in e2e to avoid service networking issues 2026-04-01 21:25:30 -05:00
Ryan Moon
bc2f39c208 fix: revert service hostnames to localhost for host network mode 2026-04-01 21:15:52 -05:00
Ryan Moon
41037af4f6 fix: use service hostnames for e2e postgres and valkey connections 2026-04-01 21:13:04 -05:00
Ryan Moon
77e155b8c3 feat: add e2e api-test job to CI 2026-04-01 21:09:40 -05:00
Ryan Moon
c01d19215d fix: skip test failure when no test files exist in backend 2026-04-01 21:08:01 -05:00
Ryan Moon
744256ae9f fix: pass with no tests in backend until unit tests are added 2026-04-01 21:06:15 -05:00
Ryan Moon
5993f8b370 fix: remove unused postgres/valkey services from CI — tests are pure unit tests 2026-04-01 21:01:33 -05:00
Ryan Moon
4c971f90eb fix: run CI on host runner to fix service container networking 2026-04-01 21:00:15 -05:00
Ryan Moon
05f926c0dc fix: remove unused imports and dead code to clear ESLint errors 2026-04-01 20:34:56 -05:00
Ryan Moon
a73c2de26e feat: add frontend nginx image and update build workflow for both images 2026-04-01 20:32:34 -05:00
Ryan Moon
0f8aff9426 fix: resolve ESLint errors — remove unused imports and dead code 2026-04-01 20:18:13 -05:00
lunarfront-bot
97638b888e chore: bump version to v0.0.24 2026-04-02 01:07:20 +00:00
ryan
6852a79f87 Merge pull request 'fix: skip build workflow on version bump commits' (#3) from feat/ci-cd-pipeline into main
Reviewed-on: #3
2026-04-02 01:06:44 +00:00
lunarfront-bot
a561b184e1 chore: bump version to v0.0.23 2026-04-02 01:06:36 +00:00
lunarfront-bot
7864c07be1 chore: bump version to v0.0.22 2026-04-02 01:06:10 +00:00
Ryan Moon
c3de66e554 fix: skip build workflow on version bump commits 2026-04-01 20:05:50 -05:00
lunarfront-bot
1e38d69b21 chore: bump version to v0.0.21 2026-04-02 01:05:45 +00:00
lunarfront-bot
eb9e669233 chore: bump version to v0.0.20 2026-04-02 01:05:20 +00:00
lunarfront-bot
13db5ce5f1 chore: bump version to v0.0.19 2026-04-02 01:04:52 +00:00
lunarfront-bot
babfccaa1b chore: bump version to v0.0.18 2026-04-02 01:04:24 +00:00
lunarfront-bot
1aa29dfb31 chore: bump version to v0.0.17 2026-04-02 01:04:02 +00:00
lunarfront-bot
efb55bc784 chore: bump version to v0.0.16 2026-04-02 01:03:40 +00:00
lunarfront-bot
9cdb2cf427 chore: bump version to v0.0.15 2026-04-02 01:03:19 +00:00
lunarfront-bot
135b88029a chore: bump version to v0.0.14 2026-04-02 01:02:59 +00:00
lunarfront-bot
23df7feaf1 chore: bump version to v0.0.13 2026-04-02 01:02:38 +00:00
lunarfront-bot
2e2832b1e3 chore: bump version to v0.0.12 2026-04-02 01:02:24 +00:00
lunarfront-bot
dd846bc86a chore: bump version to v0.0.11 2026-04-02 01:02:11 +00:00
lunarfront-bot
25e9177554 chore: bump version to v0.0.10 2026-04-02 01:01:51 +00:00
lunarfront-bot
cfd1561de9 chore: bump version to v0.0.9 2026-04-02 01:01:29 +00:00
lunarfront-bot
6304d14e56 chore: bump version to v0.0.8 2026-04-02 01:01:07 +00:00
lunarfront-bot
e4fe42c6ec chore: bump version to v0.0.7 2026-04-02 01:00:42 +00:00
lunarfront-bot
27a9900787 chore: bump version to v0.0.6 2026-04-02 01:00:15 +00:00
lunarfront-bot
90cbff0611 chore: bump version to v0.0.5 2026-04-02 00:59:46 +00:00
lunarfront-bot
ddae05dc3f chore: bump version to v0.0.4 2026-04-02 00:59:22 +00:00
lunarfront-bot
12fa36a7b0 chore: bump version to v0.0.3 2026-04-02 00:59:00 +00:00
lunarfront-bot
fc7d92e33f chore: bump version to v0.0.2 2026-04-02 00:58:35 +00:00
ryan
8f941381f9 Merge pull request 'fix: use node script for version bump instead of npm version' (#2) from feat/ci-cd-pipeline into main
Reviewed-on: #2
2026-04-02 00:58:19 +00:00
Ryan Moon
7987818ae7 fix: use node script for version bump instead of npm version 2026-04-01 19:57:09 -05:00
ryan
83b48cb3be Merge pull request 'feat: add CI/CD pipeline, production Dockerfile, and deployment architecture' (#1) from feat/ci-cd-pipeline into main
Reviewed-on: #1
2026-04-02 00:52:02 +00:00
Ryan Moon
c2b1073fef feat: add CI/CD pipeline, production Dockerfile, and deployment architecture
- Add production Dockerfile with bun build --compile, multi-stage Alpine build
- Add .dockerignore
- Swap bcrypt -> bcryptjs (pure JS, no native addons)
- Add programmatic migrations on startup via drizzle migrator
- Add /v1/version endpoint with APP_VERSION baked in at build time
- Add .gitea/workflows/ci.yml (lint + test with postgres/valkey services)
- Add .gitea/workflows/build.yml (version bump, build, push to registry)
- Update CLAUDE.md and docs/architecture.md to remove multi-tenancy
- Add docs/deployment.md covering DOKS + ArgoCD architecture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:50:37 -05:00
Ryan Moon
ffef4c8727 Remove infra folder — moved to lunarfront-infra repo 2026-03-31 08:11:37 -05:00
Ryan Moon
d18d431bd0 Add terraform lock file 2026-03-31 06:12:44 -05:00
Ryan Moon
41b6f076cb Remove terraform provider binaries from git, add .gitignore 2026-03-31 06:12:25 -05:00
Ryan Moon
fe3c7646d6 Add infra setup: Terraform for DO droplet + Cloudflare DNS, Ansible roles for Gitea, Vaultwarden, and Gitea runner 2026-03-31 06:08:21 -05:00
Ryan Moon
bde3ad64fd Fix code review items: atomic qty increment, unit updatedAt, suppliers/all endpoint, SKU unique index 2026-03-31 05:08:01 -05:00
Ryan Moon
5f5ba9e4a2 Build inventory frontend and stock management features
- Full inventory UI: product list with search/filter, product detail with
  tabs (details, units, suppliers, stock receipts, price history)
- Product filters: category, type (serialized/rental/repair), low stock,
  active/inactive — all server-side with URL-synced state
- Product-supplier junction: link products to multiple suppliers with
  preferred flag, joined supplier details in UI
- Stock receipts: record incoming stock with supplier, qty, cost per unit,
  invoice number; auto-increments qty_on_hand for non-serialized products
- Price history tab on product detail page
- categories/all endpoint to avoid pagination limit on dropdown fetches
- categoryId filter on product list endpoint
- Repair parts and additional inventory items in music store seed data
- isDualUseRepair corrected: instruments set to false, strings/parts true
- Product-supplier links and stock receipts in seed data
- Price history seed data simulating cost increases over past year
- 37 API tests covering categories, suppliers, products, units,
  product-suppliers, and stock receipts
- alert-dialog and checkbox UI components
- sync-and-deploy.sh script for rsync + remote deploy
2026-03-30 20:12:07 -05:00
Ryan Moon
ec09e319ed Update accounting planning doc v2 — fix AP/safe accounts, add periods, tax rates, gift cards, consignment, posting service 2026-03-30 19:41:23 -05:00
Ryan Moon
89b412374a Expand POS planning — discounts, returns, cash management, training mode, customer display, quick keys 2026-03-30 19:15:13 -05:00
Ryan Moon
07f199b69d Add PIN unlock flow to POS frontend planning 2026-03-30 19:12:17 -05:00
Ryan Moon
ae3c85fee0 Add frontend strategy planning doc — admin, POS, floor app 2026-03-30 19:08:16 -05:00
Ryan Moon
5ad27bc196 Add lessons module, rate cycles, EC2 deploy scripts, and help content
- Lessons module: lesson types, instructors, schedule slots, enrollments,
  sessions (list + week grid view), lesson plans, grading scales, templates
- Rate cycles: replace monthly_rate with billing_interval + billing_unit on
  enrollments; add weekly/monthly/quarterly rate presets to lesson types and
  schedule slots with auto-fill on enrollment form
- Member detail page: tabbed layout for details, identity documents, enrollments
- Sessions week view: custom 7-column grid replacing react-big-calendar
- Music store seed: instructors, lesson types, slots, enrollments, sessions,
  grading scale, lesson plan template
- Scrollbar styling: themed to match sidebar/app palette
- deploy/: EC2 setup and redeploy scripts, nginx config, systemd service
- Help: add Lessons category (overview, types, instructors, slots, enrollments,
  sessions, plans/grading); collapsible sidebar with independent scroll;
  remove POS/accounting references from docs
2026-03-30 18:52:57 -05:00
Ryan Moon
7680a73d88 Add Phase 8: lesson plan templates with deep-copy instantiation
- New tables: lesson_plan_template, lesson_plan_template_section, lesson_plan_template_item
- skill_level enum: beginner, intermediate, advanced, all_levels
- Templates are reusable curriculum definitions independent of any member/enrollment
- POST /lesson-plan-templates/:id/create-plan deep-copies the template into a member plan
- Instantiation uses template name as default plan title, accepts custom title override
- Instantiation deactivates any existing active plan on the enrollment (one-active rule)
- Plan items are independent copies — renaming the template does not affect existing plans
- 11 new integration tests
2026-03-30 10:37:30 -05:00
Ryan Moon
2cc8f24535 Add Phase 7: grade history and session-plan item linking
- New tables: lesson_plan_item_grade_history (append-only), lesson_session_plan_item
- Grading an item updates current_grade_value and creates immutable history record
- Grading a not_started item auto-transitions it to in_progress
- Linking items to a session also auto-transitions not_started items
- Link operation is idempotent — re-linking same items produces no duplicates
- Endpoints: POST/GET /lesson-plan-items/:id/grades, GET /lesson-plan-items/:id/grade-history
- Endpoints: POST/GET /lesson-sessions/:id/plan-items
- 8 new integration tests
2026-03-30 10:33:21 -05:00
Ryan Moon
5cd2d05983 Add Phase 4b: instructor blocked dates, store closures, and substitute instructors
- New tables: instructor_blocked_date, store_closure (migration 0034)
- substitute_instructor_id column added to lesson_session
- Session generation skips blocked instructor dates and store closure periods
- Substitute assignment validates sub is not blocked and has no conflicting slot
- Routes: POST/GET/DELETE /instructors/:id/blocked-dates, POST/GET/DELETE /store-closures
- 15 new integration tests covering blocked dates, store closures, and sub validation
2026-03-30 10:29:13 -05:00
Ryan Moon
aae5a022a8 Add lessons Phase 6: lesson plans with curriculum tracking
Structured lesson plans with nested sections and items per enrollment.
Deep create in one request, one-active-per-enrollment constraint,
auto-set startedDate/masteredDate on status transitions, progress %
calculation (skipped items excluded). 8 new tests (84 total).
2026-03-30 09:40:41 -05:00
Ryan Moon
31f661ff4f Add lessons Phase 5: grading scales with nested levels
Custom grading scales with ordered levels (value, label, numeric score,
color). Supports one-default-per-store constraint, deep create with
nested levels, lookup endpoint for dropdowns, and search/pagination.
12 new tests (76 total lessons tests).
2026-03-30 09:36:48 -05:00
Ryan Moon
73360cd478 Add lessons Phase 4: lesson sessions with hybrid calendar generation
Individual lesson occurrences generated from schedule slot patterns.
Idempotent session generation with configurable rolling window.
Post-lesson notes workflow with auto-set notesCompletedAt. Status
tracking (scheduled/attended/missed/makeup/cancelled) and date/time
filtering. 13 new tests (64 total lessons tests).
2026-03-30 09:29:03 -05:00
Ryan Moon
93405af3b2 Add lessons Phase 3: enrollments with capacity and time conflict checks
Links members to schedule slots via enrollments. Enforces max_students
capacity on slots and prevents members from double-booking the same
day/time. Supports status transitions and filtering. 11 new tests
(51 total lessons tests).
2026-03-30 09:23:43 -05:00
Ryan Moon
f777ce5184 Add lessons Phase 2: schedule slots with conflict detection
Recurring weekly time slots linking instructors to lesson types.
Includes day/time overlap detection, instructor and day-of-week
filtering, and 17 new integration tests (40 total lessons tests).
2026-03-30 09:20:03 -05:00
Ryan Moon
5dbe837c08 Add lessons domain Phase 1: instructor and lesson type entities
Foundation tables for the lessons module with full CRUD, pagination,
search, and sorting. Includes migration, Drizzle schema, Zod validation,
services, routes, and 23 integration tests.
2026-03-30 09:17:32 -05:00
Ryan Moon
145eb0efce Improve sidebar: collapsible, grouped sections, scrollable nav, pinned footer
- Sidebar collapses to icon-only mode with toggle button
- Nav items grouped into sections (Customers, Repairs, Storage, Admin)
- Each section is independently collapsible
- Middle nav area scrolls when items overflow
- Help, profile, and sign out pinned to bottom
2026-03-30 08:52:23 -05:00
Ryan Moon
7176c1471e Add music store seed preset and repair data reset script
- music-store-seed.ts: 52 templates covering strings, brass, woodwinds,
  guitar, plus music-specific tickets and a school band batch
- reset-repairs.ts: clears all repair data for switching between presets
- New scripts: bun run db:seed-music, bun run db:seed-reset-repairs
2026-03-30 08:52:09 -05:00
Ryan Moon
701e15ea6d Remove DB-dependent unit tests, use api-tests for integration testing
All tests in __tests__/ were hitting the database directly. Integration
testing is handled by the api-tests/ suite instead.
2026-03-30 08:52:01 -05:00
Ryan Moon
9400828f62 Rename Forte to LunarFront, generalize for any small business
Rebrand from Forte (music-store-specific) to LunarFront (any small business):
- Package namespace @forte/* → @lunarfront/*
- Database forte/forte_test → lunarfront/lunarfront_test
- Docker containers, volumes, connection strings
- UI branding, localStorage keys, test emails
- All documentation and planning docs

Generalize music-specific terminology:
- instrumentDescription → itemDescription
- instrumentCount → itemCount
- instrumentType → itemCategory (on service templates)
- New migration 0027_generalize_terminology for column renames
- Seed data updated with generic examples
- RBAC descriptions updated
2026-03-30 08:51:54 -05:00
Ryan Moon
535446696c Include accessLevel in vault categories list response 2026-03-30 07:36:23 -05:00
Ryan Moon
a51f1f5141 Add vault category permissions dialog with role/user management 2026-03-30 07:23:23 -05:00
Ryan Moon
4438188362 Fix vault dialog autofill: prevent browser password manager from overriding dark theme colors 2026-03-30 07:18:58 -05:00
Ryan Moon
1510133074 Seed default roles on startup alongside permissions 2026-03-30 07:16:25 -05:00
Ryan Moon
328b4a1f7b Fix dev seed for single-company schema, sync RBAC on startup
- Remove all company_id references from dev-seed.ts (removed in 0021)
- seedPermissions now syncs role-permission assignments for system roles
  when new permissions are added (e.g., vault.view assigned to admin)
- Fix enum migration: use text cast workaround for PostgreSQL's
  "unsafe use of new enum value" error on fresh DB creation
2026-03-30 07:10:20 -05:00
Ryan Moon
e346e072b8 Add module management system for enabling/disabling features
Stores can enable/disable feature modules from Settings. When disabled,
nav links are hidden and API routes return 403. Designed as the
foundation for future license-based gating (licensed + enabled flags).

Core modules (Accounts, Members, Users, Roles, Settings) are always on.

- module_config table with slug, name, description, licensed, enabled
- In-memory cache for fast per-request module checks
- requireModule middleware wraps route groups in main.ts
- Settings page Modules card with toggle switches
- Sidebar hides nav links for disabled modules
- Default modules seeded: inventory, pos, repairs, rentals, lessons,
  files, vault, email, reports
2026-03-30 06:52:27 -05:00
Ryan Moon
1f9297f533 Add vault secret manager frontend UI
Three-state page: not initialized → locked → unlocked.
Any user with vault.view can unlock (for store opening).
Admins can lock and change master password.

- Two-panel layout: categories on left, entries on right
- Entry reveal button shows decrypted value for 30s with copy
- Add/edit/delete entries and categories
- KeyRound icon in sidebar navigation
2026-03-30 06:17:58 -05:00
Ryan Moon
7246587955 Add vault secret manager backend with AES-256-GCM encryption
Secrets are encrypted at rest in the database. The derived encryption
key is held in memory only — on reboot, an authorized user must enter
the master password to unlock. Admins can also manually lock the vault.

- vault_config, vault_category, vault_category_permission, vault_entry tables
- AES-256-GCM encryption with PBKDF2-derived key + per-entry IV
- Master password initialize/unlock/lock/change lifecycle
- Category CRUD with role/user permission model (view/edit/admin)
- Entry CRUD with reveal endpoint (POST to avoid caching)
- Secret values never returned in list/detail responses
- vault.view/edit/admin RBAC permissions seeded
- 19 API integration tests covering full lifecycle
2026-03-30 06:11:33 -05:00
Ryan Moon
748ea59c80 Harden storage permissions and WebDAV security
Permission service:
- Add hasAccess() with explicit minLevel param, deprecate canAccess()
- Cycle protection + depth limit (50) on all parent traversal
- Pick highest access level across multiple roles (was using first match)
- isPublic only grants view on directly requested folder, not inherited
- Sanitize file extension from content-type
- Clean up orphaned traverse perms when removing permissions
- Add getPermissionById() for authz checks on permission deletion

Storage routes:
- All write ops require edit via hasAccess() — traverse can no longer
  create folders, upload files, rename, toggle isPublic, or delete
- Permission delete requires admin access on the folder
- Permission list requires admin access on the folder
- Folder children listing filtered by user access
- File search results filtered by user access (was returning all)
- Signed URL requires view (was using canAccess which allows traverse)

WebDAV:
- 100MB upload size limit (was unbounded — OOM risk)
- PROPFIND root filters folders by user access (was listing all)
- COPY uses hasAccess('view') not canAccess (traverse bypass)
- All writes use hasAccess('edit') consistently
- MKCOL at root requires files.delete permission
- Lock ownership enforced on UNLOCK (was allowing any user)
- Lock conflict check on LOCK (423 if locked by another user)
- Lock enforcement on PUT and DELETE (423 if locked by another)
- Max 100 locks per user, periodic expired lock cleanup
- Path traversal protection: reject .. and null bytes in segments
- Brute-force protection: 10 failed attempts per IP, 5min lockout
2026-03-29 18:21:19 -05:00
Ryan Moon
f998b16a3f Add traverse access level for folder navigation without file access
When a permission is set on a nested folder, traverse is automatically
granted on all ancestor folders so users can navigate to it. Traverse
only shows subfolders in listings — files are hidden. This prevents
orphaned permissions where a user has access to a nested folder but
can't reach it.

Hierarchy: traverse < view < edit < admin
2026-03-29 18:04:24 -05:00
Ryan Moon
51ca2ca683 Add folder permissions UI and WebDAV protocol support
Permissions UI:
- FolderPermissionsDialog component with public/private toggle,
  role/user permission management, and access level badges
- Integrated into file manager toolbar (visible for folder admins)
- Backend returns accessLevel in folder detail endpoint

WebDAV server:
- Full WebDAV protocol at /webdav/ with Basic Auth (existing credentials)
- PROPFIND, GET, PUT, DELETE, MKCOL, COPY, MOVE, LOCK/UNLOCK support
- Permission-checked against existing folder access model
- In-memory lock stubs for Windows client compatibility
- 22 API integration tests covering all operations

Also fixes canAccess to check folder creator (was missing).
2026-03-29 17:38:57 -05:00
Ryan Moon
cbbf2713a1 Enlarge logo upload area to 256x128 for better preview 2026-03-29 16:39:36 -05:00
Ryan Moon
1002117610 Show store logo in sidebar with Amplified by Forte branding
Sidebar header now loads the store logo from the files API and
displays it scaled to fit. Below the logo: "Amplified by Forte"
in subtle text. Falls back to store name as text if no logo is
uploaded. Logo fetched via authenticated request with blob URL
and proper cleanup.
2026-03-29 16:32:18 -05:00
Ryan Moon
f9bf1c9bff Add rectangular logo upload to settings, support SVG content type
Settings page now shows a rectangular upload area for the store logo
instead of circular avatar. Uses authenticated image fetching with
blob URL cleanup. Accepts SVG in addition to JPEG/PNG/WebP. SVG
added to file serve content type map. Simplified to single logo
image (used on PDFs, sidebar, and login).
2026-03-29 16:27:02 -05:00
Ryan Moon
8d75586f8b Add store logo and app icon uploads to settings page
AvatarUpload component now supports custom category and placeholder
icon props. Settings page shows two upload circles: Store Logo (for
PDFs/invoices, uses ImageIcon placeholder) and App Icon (for sidebar/
login, uses Building placeholder). Added 'company' to allowed file
entity types.
2026-03-29 16:14:08 -05:00
Ryan Moon
653fff6ce2 Add store settings page with location management
Company table gains address and logo_file_id columns. New store
settings API: GET/PATCH /store for company info, full CRUD for
/locations. Settings page shows store name, phone, email, address,
timezone with inline edit. Location cards with add/edit/delete.
Settings link in admin sidebar. Fixes leftover company_id on
location table and seed files.
2026-03-29 15:56:02 -05:00
Ryan Moon
0f6cc104d2 Add shared file storage with folder tree, permissions, and file manager UI
New document hub for centralized file storage — replaces scattered
drives and USB sticks for non-technical SMBs. Three new tables:
storage_folder (nested hierarchy), storage_folder_permission (role
and user-level access control), storage_file.

Backend: folder CRUD with nested paths, file upload/download via
signed URLs, permission checks (view/edit/admin with inheritance
from parent folders), public/private toggle, breadcrumb navigation,
file search.

Frontend: two-panel file manager — collapsible folder tree on left,
icon grid view on right. Folder icons by type, file size display,
upload button, context menu for download/delete. Breadcrumb nav.
Files sidebar link added.
2026-03-29 15:31:20 -05:00
296 changed files with 33561 additions and 4514 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
.git
.gitea
docs
planning
deploy
infra
packages/admin
!packages/admin/package.json
Dockerfile*
docker-compose*
*.md

View File

@@ -1,9 +1,9 @@
# Forte — Environment Variables
# LunarFront — Environment Variables
# Copy to .env and adjust values for your setup.
# Docker Compose overrides host values (postgres, valkey) automatically.
# Database (PostgreSQL 16)
DATABASE_URL=postgresql://forte:forte@localhost:5432/forte
DATABASE_URL=postgresql://lunarfront:lunarfront@localhost:5432/lunarfront
# Valkey / Redis
REDIS_URL=redis://localhost:6379
@@ -20,7 +20,7 @@ NODE_ENV=development
# Logging (optional)
# LOG_LEVEL=info
# LOG_FILE=./logs/forte.log
# LOG_FILE=./logs/lunarfront.log
# File Storage (optional — defaults to local)
# STORAGE_PROVIDER=local

View File

@@ -0,0 +1,34 @@
name: Build Devpod
on:
push:
branches: [main]
paths:
- Dockerfile.devpod
- entrypoint-devpod.sh
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
env:
REGISTRY: registry.digitalocean.com/lunarfront
DOCKER_HOST: tcp://localhost:2375
VERSION: devpod-0.1.${{ github.run_number }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to DOCR
run: echo "${{ secrets.DOCR_TOKEN }}" | docker login registry.digitalocean.com -u token --password-stdin
- name: Build and push devpod
run: |
docker build \
-t $REGISTRY/manager:$VERSION \
-t $REGISTRY/manager:devpod-latest \
-f Dockerfile.devpod .
docker push $REGISTRY/manager:$VERSION
docker push $REGISTRY/manager:devpod-latest

View File

@@ -0,0 +1,66 @@
name: Build & Release
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: build
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
env:
REGISTRY: registry.digitalocean.com/lunarfront
DOCKER_HOST: tcp://localhost:2375
VERSION: 0.1.${{ github.run_number }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to DOCR
run: echo "${{ secrets.DOCR_TOKEN }}" | docker login registry.digitalocean.com -u token --password-stdin
- name: Build and push backend
run: |
SHA=$(git rev-parse --short HEAD)
docker build \
--build-arg APP_VERSION=$VERSION \
-t $REGISTRY/lunarfront-app:$VERSION \
-t $REGISTRY/lunarfront-app:$SHA \
-t $REGISTRY/lunarfront-app:latest \
-f Dockerfile .
docker push $REGISTRY/lunarfront-app:$VERSION
docker push $REGISTRY/lunarfront-app:$SHA
docker push $REGISTRY/lunarfront-app:latest
- name: Build and push frontend
run: |
SHA=$(git rev-parse --short HEAD)
docker build \
-t $REGISTRY/lunarfront-frontend:$VERSION \
-t $REGISTRY/lunarfront-frontend:$SHA \
-t $REGISTRY/lunarfront-frontend:latest \
-f Dockerfile.frontend .
docker push $REGISTRY/lunarfront-frontend:$VERSION
docker push $REGISTRY/lunarfront-frontend:$SHA
docker push $REGISTRY/lunarfront-frontend:latest
- name: Install Helm
run: curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
- name: Package and push Helm chart
run: |
sed -i "s/^version:.*/version: $VERSION/" chart/Chart.yaml
sed -i "s/^appVersion:.*/appVersion: \"$VERSION\"/" chart/Chart.yaml
sed -i "s|tag: .*|tag: $VERSION|g" chart/values.yaml
helm registry login registry.digitalocean.com -u token --password "${{ secrets.DOCR_TOKEN }}"
helm package chart/
helm push lunarfront-$VERSION.tgz oci://registry.digitalocean.com/lunarfront
- name: Logout
if: always()
run: docker logout registry.digitalocean.com

74
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,74 @@
name: CI
on:
pull_request:
branches: [main]
workflow_dispatch:
jobs:
ci:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Bun
run: |
curl -fsSL https://bun.sh/install | bash
echo "$HOME/.bun/bin" >> $GITHUB_PATH
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Generate route tree
working-directory: packages/admin
run: bunx @tanstack/router-cli generate
- name: Lint
run: bun run lint
- name: Test
run: bun run test
e2e:
runs-on: ubuntu-latest
needs: ci
env:
DOCKER_HOST: tcp://localhost:2375
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Start services
run: |
docker run -d --name postgres \
-e POSTGRES_USER=lunarfront \
-e POSTGRES_PASSWORD=lunarfront \
-e POSTGRES_DB=postgres \
-p 5432:5432 \
postgres:16
docker run -d --name valkey \
-p 6379:6379 \
valkey/valkey:8
until docker exec postgres pg_isready -U lunarfront; do sleep 1; done
until docker exec valkey valkey-cli ping; do sleep 1; done
- name: Install Bun
run: |
curl -fsSL https://bun.sh/install | bash
echo "$HOME/.bun/bin" >> $GITHUB_PATH
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run API tests
working-directory: packages/backend
run: bun run api-test
- name: Stop services
if: always()
run: |
docker stop postgres valkey || true
docker rm postgres valkey || true

7
.gitignore vendored
View File

@@ -17,6 +17,13 @@ out/
*.o
*.pyc
__pycache__/
*.tsbuildinfo
# Turbo cache
.turbo/
# Infra (moved to lunarfront-infra repo)
infra/
# IDE
.idea/

133
CLAUDE.md
View File

@@ -1,8 +1,8 @@
# Forte — Project Conventions
# LunarFront — Project Conventions
## App
- **Name:** Forte
- **Purpose:** Music store management platform (POS, inventory, rentals, lessons, repairs, accounting)
- **Name:** LunarFront
- **Purpose:** Small business management platform (POS, inventory, rentals, scheduling, repairs, accounting)
- **Company:** Lunarfront Tech LLC
## Tech Stack
@@ -18,14 +18,13 @@
- **Linting:** ESLint 9 flat config + Prettier
## Package Namespace
- `@forte/shared` — types, Zod schemas, business logic, utils
- `@forte/backend` — Fastify API server
- `@lunarfront/shared` — types, Zod schemas, business logic, utils
- `@lunarfront/backend` — Fastify API server
## Database
- Dev: `forte` on localhost:5432
- Test: `forte_test` on localhost:5432
- Multi-tenant: `company_id` (uuid FK) on all domain tables for tenant isolation
- `location_id` (uuid FK) on tables that need per-location scoping (inventory, transactions, drawer)
- Dev: `lunarfront` on localhost:5432
- Test: `lunarfront_test` on localhost:5432
- Each deployed instance has its own isolated database — no multi-tenancy, no `company_id`
- Migrations via Drizzle Kit (`bunx drizzle-kit generate`, `bunx drizzle-kit migrate`)
## Key Entity Names
@@ -46,7 +45,7 @@
- `?sort=name&order=asc` — sorting by field name, asc or desc
- List responses always return `{ data: [...], pagination: { page, limit, total, totalPages } }`
- Search and filtering is ALWAYS server-side, never client-side
- Use `PaginationSchema` from `@forte/shared/schemas` to parse query params
- Use `PaginationSchema` from `@lunarfront/shared/schemas` to parse query params
- Use pagination helpers from `packages/backend/src/utils/pagination.ts`
- **Lookup endpoints** (e.g., `/roles/all`, `/statuses/all`) are the exception — these return a flat unpaginated list for populating dropdowns/selects. Use a `/all` suffix to distinguish from the paginated list endpoint for the same resource.
@@ -60,7 +59,119 @@
## Conventions
- Shared Zod schemas are the single source of truth for validation (used on both frontend and backend)
- Business logic lives in `@forte/shared`, not in individual app packages
- Business logic lives in `@lunarfront/shared`, not in individual app packages
- API routes are thin — validate with Zod, call a service, return result
- All financial events must be auditable (append-only audit records)
- JSON structured logging with request IDs on every log line
---
## Infrastructure
### Overview
LunarFront runs on DigitalOcean Kubernetes (DOKS). Each customer gets an isolated namespace, database, and Helm release managed by ArgoCD.
### Key Services
- **Cluster:** `lunarfront` DOKS cluster, region NYC
- **Registry:** `registry.digitalocean.com/lunarfront` (DOCR) — stores Docker images and Helm charts
- **Git:** `git.lunarfront.tech` — self-hosted Gitea, source of truth for code and charts
- **CI:** Gitea Actions — builds Docker images and Helm charts on push to `main`
- **CD:** ArgoCD at `argocd.lunarfront.tech` — deploys from `lunarfront-charts` repo
- **Database:** DO Managed PostgreSQL — one database per customer, plus manager DB
- **Cache/Queue:** DO Managed Valkey — shared across all customers (key-prefixed per customer)
- **Ingress:** nginx ingress controller with Cloudflare proxy in front
- **TLS:** cert-manager with Let's Encrypt (letsencrypt-prod cluster issuer)
- **DNS:** Cloudflare — wildcard `*.lunarfront.tech` → cluster LB IP `167.99.21.170`
### Node Pools
- `system` — 2x s-2vcpu-4gb, runs ingress, ArgoCD, manager, pgbouncer
- `customers` — autoscales 0→N, s-4vcpu-8gb, runs customer app pods (tainted `role=customer`)
- `dev` — autoscales 0→1, s-4vcpu-8gb, runs dev pod only (tainted `dedicated=dev:NoSchedule`)
### Repos
- `lunarfront-app` — main application code (this repo)
- `lunarfront-charts` — Helm charts and ArgoCD app definitions
- `lunarfront-infra` — Terraform for DO infrastructure (DOKS, managed DBs, registry, DNS)
- `lunarfront-manager` — internal ops tool for provisioning/deprovisioning customers
---
## Build & Deploy Pipeline
### How it works
1. Push code to `main` on `lunarfront-app`
2. Gitea Actions runs `.gitea/workflows/build.yml`:
- Builds `lunarfront-app` Docker image → pushes as `0.1.{run_number}`, `{sha}`, `latest`
- Builds `lunarfront-frontend` Docker image → same tags
- Packages Helm chart → pushes as `0.1.{run_number}` to DOCR OCI registry
3. ArgoCD image updater detects new image digests → updates customer deployments
4. New customer provisions always get the latest chart version (queried from DOCR at provision time)
5. Existing customers upgraded via `POST /customers/:slug/upgrade` or `POST /customers/upgrade-all` in the manager
### Versioning
- Version format: `0.1.{gitea_run_number}` — always incrementing, no git commit-back needed
- No version stored in git — source of truth is DOCR tags
- Chart version and app version are kept in sync
### Key files
- `Dockerfile` — backend image (bun runtime, runs `packages/backend/src/main.ts` directly)
- `Dockerfile.frontend` — frontend nginx image
- `chart/` — Helm chart for customer app deployments
- `.gitea/workflows/build.yml` — CI pipeline
- `.gitea/workflows/build-devpod.yml` — builds dev box image on Dockerfile.devpod changes
---
## Dev Box
### What it is
A persistent development pod running in the `dev` namespace on the cluster. Provides a full remote dev environment accessible from anywhere.
- **VS Code in browser:** `dev.lunarfront.tech` (Cloudflare Access protected, OTP to email)
- **SSH:** `ssh -p 2222 root@dev-ssh.lunarfront.tech`
- **Storage:** 100GB DO block storage PVC mounted at `/root` — everything in home dir persists
- **Image:** `registry.digitalocean.com/lunarfront/manager:devpod-latest`
- **Tools:** bun, Claude Code CLI, code-server, kubectl, helm, k9s, doctl, psql, redis-cli, git
### Managing the dev pod
```bash
# Scale up (provisions node automatically)
kubectl scale deployment dev -n dev --replicas=1
# Scale down (node auto-terminates after ~15 min)
kubectl scale deployment dev -n dev --replicas=0
```
### Running the app locally on the dev box (no containers)
The dev box runs the app as plain Bun processes, connecting to the same DO managed services as production.
**Required env vars** (create a `.env` file in the repo root or export in `.bashrc`):
```bash
DATABASE_URL=postgresql://... # DO managed postgres, lunarfront database
REDIS_URL=rediss://... # DO managed valkey
JWT_SECRET=... # any random hex string for local dev
PORT=8000
```
**Start the app:**
```bash
cd ~/lunarfront-app
bun run dev
```
Access the running backend at `dev.lunarfront.tech/proxy/8000/` in the browser (code-server proxy), or via SSH port forward:
```bash
ssh -p 2222 -L 8000:localhost:8000 root@dev-ssh.lunarfront.tech
```
**Run migrations against the dev database:**
```bash
bunx drizzle-kit migrate
```
### Workflow
1. Edit code in VS Code at `dev.lunarfront.tech` or via SSH
2. Run and test locally with `bun run dev` — app connects to DO managed postgres/valkey
3. Push to `main` → Gitea Actions builds and pushes new Docker image + Helm chart
4. ArgoCD deploys to the cluster automatically
5. Use manager at `manager.lunarfront.tech` to upgrade customer instances if needed

27
Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM oven/bun:1.3.11-alpine AS deps
WORKDIR /app
COPY package.json bun.lock ./
COPY packages/shared/package.json packages/shared/
COPY packages/backend/package.json packages/backend/
COPY packages/admin/package.json packages/admin/
RUN bun install --frozen-lockfile
FROM oven/bun:1.3.11-alpine
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/packages/backend/node_modules ./packages/backend/node_modules
COPY packages/shared ./packages/shared
COPY packages/backend ./packages/backend
COPY package.json ./
COPY tsconfig.base.json ./
RUN addgroup -S app && adduser -S app -G app
COPY packages/backend/src/db/migrations ./migrations
ENV MIGRATIONS_DIR=/app/migrations
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:8000/v1/health || exit 1
USER app
CMD ["bun", "run", "packages/backend/src/main.ts"]

47
Dockerfile.devpod Normal file
View File

@@ -0,0 +1,47 @@
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
ENV HOME=/root
ENV PATH="/usr/local/bin:/root/.bun/bin:$PATH"
# Base tools
RUN apt-get update && apt-get install -y --no-install-recommends \
curl wget git openssh-server ca-certificates gnupg \
build-essential unzip zip jq tmux zsh ripgrep \
postgresql-client redis-tools haproxy tini \
nano vim htop netcat-openbsd dnsutils iputils-ping lsof iproute2 \
&& rm -rf /var/lib/apt/lists/*
# Bun — install then move to /usr/local/bin so it's on the image filesystem, not the /root PVC
RUN curl -fsSL https://bun.sh/install | bash \
&& mv /root/.bun/bin/bun /usr/local/bin/bun \
&& ln -sf /usr/local/bin/bun /usr/local/bin/bunx \
&& rm -rf /root/.bun
# code-server (VS Code in browser)
RUN curl -fsSL https://code-server.dev/install.sh | sh
# kubectl
RUN curl -fsSL "https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \
-o /usr/local/bin/kubectl && chmod +x /usr/local/bin/kubectl
# doctl
RUN curl -fsSL https://github.com/digitalocean/doctl/releases/download/v1.119.0/doctl-1.119.0-linux-amd64.tar.gz \
| tar xz -C /usr/local/bin
# helm
RUN curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# k9s
RUN curl -fsSL https://github.com/derailed/k9s/releases/latest/download/k9s_Linux_amd64.tar.gz \
| tar xz -C /usr/local/bin k9s
# SSH setup — host keys generated at runtime via entrypoint
RUN mkdir -p /run/sshd /root/.ssh && chmod 700 /root/.ssh
COPY entrypoint-devpod.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
WORKDIR /root
EXPOSE 8080 22
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]

28
Dockerfile.frontend Normal file
View File

@@ -0,0 +1,28 @@
FROM oven/bun:1.3.11-alpine AS deps
WORKDIR /app
COPY package.json bun.lock ./
COPY packages/shared/package.json packages/shared/
COPY packages/admin/package.json packages/admin/
COPY packages/backend/package.json packages/backend/
RUN bun install --frozen-lockfile
FROM oven/bun:1.3.11-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/packages/admin/node_modules ./packages/admin/node_modules
COPY packages/shared ./packages/shared
COPY packages/admin ./packages/admin
COPY package.json ./
COPY tsconfig.base.json ./
WORKDIR /app/packages/admin
RUN bunx @tanstack/router-cli generate
RUN bun run build
FROM nginx:alpine
COPY --from=build /app/packages/admin/dist /usr/share/nginx/html
# nginx docker image processes templates in /etc/nginx/templates/ with envsubst at startup
COPY nginx.conf /etc/nginx/templates/default.conf.template
ENV BACKEND_URL=http://localhost:8000
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,12 @@
node_modules
.git
.gitea
docs
planning
deploy
infra
packages/backend
!packages/backend/package.json
Dockerfile*
docker-compose*
*.md

View File

@@ -1,6 +1,6 @@
# Forte
# LunarFront
Music store management platform — POS, inventory, rentals, lessons, repairs, and accounting.
Small business management platform — POS, inventory, rentals, scheduling, repairs, and accounting.
Built by [Lunarfront Tech LLC](https://lunarfront.com).

183
bun.lock
View File

@@ -3,7 +3,7 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "forte",
"name": "lunarfront",
"dependencies": {
"zod": "^4.3.6",
},
@@ -16,11 +16,11 @@
},
},
"packages/admin": {
"name": "@forte/admin",
"name": "@lunarfront/admin",
"version": "0.0.1",
"dependencies": {
"@forte/shared": "workspace:*",
"@hookform/resolvers": "^5.2.2",
"@lunarfront/shared": "workspace:*",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
@@ -30,12 +30,17 @@
"@radix-ui/react-tabs": "^1.1.12",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-router": "^1.121.0",
"@types/react-big-calendar": "^1.16.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"html2pdf.js": "^0.14.0",
"jsbarcode": "^3.12.3",
"jspdf": "^4.2.1",
"lucide-react": "^1.7.0",
"radix-ui": "^1.4.3",
"react": "^19.1.0",
"react-big-calendar": "^1.19.4",
"react-dom": "^19.1.0",
"react-hook-form": "^7.72.0",
"sonner": "^2.0.3",
@@ -55,15 +60,16 @@
},
},
"packages/backend": {
"name": "@forte/backend",
"version": "0.0.1",
"name": "@lunarfront/backend",
"version": "0.1.1",
"dependencies": {
"@fastify/cors": "^10",
"@fastify/jwt": "^9",
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0",
"@forte/shared": "workspace:*",
"bcrypt": "^6",
"@lunarfront/shared": "workspace:*",
"@types/bcryptjs": "^3.0.0",
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.38",
"fastify": "^5",
"fastify-plugin": "^5",
@@ -72,7 +78,6 @@
"zod": "^4",
},
"devDependencies": {
"@types/bcrypt": "^5",
"@types/node": "^22",
"drizzle-kit": "^0.30",
"pino-pretty": "^13",
@@ -80,7 +85,7 @@
},
},
"packages/shared": {
"name": "@forte/shared",
"name": "@lunarfront/shared",
"version": "0.0.1",
"dependencies": {
"zod": "^4",
@@ -243,12 +248,6 @@
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@forte/admin": ["@forte/admin@workspace:packages/admin"],
"@forte/backend": ["@forte/backend@workspace:packages/backend"],
"@forte/shared": ["@forte/shared@workspace:packages/shared"],
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@@ -273,10 +272,18 @@
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
"@lunarfront/admin": ["@lunarfront/admin@workspace:packages/admin"],
"@lunarfront/backend": ["@lunarfront/backend@workspace:packages/backend"],
"@lunarfront/shared": ["@lunarfront/shared@workspace:packages/shared"],
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
@@ -397,57 +404,59 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@restart/hooks": ["@restart/hooks@0.4.16", "", { "dependencies": { "dequal": "^2.0.3" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
@@ -487,15 +496,15 @@
"@tanstack/react-query": ["@tanstack/react-query@5.95.2", "", { "dependencies": { "@tanstack/query-core": "5.95.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA=="],
"@tanstack/react-router": ["@tanstack/react-router@1.168.7", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.168.6", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-fW/HvQja4PQeu9lsoyh+pXpZ0UXezbpQkkJvCuH6tHAaW3jvPkjh14lfadrNNiY+pXT7WiMTB3afGhTCC78PDQ=="],
"@tanstack/react-router": ["@tanstack/react-router@1.168.8", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.168.7", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-t0S0QueXubBKmI9eLPcN/A1sLQgTu8/yHerjrvvsGeD12zMdw0uJPKwEKpStQF2OThQtw64cs34uUSYXBUTSNw=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
"@tanstack/router-core": ["@tanstack/router-core@1.168.6", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-okCno3pImpLFQMJ/4zqEIGjIV5yhxLGj0JByrzQDQehORN1y1q6lJUezT0KPK5qCQiKUApeWaboLPjgBVx1kaQ=="],
"@tanstack/router-core": ["@tanstack/router-core@1.168.7", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-z4UEdlzMrFaKBsG4OIxlZEm+wsYBtEp//fnX6kW18jhQpETNcM6u2SXNdX+bcIYp6AaR7ERS3SBENzjC/xxwQQ=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.166.21", "", { "dependencies": { "@tanstack/router-core": "1.168.6", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-pJWsP6HaGrkIkfkcg6vzKyCBMbf1vV1BrQH+bFAVzXj3T/afmix3IPV2hiAj4zzjMxuddJD1on0Hn5+WDYA7zQ=="],
"@tanstack/router-generator": ["@tanstack/router-generator@1.166.22", "", { "dependencies": { "@tanstack/router-core": "1.168.7", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-wQ7H8/Q2rmSPuaxWnurJ3DATNnqWV2tajxri9TSiW4QHsG7cWPD34+goeIinKG+GajJyEdfVpz6w/gRJXfbAPw=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.8", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.6", "@tanstack/router-generator": "1.166.21", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.168.7", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"], "bin": { "intent": "bin/intent.js" } }, "sha512-/X4ACYsSX4bRmomj5X2TBU75cHuIVI99Fsax6DWnP6hPb4PaSjPUHVBfHhk2NemJzEOZu1L31UQ9QDlbHU4ZTQ=="],
"@tanstack/router-plugin": ["@tanstack/router-plugin@1.167.9", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.168.7", "@tanstack/router-generator": "1.166.22", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.168.8", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"], "bin": { "intent": "bin/intent.js" } }, "sha512-h/VV05FEHd4PVyc5Zy8B3trWLcdLt/Pmp+mfifmBKGRw+MUtvdQKbBHhmy4ouOf67s5zDJMc+n8R3xgU7bDwFA=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.161.6", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-nRcYw+w2OEgK6VfjirYvGyPLOK+tZQz1jkYcmH5AjMamQ9PycnlxZF2aEZtPpNoUsaceX2bHptn6Ub5hGXqNvw=="],
@@ -503,17 +512,17 @@
"@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.161.7", "", { "bin": { "intent": "bin/intent.js" } }, "sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.8.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-FQ9EX1xMU5nbwjxXxM3yU88AQQ6Sqc6S44exPRroMcx9XZHqqppl5ymJF0Ig/z3nvQNwDmz1Gsnvxubo+nXWjQ=="],
"@turbo/darwin-64": ["@turbo/darwin-64@2.8.21", "", { "os": "darwin", "cpu": "x64" }, "sha512-kfGoM0Iw8ZNZpbds+4IzOe0hjvHldqJwUPRAjXJi3KBxg/QOZL95N893SRoMtf2aJ+jJ3dk32yPkp8rvcIjP9g=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.8.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gpyh9ATFGThD6/s9L95YWY54cizg/VRWl2B67h0yofG8BpHf67DFAh9nuJVKG7bY0+SBJDAo5cMur+wOl9YOYw=="],
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.8.21", "", { "os": "darwin", "cpu": "arm64" }, "sha512-o9HEflxUEyr987x0cTUzZBhDOyL6u95JmdmlkH2VyxAw7zq2sdtM5e72y9ufv2N5SIoOBw1fVn9UES5VY5H6vQ=="],
"@turbo/linux-64": ["@turbo/linux-64@2.8.20", "", { "os": "linux", "cpu": "x64" }, "sha512-p2QxWUYyYUgUFG0b0kR+pPi8t7c9uaVlRtjTTI1AbCvVqkpjUfCcReBn6DgG/Hu8xrWdKLuyQFaLYFzQskZbcA=="],
"@turbo/linux-64": ["@turbo/linux-64@2.8.21", "", { "os": "linux", "cpu": "x64" }, "sha512-uTxlCcXWy5h1fSSymP8XSJ+AudzEHMDV3IDfKX7+DGB8kgJ+SLoTUAH7z4OFA7I/l2sznz0upPdbNNZs91YMag=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.8.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-Gn5yjlZGLRZWarLWqdQzv0wMqyBNIdq1QLi48F1oY5Lo9kiohuf7BPQWtWxeNVS2NgJ1+nb/DzK1JduYC4AWOA=="],
"@turbo/linux-arm64": ["@turbo/linux-arm64@2.8.21", "", { "os": "linux", "cpu": "arm64" }, "sha512-cdHIcxNcihHHkCHp0Y4Zb60K4Qz+CK4xw1gb6s/t/9o4SMeMj+hTBCtoW6QpPnl9xPYmxuTou8Zw6+cylTnREg=="],
"@turbo/windows-64": ["@turbo/windows-64@2.8.20", "", { "os": "win32", "cpu": "x64" }, "sha512-vyaDpYk/8T6Qz5V/X+ihKvKFEZFUoC0oxYpC1sZanK6gaESJlmV3cMRT3Qhcg4D2VxvtC2Jjs9IRkrZGL+exLw=="],
"@turbo/windows-64": ["@turbo/windows-64@2.8.21", "", { "os": "win32", "cpu": "x64" }, "sha512-/iBj4OzbqEY8CX+eaeKbBTMZv2CLXNrt0692F7HnK7LcyYwyDecaAiSET6ZzL4opT7sbwkKvzAC/fhqT3Quu1A=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw=="],
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.8.21", "", { "os": "win32", "cpu": "arm64" }, "sha512-95tMA/ZbIidJFUUtkmqioQ1gf3n3I1YbRP3ZgVdWTVn2qVbkodcIdGXBKRHHrIbRsLRl99SiHi/L7IxhpZDagQ=="],
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
@@ -523,7 +532,9 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bcrypt": ["@types/bcrypt@5.0.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ=="],
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
"@types/date-arithmetic": ["@types/date-arithmetic@4.1.4", "", {}, "sha512-p9eZ2X9B80iKiTW4ukVj8B4K6q9/+xFtQ5MGYA5HWToY9nL4EkhV9+6ftT2VHpVMEZb5Tv00Iel516bVdO+yRw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -533,14 +544,20 @@
"@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
"@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-big-calendar": ["@types/react-big-calendar@1.16.3", "", { "dependencies": { "@types/date-arithmetic": "*", "@types/prop-types": "*", "@types/react": "*" } }, "sha512-CR+5BKMhlr/wPgsp+sXOeNKNkoU1h/+6H1XoWuL7xnurvzGRQv/EnM8jPS9yxxBvXI8pjQBaJcI7RTSGiewG/Q=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/warning": ["@types/warning@3.0.4", "", {}, "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.57.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/type-utils": "8.57.2", "@typescript-eslint/utils": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.57.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.57.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/visitor-keys": "8.57.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA=="],
@@ -597,9 +614,9 @@
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-DAKrHphkJyiGuau/cFieRYhcTFeK/lBuD++C7cZ6KZHbMhBrisoi+EvhQ5RZrIfV5qwsW8kgQ07JIC+MDJRAhg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ=="],
"bcrypt": ["bcrypt@6.0.0", "", { "dependencies": { "node-addon-api": "^8.3.0", "node-gyp-build": "^4.8.4" } }, "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg=="],
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
@@ -615,7 +632,7 @@
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001781", "", {}, "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw=="],
"caniuse-lite": ["caniuse-lite@1.0.30001782", "", {}, "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw=="],
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
@@ -651,8 +668,14 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"date-arithmetic": ["date-arithmetic@4.1.0", "", {}, "sha512-QWxYLR5P/6GStZcdem+V1xoto6DMadYWpMXU82ES3/RfR3Wdwr3D0+be7mgOJ+Ov0G9D5Dmb9T17sNLQYj9XOg=="],
"date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="],
"dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="],
"dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
@@ -667,6 +690,8 @@
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
"dompurify": ["dompurify@3.3.3", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA=="],
"drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="],
@@ -771,6 +796,8 @@
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globalize": ["globalize@0.1.1", "", {}, "sha512-5e01v8eLGfuQSOvx2MsDMOWS0GFtCx1wPzQSmcHw4hkxFzrQDBO3Xwg/m8Hr/7qXMrHeOIE29qWVzyv06u1TZA=="],
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
@@ -781,6 +808,8 @@
"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=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -789,6 +818,8 @@
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="],
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
"ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="],
@@ -815,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=="],
"jsbarcode": ["jsbarcode@3.12.3", "", {}, "sha512-CuHU9hC6dPsHF5oVFMo8NW76uQVjH4L22CsP4hW+dNnGywJHC/B0ThA1CTDVLnxKLrrpYdicBLnd2xsgTfRnvg=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
@@ -861,18 +894,28 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
"lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@1.7.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg=="],
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"memoize-one": ["memoize-one@6.0.0", "", {}, "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="],
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
@@ -881,20 +924,22 @@
"mnemonist": ["mnemonist@0.40.0", "", { "dependencies": { "obliterator": "^2.0.4" } }, "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg=="],
"moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="],
"moment-timezone": ["moment-timezone@0.5.48", "", { "dependencies": { "moment": "^2.29.4" } }, "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"obliterator": ["obliterator@2.0.5", "", {}, "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
@@ -941,6 +986,8 @@
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@@ -953,10 +1000,18 @@
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-big-calendar": ["react-big-calendar@1.19.4", "", { "dependencies": { "@babel/runtime": "^7.20.7", "clsx": "^1.2.1", "date-arithmetic": "^4.1.0", "dayjs": "^1.11.7", "dom-helpers": "^5.2.1", "globalize": "^0.1.1", "invariant": "^2.2.4", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "luxon": "^3.2.1", "memoize-one": "^6.0.0", "moment": "^2.29.4", "moment-timezone": "^0.5.40", "prop-types": "^15.8.1", "react-overlays": "^5.2.1", "uncontrollable": "^7.2.1" }, "peerDependencies": { "react": "^16.14.0 || ^17 || ^18 || ^19", "react-dom": "^16.14.0 || ^17 || ^18 || ^19" } }, "sha512-FrvbDx2LF6JAWFD96LU1jjloppC5OgIvMYUYIPzAw5Aq+ArYFPxAjLqXc4DyxfsQDN0TJTMuS/BIbcSB7Pg0YA=="],
"react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-hook-form": ["react-hook-form@7.72.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-lifecycles-compat": ["react-lifecycles-compat@3.0.4", "", {}, "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="],
"react-overlays": ["react-overlays@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.13.8", "@popperjs/core": "^2.11.6", "@restart/hooks": "^0.4.7", "@types/warning": "^3.0.0", "dom-helpers": "^5.2.0", "prop-types": "^15.7.2", "uncontrollable": "^7.2.1", "warning": "^4.0.3" }, "peerDependencies": { "react": ">=16.3.0", "react-dom": ">=16.3.0" } }, "sha512-GLLSOLWr21CqtJn8geSwQfoJufdt3mfdsnIiQswouuQ2MMPns+ihZklxvsTDKD3cR2tF8ELbi5xUsvqVhR6WvA=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
@@ -991,7 +1046,7 @@
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
"rollup": ["rollup@4.60.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm64": "4.60.0", "@rollup/rollup-darwin-arm64": "4.60.0", "@rollup/rollup-darwin-x64": "4.60.0", "@rollup/rollup-freebsd-arm64": "4.60.0", "@rollup/rollup-freebsd-x64": "4.60.0", "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", "@rollup/rollup-linux-arm-musleabihf": "4.60.0", "@rollup/rollup-linux-arm64-gnu": "4.60.0", "@rollup/rollup-linux-arm64-musl": "4.60.0", "@rollup/rollup-linux-loong64-gnu": "4.60.0", "@rollup/rollup-linux-loong64-musl": "4.60.0", "@rollup/rollup-linux-ppc64-gnu": "4.60.0", "@rollup/rollup-linux-ppc64-musl": "4.60.0", "@rollup/rollup-linux-riscv64-gnu": "4.60.0", "@rollup/rollup-linux-riscv64-musl": "4.60.0", "@rollup/rollup-linux-s390x-gnu": "4.60.0", "@rollup/rollup-linux-x64-gnu": "4.60.0", "@rollup/rollup-linux-x64-musl": "4.60.0", "@rollup/rollup-openbsd-x64": "4.60.0", "@rollup/rollup-openharmony-arm64": "4.60.0", "@rollup/rollup-win32-arm64-msvc": "4.60.0", "@rollup/rollup-win32-ia32-msvc": "4.60.0", "@rollup/rollup-win32-x64-gnu": "4.60.0", "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ=="],
"rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@@ -1067,7 +1122,7 @@
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
"turbo": ["turbo@2.8.20", "", { "optionalDependencies": { "@turbo/darwin-64": "2.8.20", "@turbo/darwin-arm64": "2.8.20", "@turbo/linux-64": "2.8.20", "@turbo/linux-arm64": "2.8.20", "@turbo/windows-64": "2.8.20", "@turbo/windows-arm64": "2.8.20" }, "bin": { "turbo": "bin/turbo" } }, "sha512-Rb4qk5YT8RUwwdXtkLpkVhNEe/lor6+WV7S5tTlLpxSz6MjV5Qi8jGNn4gS6NAvrYGA/rNrE6YUQM85sCZUDbQ=="],
"turbo": ["turbo@2.8.21", "", { "optionalDependencies": { "@turbo/darwin-64": "2.8.21", "@turbo/darwin-arm64": "2.8.21", "@turbo/linux-64": "2.8.21", "@turbo/linux-arm64": "2.8.21", "@turbo/windows-64": "2.8.21", "@turbo/windows-arm64": "2.8.21" }, "bin": { "turbo": "bin/turbo" } }, "sha512-FlJ8OD5Qcp0jTAM7E4a/RhUzRNds2GzKlyxHKA6N247VLy628rrxAGlMpIXSz6VB430+TiQDJ/SMl6PL1lu6wQ=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
@@ -1075,6 +1130,8 @@
"typescript-eslint": ["typescript-eslint@8.57.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.57.2", "@typescript-eslint/parser": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2", "@typescript-eslint/utils": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-VEPQ0iPgWO/sBaZOU1xo4nuNdODVOajPnTIbog2GKYr31nIlZ0fWPoCQgGfF3ETyBl1vn63F/p50Um9Z4J8O8A=="],
"uncontrollable": ["uncontrollable@7.2.1", "", { "dependencies": { "@babel/runtime": "^7.6.3", "@types/react": ">=16.9.11", "invariant": "^2.2.4", "react-lifecycles-compat": "^3.0.4" }, "peerDependencies": { "react": ">=15.0.0" } }, "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
@@ -1093,6 +1150,8 @@
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@@ -1153,7 +1212,7 @@
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -1189,6 +1248,8 @@
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"react-big-calendar/clsx": ["clsx@1.2.1", "", {}, "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="],
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],

6
chart/Chart.yaml Normal file
View File

@@ -0,0 +1,6 @@
apiVersion: v2
name: lunarfront
description: LunarFront small business management platform
type: application
version: 0.1.1
appVersion: "0.1.1"

View File

@@ -0,0 +1,8 @@
{{- define "lunarfront.name" -}}
{{- .Release.Name }}
{{- end }}
{{- define "lunarfront.labels" -}}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

View File

@@ -0,0 +1,129 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-backend
namespace: {{ .Release.Namespace }}
labels:
{{- include "lunarfront.labels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
app: {{ .Release.Name }}-backend
template:
metadata:
labels:
app: {{ .Release.Name }}-backend
spec:
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
containers:
- name: backend
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}"
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
ports:
- containerPort: {{ .Values.backend.port }}
env:
- name: PORT
value: {{ .Values.backend.port | quote }}
- name: NODE_ENV
value: production
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: database-url
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: redis-url
- name: REDIS_KEY_PREFIX
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: redis-key-prefix
- name: SPACES_KEY
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: spaces-key
- name: SPACES_SECRET
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: spaces-secret
- name: SPACES_BUCKET
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: spaces-bucket
- name: SPACES_ENDPOINT
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: spaces-endpoint
- name: SPACES_PREFIX
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: spaces-prefix
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: jwt-secret
- name: ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: encryption-key
- name: RESEND_API_KEY
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: resend-api-key
- name: MAIL_FROM
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: mail-from
- name: BUSINESS_NAME
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: business-name
- name: APP_URL
value: "https://{{ .Values.ingress.host }}"
- name: INITIAL_USER_EMAIL
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: initial-user-email
optional: true
- name: INITIAL_USER_FIRST_NAME
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: initial-user-first-name
optional: true
- name: INITIAL_USER_LAST_NAME
valueFrom:
secretKeyRef:
name: lunarfront-secrets
key: initial-user-last-name
optional: true
livenessProbe:
httpGet:
path: /v1/health
port: {{ .Values.backend.port }}
initialDelaySeconds: 15
periodSeconds: 30
readinessProbe:
httpGet:
path: /v1/health
port: {{ .Values.backend.port }}
initialDelaySeconds: 5
periodSeconds: 10
resources:
{{- toYaml .Values.backend.resources | nindent 12 }}

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-backend
namespace: {{ .Release.Namespace }}
labels:
{{- include "lunarfront.labels" . | nindent 4 }}
spec:
selector:
app: {{ .Release.Name }}-backend
ports:
- port: {{ .Values.backend.port }}
targetPort: {{ .Values.backend.port }}

View File

@@ -0,0 +1,30 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-frontend
namespace: {{ .Release.Namespace }}
labels:
{{- include "lunarfront.labels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
app: {{ .Release.Name }}-frontend
template:
metadata:
labels:
app: {{ .Release.Name }}-frontend
spec:
imagePullSecrets:
{{- toYaml .Values.imagePullSecrets | nindent 8 }}
containers:
- name: frontend
image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}"
imagePullPolicy: {{ .Values.frontend.image.pullPolicy }}
ports:
- containerPort: {{ .Values.frontend.port }}
env:
- name: BACKEND_URL
value: "http://{{ .Release.Name }}-backend:{{ .Values.backend.port }}"
resources:
{{- toYaml .Values.frontend.resources | nindent 12 }}

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-frontend
namespace: {{ .Release.Namespace }}
labels:
{{- include "lunarfront.labels" . | nindent 4 }}
spec:
selector:
app: {{ .Release.Name }}-frontend
ports:
- port: 80
targetPort: {{ .Values.frontend.port }}

View File

@@ -0,0 +1,29 @@
{{- if .Values.ingress.host }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "lunarfront.labels" . | nindent 4 }}
annotations:
kubernetes.io/ingress.class: {{ .Values.ingress.className }}
cert-manager.io/cluster-issuer: {{ .Values.ingress.tlsIssuer }}
spec:
ingressClassName: {{ .Values.ingress.className }}
tls:
- hosts:
- {{ .Values.ingress.host }}
secretName: {{ .Release.Name }}-tls
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ .Release.Name }}-frontend
port:
number: 80
{{- end }}

39
chart/values.yaml Normal file
View File

@@ -0,0 +1,39 @@
backend:
image:
repository: registry.digitalocean.com/lunarfront/lunarfront-app
tag: 0.0.27
pullPolicy: Always
port: 8000
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 1000m
memory: 512Mi
frontend:
image:
repository: registry.digitalocean.com/lunarfront/lunarfront-frontend
tag: 0.0.27
pullPolicy: Always
port: 80
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
ingress:
host: ""
className: nginx
tlsIssuer: letsencrypt
imagePullSecrets:
- name: registry-lunarfront
# Secrets are expected to exist in-namespace as 'lunarfront-secrets' with keys:
# database-url, jwt-secret, redis-url
# These are created by the manager during provisioning, not by this chart.

27
deploy/deploy.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# LunarFront — Redeploy script (run after pushing changes to main)
# Usage: sudo bash deploy/deploy.sh
set -euo pipefail
APP_DIR="/opt/lunarfront"
APP_USER="ubuntu"
BUN_BIN="/home/${APP_USER}/.bun/bin/bun"
cd "$APP_DIR"
echo "==> Installing dependencies..."
sudo -u "$APP_USER" "$BUN_BIN" install --frozen-lockfile
echo "==> Building admin frontend..."
sudo -u "$APP_USER" bash -c "cd ${APP_DIR}/packages/admin && ${BUN_BIN} run build"
echo "==> Running migrations..."
sudo -u "$APP_USER" bash -c \
"cd ${APP_DIR}/packages/backend && ${BUN_BIN} x drizzle-kit migrate"
echo "==> Restarting backend..."
sudo systemctl restart lunarfront
echo "==> Done! Checking status..."
sleep 2
sudo systemctl status lunarfront --no-pager

18
deploy/lunarfront.service Normal file
View File

@@ -0,0 +1,18 @@
[Unit]
Description=LunarFront API Server
After=network.target postgresql.service
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/opt/lunarfront/packages/backend
EnvironmentFile=/opt/lunarfront/.env
ExecStart=/home/ubuntu/.bun/bin/bun run src/main.ts
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=lunarfront
[Install]
WantedBy=multi-user.target

47
deploy/nginx.conf Normal file
View File

@@ -0,0 +1,47 @@
server {
listen 80;
server_name YOUR_DOMAIN www.YOUR_DOMAIN;
# Certbot will automatically add HTTPS redirect and SSL config below this line
root /opt/lunarfront/packages/admin/dist;
index index.html;
# Proxy API requests to Bun backend
location /v1/ {
proxy_pass http://localhost:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
client_max_body_size 20M;
}
# WebDAV passthrough (all HTTP methods)
location /webdav/ {
proxy_pass http://localhost:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
client_max_body_size 100M;
}
# SPA fallback — serve index.html for all unmatched paths
location / {
try_files $uri $uri/ /index.html;
}
# Cache hashed static assets aggressively
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_vary on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
}

128
deploy/setup.sh Executable file
View File

@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# LunarFront — One-time EC2 provisioning script
# Run as root (or with sudo) on a fresh Ubuntu 24.04 instance.
# Usage: sudo bash deploy/setup.sh
set -euo pipefail
REPO_URL="git@github.com:YOUR_ORG/YOUR_REPO.git"
APP_DIR="/opt/lunarfront"
APP_USER="ubuntu"
DB_USER="lunarfront"
DB_NAME="lunarfront"
DB_PASS="$(openssl rand -hex 16)" # auto-generated; written to .env
# ── 1. System packages ────────────────────────────────────────────────────────
echo "==> Updating system packages..."
apt-get update -y && apt-get upgrade -y
apt-get install -y curl git build-essential nginx certbot python3-certbot-nginx unzip
# ── 2. Bun runtime ────────────────────────────────────────────────────────────
echo "==> Installing Bun..."
sudo -u "$APP_USER" bash -c 'curl -fsSL https://bun.sh/install | bash'
BUN_BIN="/home/${APP_USER}/.bun/bin/bun"
# ── 3. PostgreSQL 16 ──────────────────────────────────────────────────────────
echo "==> Installing PostgreSQL 16..."
apt-get install -y postgresql-16 postgresql-contrib-16
systemctl enable --now postgresql
echo "==> Creating database user and database..."
sudo -u postgres psql -tc "SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'" | grep -q 1 || \
sudo -u postgres psql -c "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';"
sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'" | grep -q 1 || \
sudo -u postgres psql -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};"
# ── 4. Valkey 8 ───────────────────────────────────────────────────────────────
echo "==> Installing Valkey..."
# Try official Valkey apt repo first; fall back to Redis 7 if unavailable
if curl -fsSL https://packages.valkey.io/ubuntu/gpg.asc 2>/dev/null | \
gpg --dearmor -o /usr/share/keyrings/valkey.gpg; then
echo "deb [signed-by=/usr/share/keyrings/valkey.gpg] https://packages.valkey.io/ubuntu noble main" \
> /etc/apt/sources.list.d/valkey.list
apt-get update -y && apt-get install -y valkey
REDIS_SERVICE="valkey"
else
echo "Valkey repo unavailable, falling back to Redis 7..."
apt-get install -y redis-server
REDIS_SERVICE="redis-server"
fi
systemctl enable --now "$REDIS_SERVICE"
# ── 5. Clone repository ───────────────────────────────────────────────────────
echo "==> Cloning repository to ${APP_DIR}..."
if [ -d "$APP_DIR" ]; then
echo " ${APP_DIR} already exists, skipping clone."
else
git clone "$REPO_URL" "$APP_DIR"
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
fi
cd "$APP_DIR"
# ── 6. Environment file ───────────────────────────────────────────────────────
if [ ! -f "${APP_DIR}/.env" ]; then
echo "==> Generating .env..."
JWT_SECRET=$(openssl rand -hex 32)
cat > "${APP_DIR}/.env" <<EOF
DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@localhost:5432/${DB_NAME}
REDIS_URL=redis://localhost:6379
JWT_SECRET=${JWT_SECRET}
PORT=8000
HOST=0.0.0.0
NODE_ENV=production
CORS_ORIGINS=http://$(curl -s http://169.254.169.254/latest/meta-data/public-ipv4)
STORAGE_LOCAL_PATH=${APP_DIR}/data/files
EOF
chown "$APP_USER:$APP_USER" "${APP_DIR}/.env"
chmod 600 "${APP_DIR}/.env"
echo " Generated JWT secret and wrote .env"
echo " NOTE: Update CORS_ORIGINS once you have a domain."
else
echo " .env already exists, skipping generation."
fi
# ── 7. Install dependencies + build frontend ──────────────────────────────────
echo "==> Installing dependencies..."
sudo -u "$APP_USER" "$BUN_BIN" install --frozen-lockfile
echo "==> Building admin frontend..."
sudo -u "$APP_USER" bash -c "cd ${APP_DIR}/packages/admin && ${BUN_BIN} run build"
# ── 8. Run database migrations ────────────────────────────────────────────────
echo "==> Running database migrations..."
sudo -u "$APP_USER" bash -c \
"cd ${APP_DIR}/packages/backend && ${BUN_BIN} x drizzle-kit migrate"
# ── 9. Create file storage directory ─────────────────────────────────────────
mkdir -p "${APP_DIR}/data/files"
chown -R "$APP_USER:$APP_USER" "${APP_DIR}/data"
# ── 10. Systemd service ───────────────────────────────────────────────────────
echo "==> Installing systemd service..."
# Substitute real Bun path into service file
sed "s|/home/ubuntu/.bun/bin/bun|${BUN_BIN}|g" \
"${APP_DIR}/deploy/lunarfront.service" > /etc/systemd/system/lunarfront.service
systemctl daemon-reload
systemctl enable lunarfront
systemctl restart lunarfront
# ── 11. Nginx ─────────────────────────────────────────────────────────────────
echo "==> Configuring Nginx..."
cp "${APP_DIR}/deploy/nginx.conf" /etc/nginx/sites-available/lunarfront
ln -sf /etc/nginx/sites-available/lunarfront /etc/nginx/sites-enabled/lunarfront
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
echo ""
echo "========================================================"
echo " Setup complete!"
echo ""
echo " Next steps:"
echo " 1. Verify .env has correct values:"
echo " nano ${APP_DIR}/.env"
echo " 2. Restart backend after editing .env:"
echo " sudo systemctl restart lunarfront"
echo " 3. Set up HTTPS (after pointing DNS to this IP):"
echo " sudo certbot --nginx -d YOUR_DOMAIN -d www.YOUR_DOMAIN"
echo " 4. Check logs:"
echo " journalctl -u lunarfront -f"
echo "========================================================"

25
deploy/sync-and-deploy.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# LunarFront — Sync local source to EC2 and redeploy
# Usage: bash deploy/sync-and-deploy.sh
set -euo pipefail
EC2_HOST="18.217.233.214"
EC2_USER="ubuntu"
SSH_KEY="$HOME/.ssh/lunarfront-dev.pem"
APP_DIR="/opt/lunarfront"
echo "==> Syncing source to ${EC2_USER}@${EC2_HOST}:${APP_DIR} ..."
rsync -az --delete \
--exclude='.git' \
--exclude='node_modules' \
--exclude='packages/*/node_modules' \
--exclude='packages/admin/dist' \
--exclude='packages/backend/dist' \
--exclude='*.env' \
-e "ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no" \
/home/ryan/pos/ \
"${EC2_USER}@${EC2_HOST}:${APP_DIR}/"
echo "==> Running deploy script on server..."
ssh -i "${SSH_KEY}" -o StrictHostKeyChecking=no "${EC2_USER}@${EC2_HOST}" \
"sudo bash ${APP_DIR}/deploy/deploy.sh"

View File

@@ -3,7 +3,7 @@ services:
build:
context: .
dockerfile: Dockerfile.dev
container_name: forte-api
container_name: lunarfront-api
restart: unless-stopped
ports:
- "8000:8000"
@@ -14,7 +14,7 @@ services:
- /app/packages/backend/node_modules
- /app/packages/shared/node_modules
environment:
DATABASE_URL: postgresql://forte:forte@postgres:5432/forte
DATABASE_URL: postgresql://lunarfront:lunarfront@postgres:5432/lunarfront
REDIS_URL: redis://valkey:6379
JWT_SECRET: dev-secret-do-not-use-in-production
NODE_ENV: development
@@ -28,30 +28,30 @@ services:
postgres:
image: postgres:16
container_name: forte-postgres
container_name: lunarfront-postgres
restart: unless-stopped
environment:
POSTGRES_USER: forte
POSTGRES_PASSWORD: forte
POSTGRES_DB: forte
POSTGRES_USER: lunarfront
POSTGRES_PASSWORD: lunarfront
POSTGRES_DB: lunarfront
ports:
- "5432:5432"
volumes:
- forte-pgdata:/var/lib/postgresql/data
- lunarfront-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U forte"]
test: ["CMD-SHELL", "pg_isready -U lunarfront"]
interval: 5s
timeout: 3s
retries: 5
valkey:
image: valkey/valkey:8
container_name: forte-valkey
container_name: lunarfront-valkey
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- forte-valkey:/data
- lunarfront-valkey:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 5s
@@ -59,5 +59,5 @@ services:
retries: 5
volumes:
forte-pgdata:
forte-valkey:
lunarfront-pgdata:
lunarfront-valkey:

View File

@@ -3,16 +3,16 @@
## Monorepo Structure
```
forte/
lunarfront/
packages/
shared/ @forte/shared — Zod schemas, types, business logic, utils
backend/ @forte/backend — Fastify API server
admin/ @forte/admin — Admin UI (React + Vite)
shared/ @lunarfront/shared — Zod schemas, types, business logic, utils
backend/ @lunarfront/backend — Fastify API server
admin/ @lunarfront/admin — Admin UI (React + Vite)
planning/ Domain planning docs (01-26)
docs/ Technical documentation
```
Managed with Turborepo and Bun workspaces. `@forte/shared` is a dependency of both `backend` and `admin`.
Managed with Turborepo and Bun workspaces. `@lunarfront/shared` is a dependency of both `backend` and `admin`.
## Backend
@@ -56,11 +56,10 @@ src/
### Request Flow
1. Fastify receives request
2. `onRequest` hook sets `companyId` from header
3. `authenticate` preHandler verifies JWT, loads permissions, checks `is_active`
4. `requirePermission` preHandler checks user has required permission slug
5. Route handler validates input with Zod, calls service, returns response
6. Error handler catches typed errors and maps to HTTP status codes
2. `authenticate` preHandler verifies JWT, loads permissions, checks `is_active`
3. `requirePermission` preHandler checks user has required permission slug
4. Route handler validates input with Zod, calls service, returns response
5. Error handler catches typed errors and maps to HTTP status codes
### Permission Inheritance
@@ -109,9 +108,9 @@ src/
| Theme | Zustand store, persisted to localStorage |
| Component state | React `useState` |
## Multi-Tenancy
## Deployment Model
Every domain table has a `company_id` column. All queries filter by the authenticated user's company. Location-scoped tables (inventory, transactions) additionally filter by `location_id`.
Each customer runs as a fully isolated deployment — their own Kubernetes namespace on DOKS, their own database on the shared managed Postgres instance. There is no multi-tenancy in the application layer. No `company_id`, no row-level isolation. One instance = one customer.
## Database

View File

@@ -6,8 +6,8 @@ PostgreSQL 16. Two databases:
| Database | Port | Usage |
|----------|------|-------|
| `forte` | 5432 | Development |
| `forte_api_test` | 5432 | API integration tests (auto-created by test runner) |
| `lunarfront` | 5432 | Development |
| `lunarfront_api_test` | 5432 | API integration tests (auto-created by test runner) |
## Migrations
@@ -35,7 +35,7 @@ All domain tables include `company_id` (uuid FK to `company`). Every query filte
| Table | Description |
|-------|-------------|
| `company` | Tenant (music store business) |
| `company` | Tenant (tenant business) |
| `location` | Physical store location |
| `user` | Staff/admin user account |

193
docs/deployment.md Normal file
View File

@@ -0,0 +1,193 @@
# Infrastructure & Deployment Architecture
## Overview
LunarFront runs on DigitalOcean. Each customer is a fully isolated deployment — their own Kubernetes namespace, their own database. There is no multi-tenancy in the application layer.
**Stack:** DOKS (Kubernetes) · ArgoCD · Helm · Gitea · Terraform · Ansible
The guiding principle is simplicity — Docker Compose on Droplets for Gitea, Kubernetes for customer app instances. No surprise bills.
---
## Monthly Cost
| Resource | Spec | Cost |
|---|---|---|
| DOKS cluster | Control plane | $12/mo |
| DOKS nodes (2x s-2vcpu-4gb) | Runs all customer apps | ~$48/mo |
| Managed Postgres (shared) | All customer databases | $15-25/mo |
| Managed Redis (shared) | All customer queues/cache | $15/mo |
| Gitea Droplet | 1 vCPU, 1GB RAM | $6/mo |
| Gitea Postgres | Dedicated managed Postgres | $15/mo |
| Spaces | Registry, backups, files | ~$21/mo |
| **Fixed total** | | **~$132-142/mo** |
| **Per customer** | New database in shared Postgres | **~$0 marginal** |
---
## Environments
| Environment | Hostname | Purpose |
|---|---|---|
| Production (per customer) | `{customer}.app.lunarfront.tech` | Live customer instance on DOKS |
| Gitea | `git.lunarfront.tech` | Source control, CI/CD, container registry |
| Dev | Local / feature namespace on DOKS | Testing and staging |
---
## DNS
Managed through Cloudflare. Records defined in Terraform.
| Record | Type | Points To |
|---|---|---|
| `git.lunarfront.tech` | A | Gitea Droplet |
| `git-ssh.lunarfront.tech` | A | Gitea Droplet (SSH port 2222) |
| `registry.lunarfront.tech` | A | Gitea Droplet (container registry) |
| `{customer}.app.lunarfront.tech` | CNAME | DOKS load balancer |
---
## Infrastructure (Terraform)
Terraform manages all DigitalOcean and Cloudflare resources. State stored in Spaces.
```
/terraform
main.tf providers, backend (Spaces state)
variables.tf
cluster.tf DOKS cluster + node pools
databases.tf shared Postgres, shared Redis
gitea.tf Gitea Droplet
spaces.tf files, backups, tf-state buckets
dns.tf Cloudflare DNS records
outputs.tf cluster endpoint, DB URLs
terraform.tfvars secrets (gitignored)
```
### State Backend
```hcl
terraform {
backend "s3" {
endpoint = "https://nyc3.digitaloceanspaces.com"
bucket = "lunarfront-terraform-state"
key = "terraform.tfstate"
region = "us-east-1"
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
force_path_style = true
}
}
```
---
## Gitea (Source Control + CI/CD)
Gitea runs on its own Droplet with a dedicated managed Postgres. It hosts:
- Source code repositories
- Gitea Actions CI/CD pipelines
- Docker container registry (`registry.lunarfront.tech`)
Managed by Ansible. See `lunarfront-infra/ansible/roles/gitea/`.
### CI Pipeline (Gitea Actions)
On push to `main`:
1. Run lint + unit tests using the shared `ci-runner` image
2. Build Docker image, push to `registry.lunarfront.tech/ryan/lunarfront-app:{sha}`
3. Update the Helm chart `values.yaml` with the new image tag
4. ArgoCD detects the change and syncs all customer deployments
---
## Kubernetes (DOKS + ArgoCD)
All customer app instances run on a single DOKS cluster managed by ArgoCD (GitOps).
### Repository Structure
```
/gitops
apps/
customer-acme/
values.yaml
customer-foo/
values.yaml
chart/
Chart.yaml
templates/
deployment.yaml
service.yaml
ingress.yaml
job-migrate.yaml # runs drizzle-kit migrate on deploy
secret.yaml
```
### Per-Customer values.yaml
```yaml
customer: acme
subdomain: acme
image:
repository: registry.lunarfront.tech/ryan/lunarfront-app
tag: "abc123"
database:
url: "postgresql://lunarfront:pass@db-host:25060/customer_acme?sslmode=require"
redis:
url: "rediss://..."
env:
JWT_SECRET: "..."
NODE_ENV: production
```
### Adding a New Customer
1. `CREATE DATABASE customer_x;` on the shared managed Postgres
2. Add `gitops/apps/customer-x/values.yaml`
3. Push — ArgoCD syncs, migration Job runs, instance is live
4. Add DNS CNAME in Terraform
---
## Database Strategy
One managed Postgres cluster shared across all customers. Each customer gets their own isolated database (`CREATE DATABASE customer_x`). No cross-customer queries are possible at the database level.
- New customer = new database, no new infrastructure cost
- Managed DO Postgres handles backups, failover, and SSL
- Resize the cluster as total load grows
---
## Day-to-Day Workflow
```bash
# Provision infrastructure from scratch
terraform init && terraform apply
# Configure Gitea server
ansible-playbook ansible/gitea.yml -i ansible/inventory.ini
# Normal deploy flow — push to main
git push origin main
# → Gitea Actions: lint + test + build image
# → Image pushed to registry
# → Helm chart values updated
# → ArgoCD syncs all customer deployments automatically
# Add a new customer
# 1. Create database
psql $DATABASE_URL -c "CREATE DATABASE customer_x;"
# 2. Add values file, push
git add gitops/apps/customer-x/values.yaml && git push
# Done — ArgoCD handles the rest
```

View File

@@ -9,7 +9,7 @@
## Installation
```bash
git clone <repo-url> && cd forte
git clone <repo-url> && cd lunarfront
bun install
```
@@ -18,7 +18,7 @@ bun install
Create a `.env` file in the project root:
```env
DATABASE_URL=postgresql://forte:forte@localhost:5432/forte
DATABASE_URL=postgresql://lunarfront:lunarfront@localhost:5432/lunarfront
REDIS_URL=redis://localhost:6379
JWT_SECRET=your-secret-here
NODE_ENV=development
@@ -28,7 +28,7 @@ NODE_ENV=development
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `postgresql://forte:forte@localhost:5432/forte` | PostgreSQL connection string |
| `DATABASE_URL` | `postgresql://lunarfront:lunarfront@localhost:5432/lunarfront` | PostgreSQL connection string |
| `REDIS_URL` | `redis://localhost:6379` | Valkey/Redis connection string |
| `JWT_SECRET` | (auto-generated in dev) | Secret for signing JWTs. **Required in production.** |
| `PORT` | `8000` | Backend API port |
@@ -45,7 +45,7 @@ NODE_ENV=development
```bash
# Create the database
createdb forte
createdb lunarfront
# Run migrations
cd packages/backend

View File

@@ -4,7 +4,7 @@
The primary test suite lives at `packages/backend/api-tests/`. It uses a custom runner that:
1. Creates/migrates a `forte_api_test` database
1. Creates/migrates a `lunarfront_api_test` database
2. Seeds company, lookup tables, RBAC permissions/roles
3. Starts the backend on port 8001
4. Registers a test user with admin role

82
entrypoint-devpod.sh Normal file
View File

@@ -0,0 +1,82 @@
#!/bin/bash
set -e
# Generate SSH host keys if not present
ssh-keygen -A
# Write authorized keys from env if provided
if [ -n "$SSH_AUTHORIZED_KEYS" ]; then
mkdir -p /root/.ssh
chmod 700 /root/.ssh
echo "$SSH_AUTHORIZED_KEYS" > /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
fi
# Bootstrap home dir on fresh PVC
if [ ! -f /root/.bashrc ]; then
cp /etc/skel/.bashrc /root/.bashrc 2>/dev/null || true
cat >> /root/.bashrc <<'EOF'
export PATH="/usr/local/bin:$PATH"
export HISTFILE=/root/.bash_history
export HISTSIZE=10000
EOF
fi
if [ ! -f /root/.profile ]; then
cat > /root/.profile <<'EOF'
export PATH="/usr/local/bin:$PATH"
[ -f /root/.bashrc ] && . /root/.bashrc
EOF
fi
if [ ! -f /root/.gitconfig ]; then
cat > /root/.gitconfig <<'EOF'
[user]
name = ryan
email = ryan@lunartech.com
[init]
defaultBranch = main
[core]
editor = code --wait
EOF
fi
# Install Claude Code on first boot (installs to /root/.claude, persists on PVC)
if [ ! -f /root/.claude/bin/claude ]; then
curl -fsSL https://claude.ai/install.sh | bash
fi
# Allow root login via SSH key, listen on internal port 2222
echo "PermitRootLogin yes" >> /etc/ssh/sshd_config
echo "Port 2222" >> /etc/ssh/sshd_config
# Start SSH daemon on internal port 2222
/usr/sbin/sshd
# Start haproxy on port 22 to accept PROXY protocol from nginx and forward to sshd:2222
cat > /etc/haproxy/haproxy.cfg <<'EOF'
global
daemon
maxconn 256
defaults
mode tcp
timeout connect 5s
timeout client 60s
timeout server 60s
frontend ssh
bind *:22 accept-proxy
default_backend sshd
backend sshd
server local 127.0.0.1:2222
EOF
haproxy -f /etc/haproxy/haproxy.cfg
# Start code-server
exec code-server \
--bind-addr 0.0.0.0:8080 \
--auth none \
--disable-telemetry \
/root

29
nginx.conf Normal file
View File

@@ -0,0 +1,29 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Proxy API and WebDAV to backend — BACKEND_URL injected at runtime via envsubst
location /v1/ {
proxy_pass ${BACKEND_URL};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /webdav/ {
proxy_pass ${BACKEND_URL};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA fallback — all other routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -1,6 +1,7 @@
{
"name": "forte",
"name": "lunarfront",
"private": true,
"packageManager": "bun@1.3.11",
"workspaces": ["packages/*"],
"scripts": {
"dev": "turbo run dev",

View File

@@ -1,473 +0,0 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// 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 LoginRouteImport } from './routes/login'
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
import { Route as AuthenticatedUsersRouteImport } from './routes/_authenticated/users'
import { Route as AuthenticatedProfileRouteImport } from './routes/_authenticated/profile'
import { Route as AuthenticatedHelpRouteImport } from './routes/_authenticated/help'
import { Route as AuthenticatedRolesIndexRouteImport } from './routes/_authenticated/roles/index'
import { Route as AuthenticatedMembersIndexRouteImport } from './routes/_authenticated/members/index'
import { Route as AuthenticatedAccountsIndexRouteImport } from './routes/_authenticated/accounts/index'
import { Route as AuthenticatedRolesNewRouteImport } from './routes/_authenticated/roles/new'
import { Route as AuthenticatedRolesRoleIdRouteImport } from './routes/_authenticated/roles/$roleId'
import { Route as AuthenticatedMembersMemberIdRouteImport } from './routes/_authenticated/members/$memberId'
import { Route as AuthenticatedAccountsNewRouteImport } from './routes/_authenticated/accounts/new'
import { Route as AuthenticatedAccountsAccountIdRouteImport } from './routes/_authenticated/accounts/$accountId'
import { Route as AuthenticatedAccountsAccountIdIndexRouteImport } from './routes/_authenticated/accounts/$accountId/index'
import { Route as AuthenticatedAccountsAccountIdTaxExemptionsRouteImport } from './routes/_authenticated/accounts/$accountId/tax-exemptions'
import { Route as AuthenticatedAccountsAccountIdProcessorLinksRouteImport } from './routes/_authenticated/accounts/$accountId/processor-links'
import { Route as AuthenticatedAccountsAccountIdPaymentMethodsRouteImport } from './routes/_authenticated/accounts/$accountId/payment-methods'
import { Route as AuthenticatedAccountsAccountIdMembersRouteImport } from './routes/_authenticated/accounts/$accountId/members'
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedRoute = AuthenticatedRouteImport.update({
id: '/_authenticated',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedUsersRoute = AuthenticatedUsersRouteImport.update({
id: '/users',
path: '/users',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedProfileRoute = AuthenticatedProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedHelpRoute = AuthenticatedHelpRouteImport.update({
id: '/help',
path: '/help',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedRolesIndexRoute = AuthenticatedRolesIndexRouteImport.update({
id: '/roles/',
path: '/roles/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedMembersIndexRoute =
AuthenticatedMembersIndexRouteImport.update({
id: '/members/',
path: '/members/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAccountsIndexRoute =
AuthenticatedAccountsIndexRouteImport.update({
id: '/accounts/',
path: '/accounts/',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedRolesNewRoute = AuthenticatedRolesNewRouteImport.update({
id: '/roles/new',
path: '/roles/new',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedRolesRoleIdRoute =
AuthenticatedRolesRoleIdRouteImport.update({
id: '/roles/$roleId',
path: '/roles/$roleId',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedMembersMemberIdRoute =
AuthenticatedMembersMemberIdRouteImport.update({
id: '/members/$memberId',
path: '/members/$memberId',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAccountsNewRoute =
AuthenticatedAccountsNewRouteImport.update({
id: '/accounts/new',
path: '/accounts/new',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAccountsAccountIdRoute =
AuthenticatedAccountsAccountIdRouteImport.update({
id: '/accounts/$accountId',
path: '/accounts/$accountId',
getParentRoute: () => AuthenticatedRoute,
} as any)
const AuthenticatedAccountsAccountIdIndexRoute =
AuthenticatedAccountsAccountIdIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
} as any)
const AuthenticatedAccountsAccountIdTaxExemptionsRoute =
AuthenticatedAccountsAccountIdTaxExemptionsRouteImport.update({
id: '/tax-exemptions',
path: '/tax-exemptions',
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
} as any)
const AuthenticatedAccountsAccountIdProcessorLinksRoute =
AuthenticatedAccountsAccountIdProcessorLinksRouteImport.update({
id: '/processor-links',
path: '/processor-links',
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
} as any)
const AuthenticatedAccountsAccountIdPaymentMethodsRoute =
AuthenticatedAccountsAccountIdPaymentMethodsRouteImport.update({
id: '/payment-methods',
path: '/payment-methods',
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
} as any)
const AuthenticatedAccountsAccountIdMembersRoute =
AuthenticatedAccountsAccountIdMembersRouteImport.update({
id: '/members',
path: '/members',
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof AuthenticatedIndexRoute
'/login': typeof LoginRoute
'/help': typeof AuthenticatedHelpRoute
'/profile': typeof AuthenticatedProfileRoute
'/users': typeof AuthenticatedUsersRoute
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
'/accounts/new': typeof AuthenticatedAccountsNewRoute
'/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
'/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
'/roles/new': typeof AuthenticatedRolesNewRoute
'/accounts/': typeof AuthenticatedAccountsIndexRoute
'/members/': typeof AuthenticatedMembersIndexRoute
'/roles/': typeof AuthenticatedRolesIndexRoute
'/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
'/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
'/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
'/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
'/accounts/$accountId/': typeof AuthenticatedAccountsAccountIdIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/help': typeof AuthenticatedHelpRoute
'/profile': typeof AuthenticatedProfileRoute
'/users': typeof AuthenticatedUsersRoute
'/': typeof AuthenticatedIndexRoute
'/accounts/new': typeof AuthenticatedAccountsNewRoute
'/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
'/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
'/roles/new': typeof AuthenticatedRolesNewRoute
'/accounts': typeof AuthenticatedAccountsIndexRoute
'/members': typeof AuthenticatedMembersIndexRoute
'/roles': typeof AuthenticatedRolesIndexRoute
'/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
'/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
'/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
'/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_authenticated': typeof AuthenticatedRouteWithChildren
'/login': typeof LoginRoute
'/_authenticated/help': typeof AuthenticatedHelpRoute
'/_authenticated/profile': typeof AuthenticatedProfileRoute
'/_authenticated/users': typeof AuthenticatedUsersRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
'/_authenticated/accounts/new': typeof AuthenticatedAccountsNewRoute
'/_authenticated/members/$memberId': typeof AuthenticatedMembersMemberIdRoute
'/_authenticated/roles/$roleId': typeof AuthenticatedRolesRoleIdRoute
'/_authenticated/roles/new': typeof AuthenticatedRolesNewRoute
'/_authenticated/accounts/': typeof AuthenticatedAccountsIndexRoute
'/_authenticated/members/': typeof AuthenticatedMembersIndexRoute
'/_authenticated/roles/': typeof AuthenticatedRolesIndexRoute
'/_authenticated/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
'/_authenticated/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
'/_authenticated/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
'/_authenticated/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
'/_authenticated/accounts/$accountId/': typeof AuthenticatedAccountsAccountIdIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/login'
| '/help'
| '/profile'
| '/users'
| '/accounts/$accountId'
| '/accounts/new'
| '/members/$memberId'
| '/roles/$roleId'
| '/roles/new'
| '/accounts/'
| '/members/'
| '/roles/'
| '/accounts/$accountId/members'
| '/accounts/$accountId/payment-methods'
| '/accounts/$accountId/processor-links'
| '/accounts/$accountId/tax-exemptions'
| '/accounts/$accountId/'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
| '/help'
| '/profile'
| '/users'
| '/'
| '/accounts/new'
| '/members/$memberId'
| '/roles/$roleId'
| '/roles/new'
| '/accounts'
| '/members'
| '/roles'
| '/accounts/$accountId/members'
| '/accounts/$accountId/payment-methods'
| '/accounts/$accountId/processor-links'
| '/accounts/$accountId/tax-exemptions'
| '/accounts/$accountId'
id:
| '__root__'
| '/_authenticated'
| '/login'
| '/_authenticated/help'
| '/_authenticated/profile'
| '/_authenticated/users'
| '/_authenticated/'
| '/_authenticated/accounts/$accountId'
| '/_authenticated/accounts/new'
| '/_authenticated/members/$memberId'
| '/_authenticated/roles/$roleId'
| '/_authenticated/roles/new'
| '/_authenticated/accounts/'
| '/_authenticated/members/'
| '/_authenticated/roles/'
| '/_authenticated/accounts/$accountId/members'
| '/_authenticated/accounts/$accountId/payment-methods'
| '/_authenticated/accounts/$accountId/processor-links'
| '/_authenticated/accounts/$accountId/tax-exemptions'
| '/_authenticated/accounts/$accountId/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
LoginRoute: typeof LoginRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated': {
id: '/_authenticated'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthenticatedRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated/': {
id: '/_authenticated/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof AuthenticatedIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/users': {
id: '/_authenticated/users'
path: '/users'
fullPath: '/users'
preLoaderRoute: typeof AuthenticatedUsersRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/profile': {
id: '/_authenticated/profile'
path: '/profile'
fullPath: '/profile'
preLoaderRoute: typeof AuthenticatedProfileRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/help': {
id: '/_authenticated/help'
path: '/help'
fullPath: '/help'
preLoaderRoute: typeof AuthenticatedHelpRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/roles/': {
id: '/_authenticated/roles/'
path: '/roles'
fullPath: '/roles/'
preLoaderRoute: typeof AuthenticatedRolesIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/members/': {
id: '/_authenticated/members/'
path: '/members'
fullPath: '/members/'
preLoaderRoute: typeof AuthenticatedMembersIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/accounts/': {
id: '/_authenticated/accounts/'
path: '/accounts'
fullPath: '/accounts/'
preLoaderRoute: typeof AuthenticatedAccountsIndexRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/roles/new': {
id: '/_authenticated/roles/new'
path: '/roles/new'
fullPath: '/roles/new'
preLoaderRoute: typeof AuthenticatedRolesNewRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/roles/$roleId': {
id: '/_authenticated/roles/$roleId'
path: '/roles/$roleId'
fullPath: '/roles/$roleId'
preLoaderRoute: typeof AuthenticatedRolesRoleIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/members/$memberId': {
id: '/_authenticated/members/$memberId'
path: '/members/$memberId'
fullPath: '/members/$memberId'
preLoaderRoute: typeof AuthenticatedMembersMemberIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/accounts/new': {
id: '/_authenticated/accounts/new'
path: '/accounts/new'
fullPath: '/accounts/new'
preLoaderRoute: typeof AuthenticatedAccountsNewRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/accounts/$accountId': {
id: '/_authenticated/accounts/$accountId'
path: '/accounts/$accountId'
fullPath: '/accounts/$accountId'
preLoaderRoute: typeof AuthenticatedAccountsAccountIdRouteImport
parentRoute: typeof AuthenticatedRoute
}
'/_authenticated/accounts/$accountId/': {
id: '/_authenticated/accounts/$accountId/'
path: '/'
fullPath: '/accounts/$accountId/'
preLoaderRoute: typeof AuthenticatedAccountsAccountIdIndexRouteImport
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
}
'/_authenticated/accounts/$accountId/tax-exemptions': {
id: '/_authenticated/accounts/$accountId/tax-exemptions'
path: '/tax-exemptions'
fullPath: '/accounts/$accountId/tax-exemptions'
preLoaderRoute: typeof AuthenticatedAccountsAccountIdTaxExemptionsRouteImport
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
}
'/_authenticated/accounts/$accountId/processor-links': {
id: '/_authenticated/accounts/$accountId/processor-links'
path: '/processor-links'
fullPath: '/accounts/$accountId/processor-links'
preLoaderRoute: typeof AuthenticatedAccountsAccountIdProcessorLinksRouteImport
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
}
'/_authenticated/accounts/$accountId/payment-methods': {
id: '/_authenticated/accounts/$accountId/payment-methods'
path: '/payment-methods'
fullPath: '/accounts/$accountId/payment-methods'
preLoaderRoute: typeof AuthenticatedAccountsAccountIdPaymentMethodsRouteImport
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
}
'/_authenticated/accounts/$accountId/members': {
id: '/_authenticated/accounts/$accountId/members'
path: '/members'
fullPath: '/accounts/$accountId/members'
preLoaderRoute: typeof AuthenticatedAccountsAccountIdMembersRouteImport
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
}
}
}
interface AuthenticatedAccountsAccountIdRouteChildren {
AuthenticatedAccountsAccountIdMembersRoute: typeof AuthenticatedAccountsAccountIdMembersRoute
AuthenticatedAccountsAccountIdPaymentMethodsRoute: typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
AuthenticatedAccountsAccountIdProcessorLinksRoute: typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
AuthenticatedAccountsAccountIdTaxExemptionsRoute: typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
AuthenticatedAccountsAccountIdIndexRoute: typeof AuthenticatedAccountsAccountIdIndexRoute
}
const AuthenticatedAccountsAccountIdRouteChildren: AuthenticatedAccountsAccountIdRouteChildren =
{
AuthenticatedAccountsAccountIdMembersRoute:
AuthenticatedAccountsAccountIdMembersRoute,
AuthenticatedAccountsAccountIdPaymentMethodsRoute:
AuthenticatedAccountsAccountIdPaymentMethodsRoute,
AuthenticatedAccountsAccountIdProcessorLinksRoute:
AuthenticatedAccountsAccountIdProcessorLinksRoute,
AuthenticatedAccountsAccountIdTaxExemptionsRoute:
AuthenticatedAccountsAccountIdTaxExemptionsRoute,
AuthenticatedAccountsAccountIdIndexRoute:
AuthenticatedAccountsAccountIdIndexRoute,
}
const AuthenticatedAccountsAccountIdRouteWithChildren =
AuthenticatedAccountsAccountIdRoute._addFileChildren(
AuthenticatedAccountsAccountIdRouteChildren,
)
interface AuthenticatedRouteChildren {
AuthenticatedHelpRoute: typeof AuthenticatedHelpRoute
AuthenticatedProfileRoute: typeof AuthenticatedProfileRoute
AuthenticatedUsersRoute: typeof AuthenticatedUsersRoute
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren
AuthenticatedAccountsNewRoute: typeof AuthenticatedAccountsNewRoute
AuthenticatedMembersMemberIdRoute: typeof AuthenticatedMembersMemberIdRoute
AuthenticatedRolesRoleIdRoute: typeof AuthenticatedRolesRoleIdRoute
AuthenticatedRolesNewRoute: typeof AuthenticatedRolesNewRoute
AuthenticatedAccountsIndexRoute: typeof AuthenticatedAccountsIndexRoute
AuthenticatedMembersIndexRoute: typeof AuthenticatedMembersIndexRoute
AuthenticatedRolesIndexRoute: typeof AuthenticatedRolesIndexRoute
}
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
AuthenticatedHelpRoute: AuthenticatedHelpRoute,
AuthenticatedProfileRoute: AuthenticatedProfileRoute,
AuthenticatedUsersRoute: AuthenticatedUsersRoute,
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
AuthenticatedAccountsAccountIdRoute:
AuthenticatedAccountsAccountIdRouteWithChildren,
AuthenticatedAccountsNewRoute: AuthenticatedAccountsNewRoute,
AuthenticatedMembersMemberIdRoute: AuthenticatedMembersMemberIdRoute,
AuthenticatedRolesRoleIdRoute: AuthenticatedRolesRoleIdRoute,
AuthenticatedRolesNewRoute: AuthenticatedRolesNewRoute,
AuthenticatedAccountsIndexRoute: AuthenticatedAccountsIndexRoute,
AuthenticatedMembersIndexRoute: AuthenticatedMembersIndexRoute,
AuthenticatedRolesIndexRoute: AuthenticatedRolesIndexRoute,
}
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
AuthenticatedRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
AuthenticatedRoute: AuthenticatedRouteWithChildren,
LoginRoute: LoginRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

View File

@@ -3,13 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Forte Admin</title>
<title>LunarFront Admin</title>
</head>
<body>
<script>
// Apply mode before React renders to prevent flash
(function() {
var mode = localStorage.getItem('forte-mode') || 'system';
var mode = localStorage.getItem('lunarfront-mode') || 'system';
var isDark = mode === 'dark' || (mode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (isDark) document.documentElement.classList.add('dark');
})();

View File

@@ -1,5 +1,5 @@
{
"name": "@forte/admin",
"name": "@lunarfront/admin",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -10,8 +10,8 @@
"lint": "eslint ."
},
"dependencies": {
"@forte/shared": "workspace:*",
"@hookform/resolvers": "^5.2.2",
"@lunarfront/shared": "workspace:*",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
@@ -21,12 +21,17 @@
"@radix-ui/react-tabs": "^1.1.12",
"@tanstack/react-query": "^5.75.5",
"@tanstack/react-router": "^1.121.0",
"@types/react-big-calendar": "^1.16.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"html2pdf.js": "^0.14.0",
"jsbarcode": "^3.12.3",
"jspdf": "^4.2.1",
"lucide-react": "^1.7.0",
"radix-ui": "^1.4.3",
"react": "^19.1.0",
"react-big-calendar": "^1.19.4",
"react-dom": "^19.1.0",
"react-hook-form": "^7.72.0",
"sonner": "^2.0.3",

View File

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { Account } from '@/types/account'
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
export const accountKeys = {
all: ['accounts'] as const,

View File

@@ -14,3 +14,11 @@ interface LoginResponse {
export async function login(email: string, password: string): Promise<LoginResponse> {
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

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { MemberIdentifier } from '@/types/account'
import type { PaginatedResponse } from '@forte/shared/schemas'
import type { PaginatedResponse } from '@lunarfront/shared/schemas'
export const identifierKeys = {
all: (memberId: string) => ['members', memberId, 'identifiers'] as const,

View File

@@ -0,0 +1,170 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { Category, Supplier, Product, InventoryUnit, ProductSupplier, StockReceipt, PriceHistory } from '@/types/inventory'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
// ─── Categories ──────────────────────────────────────────────────────────────
export const categoryKeys = {
all: ['categories'] as const,
list: (params: PaginationInput) => [...categoryKeys.all, 'list', params] as const,
allCategories: [...['categories'], 'all-flat'] as const,
detail: (id: string) => [...categoryKeys.all, 'detail', id] as const,
}
export function categoryListOptions(params: PaginationInput) {
return queryOptions({
queryKey: categoryKeys.list(params),
queryFn: () => api.get<PaginatedResponse<Category>>('/v1/categories', params as Record<string, unknown>),
})
}
export function categoryAllOptions() {
return queryOptions({
queryKey: categoryKeys.allCategories,
queryFn: () => api.get<{ data: Category[] }>('/v1/categories/all'),
})
}
export const categoryMutations = {
create: (data: Record<string, unknown>) => api.post<Category>('/v1/categories', data),
update: (id: string, data: Record<string, unknown>) => api.patch<Category>(`/v1/categories/${id}`, data),
delete: (id: string) => api.del<Category>(`/v1/categories/${id}`),
}
// ─── Suppliers ───────────────────────────────────────────────────────────────
export const supplierKeys = {
all: ['suppliers'] as const,
allSuppliers: ['suppliers', 'all'] as const,
list: (params: PaginationInput) => [...supplierKeys.all, 'list', params] as const,
detail: (id: string) => [...supplierKeys.all, 'detail', id] as const,
}
export function supplierListOptions(params: PaginationInput) {
return queryOptions({
queryKey: supplierKeys.list(params),
queryFn: () => api.get<PaginatedResponse<Supplier>>('/v1/suppliers', params as Record<string, unknown>),
})
}
export function supplierAllOptions() {
return queryOptions({
queryKey: supplierKeys.allSuppliers,
queryFn: () => api.get<{ data: Supplier[] }>('/v1/suppliers/all'),
})
}
export const supplierMutations = {
create: (data: Record<string, unknown>) => api.post<Supplier>('/v1/suppliers', data),
update: (id: string, data: Record<string, unknown>) => api.patch<Supplier>(`/v1/suppliers/${id}`, data),
delete: (id: string) => api.del<Supplier>(`/v1/suppliers/${id}`),
}
// ─── Products ────────────────────────────────────────────────────────────────
export const productKeys = {
all: ['products'] as const,
list: (params: Record<string, unknown>) => [...productKeys.all, 'list', params] as const,
detail: (id: string) => [...productKeys.all, 'detail', id] as const,
}
export function productListOptions(params: Record<string, unknown>) {
return queryOptions({
queryKey: productKeys.list(params),
queryFn: () => api.get<PaginatedResponse<Product>>('/v1/products', params),
})
}
export function productDetailOptions(id: string) {
return queryOptions({
queryKey: productKeys.detail(id),
queryFn: () => api.get<Product>(`/v1/products/${id}`),
enabled: !!id,
})
}
export const productMutations = {
create: (data: Record<string, unknown>) => api.post<Product>('/v1/products', data),
update: (id: string, data: Record<string, unknown>) => api.patch<Product>(`/v1/products/${id}`, data),
delete: (id: string) => api.del<Product>(`/v1/products/${id}`),
}
// ─── Inventory Units ─────────────────────────────────────────────────────────
export const unitKeys = {
all: ['inventory-units'] as const,
byProduct: (productId: string) => [...unitKeys.all, 'product', productId] as const,
detail: (id: string) => [...unitKeys.all, 'detail', id] as const,
}
export function unitListOptions(productId: string) {
return queryOptions({
queryKey: unitKeys.byProduct(productId),
queryFn: () => api.get<{ data: InventoryUnit[] }>(`/v1/products/${productId}/units`),
enabled: !!productId,
})
}
export const unitMutations = {
create: (productId: string, data: Record<string, unknown>) =>
api.post<InventoryUnit>(`/v1/products/${productId}/units`, data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<InventoryUnit>(`/v1/units/${id}`, data),
}
// ─── Product Suppliers ───────────────────────────────────────────────────────
export const productSupplierKeys = {
byProduct: (productId: string) => ['products', productId, 'suppliers'] as const,
}
export function productSupplierListOptions(productId: string) {
return queryOptions({
queryKey: productSupplierKeys.byProduct(productId),
queryFn: () => api.get<{ data: ProductSupplier[] }>(`/v1/products/${productId}/suppliers`),
enabled: !!productId,
})
}
export const productSupplierMutations = {
create: (productId: string, data: Record<string, unknown>) =>
api.post<ProductSupplier>(`/v1/products/${productId}/suppliers`, data),
update: (productId: string, id: string, data: Record<string, unknown>) =>
api.patch<ProductSupplier>(`/v1/products/${productId}/suppliers/${id}`, data),
delete: (productId: string, id: string) =>
api.del(`/v1/products/${productId}/suppliers/${id}`),
}
// ─── Stock Receipts ──────────────────────────────────────────────────────────
export const stockReceiptKeys = {
byProduct: (productId: string) => ['products', productId, 'stock-receipts'] as const,
}
export function stockReceiptListOptions(productId: string) {
return queryOptions({
queryKey: stockReceiptKeys.byProduct(productId),
queryFn: () => api.get<{ data: StockReceipt[] }>(`/v1/products/${productId}/stock-receipts`),
enabled: !!productId,
})
}
export const stockReceiptMutations = {
create: (productId: string, data: Record<string, unknown>) =>
api.post<StockReceipt>(`/v1/products/${productId}/stock-receipts`, data),
}
// ─── Price History ───────────────────────────────────────────────────────────
export const priceHistoryKeys = {
byProduct: (productId: string) => ['products', productId, 'price-history'] as const,
}
export function priceHistoryOptions(productId: string) {
return queryOptions({
queryKey: priceHistoryKeys.byProduct(productId),
queryFn: () => api.get<{ data: PriceHistory[] }>(`/v1/products/${productId}/price-history`),
enabled: !!productId,
})
}

View File

@@ -0,0 +1,341 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type {
Instructor,
InstructorBlockedDate,
LessonType,
ScheduleSlot,
Enrollment,
LessonSession,
GradingScale,
LessonPlan,
LessonPlanItem,
LessonPlanItemGradeHistory,
LessonPlanTemplate,
StoreClosure,
SessionPlanItem,
} from '@/types/lesson'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
// --- Instructors ---
export const instructorKeys = {
all: ['instructors'] as const,
list: (params: PaginationInput) => [...instructorKeys.all, 'list', params] as const,
detail: (id: string) => [...instructorKeys.all, 'detail', id] as const,
blockedDates: (id: string) => [...instructorKeys.all, id, 'blocked-dates'] as const,
}
export function instructorListOptions(params: PaginationInput) {
return queryOptions({
queryKey: instructorKeys.list(params),
queryFn: () => api.get<PaginatedResponse<Instructor>>('/v1/instructors', params),
})
}
export function instructorDetailOptions(id: string) {
return queryOptions({
queryKey: instructorKeys.detail(id),
queryFn: () => api.get<Instructor>(`/v1/instructors/${id}`),
enabled: !!id,
})
}
export function instructorBlockedDatesOptions(instructorId: string) {
return queryOptions({
queryKey: instructorKeys.blockedDates(instructorId),
queryFn: () => api.get<InstructorBlockedDate[]>(`/v1/instructors/${instructorId}/blocked-dates`),
enabled: !!instructorId,
})
}
export const instructorMutations = {
create: (data: Record<string, unknown>) =>
api.post<Instructor>('/v1/instructors', data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<Instructor>(`/v1/instructors/${id}`, data),
delete: (id: string) =>
api.del<Instructor>(`/v1/instructors/${id}`),
addBlockedDate: (instructorId: string, data: Record<string, unknown>) =>
api.post<InstructorBlockedDate>(`/v1/instructors/${instructorId}/blocked-dates`, data),
deleteBlockedDate: (instructorId: string, id: string) =>
api.del<InstructorBlockedDate>(`/v1/instructors/${instructorId}/blocked-dates/${id}`),
}
// --- Lesson Types ---
export const lessonTypeKeys = {
all: ['lesson-types'] as const,
list: (params: PaginationInput) => [...lessonTypeKeys.all, 'list', params] as const,
detail: (id: string) => [...lessonTypeKeys.all, 'detail', id] as const,
}
export function lessonTypeListOptions(params: PaginationInput) {
return queryOptions({
queryKey: lessonTypeKeys.list(params),
queryFn: () => api.get<PaginatedResponse<LessonType>>('/v1/lesson-types', params),
})
}
export const lessonTypeMutations = {
create: (data: Record<string, unknown>) =>
api.post<LessonType>('/v1/lesson-types', data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<LessonType>(`/v1/lesson-types/${id}`, data),
delete: (id: string) =>
api.del<LessonType>(`/v1/lesson-types/${id}`),
}
// --- Schedule Slots ---
export const scheduleSlotKeys = {
all: ['schedule-slots'] as const,
list: (params: PaginationInput) => [...scheduleSlotKeys.all, 'list', params] as const,
byInstructor: (instructorId: string, params: PaginationInput) =>
[...scheduleSlotKeys.all, 'instructor', instructorId, params] as const,
detail: (id: string) => [...scheduleSlotKeys.all, 'detail', id] as const,
}
export function scheduleSlotListOptions(params: PaginationInput, filters?: { instructorId?: string; dayOfWeek?: number }) {
const query = { ...params, ...filters }
return queryOptions({
queryKey: filters?.instructorId
? scheduleSlotKeys.byInstructor(filters.instructorId, params)
: scheduleSlotKeys.list(params),
queryFn: () => api.get<PaginatedResponse<ScheduleSlot>>('/v1/schedule-slots', query),
})
}
export const scheduleSlotMutations = {
create: (data: Record<string, unknown>) =>
api.post<ScheduleSlot>('/v1/schedule-slots', data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<ScheduleSlot>(`/v1/schedule-slots/${id}`, data),
delete: (id: string) =>
api.del<ScheduleSlot>(`/v1/schedule-slots/${id}`),
}
// --- Enrollments ---
export const enrollmentKeys = {
all: ['enrollments'] as const,
list: (params: Record<string, unknown>) => [...enrollmentKeys.all, 'list', params] as const,
detail: (id: string) => [...enrollmentKeys.all, 'detail', id] as const,
}
export function enrollmentListOptions(params: Record<string, unknown>) {
return queryOptions({
queryKey: enrollmentKeys.list(params),
queryFn: () => api.get<PaginatedResponse<Enrollment>>('/v1/enrollments', params),
})
}
export function enrollmentDetailOptions(id: string) {
return queryOptions({
queryKey: enrollmentKeys.detail(id),
queryFn: () => api.get<Enrollment>(`/v1/enrollments/${id}`),
enabled: !!id,
})
}
export const enrollmentMutations = {
create: (data: Record<string, unknown>) =>
api.post<Enrollment>('/v1/enrollments', data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<Enrollment>(`/v1/enrollments/${id}`, data),
updateStatus: (id: string, status: string) =>
api.post<Enrollment>(`/v1/enrollments/${id}/status`, { status }),
generateSessions: (id: string, weeks?: number) =>
api.post<{ generated: number; sessions: LessonSession[] }>(
`/v1/enrollments/${id}/generate-sessions${weeks ? `?weeks=${weeks}` : ''}`,
{},
),
}
// --- Lesson Sessions ---
export const sessionKeys = {
all: ['lesson-sessions'] as const,
list: (params: Record<string, unknown>) => [...sessionKeys.all, 'list', params] as const,
detail: (id: string) => [...sessionKeys.all, 'detail', id] as const,
planItems: (id: string) => [...sessionKeys.all, id, 'plan-items'] as const,
}
export function sessionListOptions(params: Record<string, unknown>) {
return queryOptions({
queryKey: sessionKeys.list(params),
queryFn: () => api.get<PaginatedResponse<LessonSession>>('/v1/lesson-sessions', params),
})
}
export function sessionDetailOptions(id: string) {
return queryOptions({
queryKey: sessionKeys.detail(id),
queryFn: () => api.get<LessonSession>(`/v1/lesson-sessions/${id}`),
enabled: !!id,
})
}
export function sessionPlanItemsOptions(sessionId: string) {
return queryOptions({
queryKey: sessionKeys.planItems(sessionId),
queryFn: () => api.get<SessionPlanItem[]>(`/v1/lesson-sessions/${sessionId}/plan-items`),
enabled: !!sessionId,
})
}
export const sessionMutations = {
update: (id: string, data: Record<string, unknown>) =>
api.patch<LessonSession>(`/v1/lesson-sessions/${id}`, data),
updateStatus: (id: string, status: string) =>
api.post<LessonSession>(`/v1/lesson-sessions/${id}/status`, { status }),
updateNotes: (id: string, data: Record<string, unknown>) =>
api.post<LessonSession>(`/v1/lesson-sessions/${id}/notes`, data),
linkPlanItems: (id: string, lessonPlanItemIds: string[]) =>
api.post<{ linked: number; items: SessionPlanItem[] }>(`/v1/lesson-sessions/${id}/plan-items`, { lessonPlanItemIds }),
}
// --- Grading Scales ---
export const gradingScaleKeys = {
all: ['grading-scales'] as const,
list: (params: PaginationInput) => [...gradingScaleKeys.all, 'list', params] as const,
allScales: [...['grading-scales'], 'all'] as const,
detail: (id: string) => [...gradingScaleKeys.all, 'detail', id] as const,
}
export function gradingScaleListOptions(params: PaginationInput) {
return queryOptions({
queryKey: gradingScaleKeys.list(params),
queryFn: () => api.get<PaginatedResponse<GradingScale>>('/v1/grading-scales', params),
})
}
export function gradingScaleAllOptions() {
return queryOptions({
queryKey: gradingScaleKeys.allScales,
queryFn: () => api.get<GradingScale[]>('/v1/grading-scales/all'),
})
}
export function gradingScaleDetailOptions(id: string) {
return queryOptions({
queryKey: gradingScaleKeys.detail(id),
queryFn: () => api.get<GradingScale>(`/v1/grading-scales/${id}`),
enabled: !!id,
})
}
export const gradingScaleMutations = {
create: (data: Record<string, unknown>) =>
api.post<GradingScale>('/v1/grading-scales', data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<GradingScale>(`/v1/grading-scales/${id}`, data),
delete: (id: string) =>
api.del<GradingScale>(`/v1/grading-scales/${id}`),
}
// --- Lesson Plans ---
export const lessonPlanKeys = {
all: ['lesson-plans'] as const,
list: (params: Record<string, unknown>) => [...lessonPlanKeys.all, 'list', params] as const,
detail: (id: string) => [...lessonPlanKeys.all, 'detail', id] as const,
}
export function lessonPlanListOptions(params: Record<string, unknown>) {
return queryOptions({
queryKey: lessonPlanKeys.list(params),
queryFn: () => api.get<PaginatedResponse<LessonPlan>>('/v1/lesson-plans', params),
})
}
export function lessonPlanDetailOptions(id: string) {
return queryOptions({
queryKey: lessonPlanKeys.detail(id),
queryFn: () => api.get<LessonPlan>(`/v1/lesson-plans/${id}`),
enabled: !!id,
})
}
export const lessonPlanMutations = {
create: (data: Record<string, unknown>) =>
api.post<LessonPlan>('/v1/lesson-plans', data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<LessonPlan>(`/v1/lesson-plans/${id}`, data),
}
// --- Lesson Plan Items ---
export const lessonPlanItemKeys = {
gradeHistory: (itemId: string) => ['lesson-plan-items', itemId, 'grade-history'] as const,
}
export function lessonPlanItemGradeHistoryOptions(itemId: string) {
return queryOptions({
queryKey: lessonPlanItemKeys.gradeHistory(itemId),
queryFn: () => api.get<LessonPlanItemGradeHistory[]>(`/v1/lesson-plan-items/${itemId}/grade-history`),
enabled: !!itemId,
})
}
export const lessonPlanItemMutations = {
update: (id: string, data: Record<string, unknown>) =>
api.patch<LessonPlanItem>(`/v1/lesson-plan-items/${id}`, data),
addGrade: (id: string, data: Record<string, unknown>) =>
api.post<{ record: LessonPlanItemGradeHistory; item: LessonPlanItem }>(`/v1/lesson-plan-items/${id}/grades`, data),
}
// --- Lesson Plan Templates ---
export const lessonPlanTemplateKeys = {
all: ['lesson-plan-templates'] as const,
list: (params: PaginationInput) => [...lessonPlanTemplateKeys.all, 'list', params] as const,
detail: (id: string) => [...lessonPlanTemplateKeys.all, 'detail', id] as const,
}
export function lessonPlanTemplateListOptions(params: PaginationInput) {
return queryOptions({
queryKey: lessonPlanTemplateKeys.list(params),
queryFn: () => api.get<PaginatedResponse<LessonPlanTemplate>>('/v1/lesson-plan-templates', params),
})
}
export function lessonPlanTemplateDetailOptions(id: string) {
return queryOptions({
queryKey: lessonPlanTemplateKeys.detail(id),
queryFn: () => api.get<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`),
enabled: !!id,
})
}
export const lessonPlanTemplateMutations = {
create: (data: Record<string, unknown>) =>
api.post<LessonPlanTemplate>('/v1/lesson-plan-templates', data),
update: (id: string, data: Record<string, unknown>) =>
api.patch<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`, data),
delete: (id: string) =>
api.del<LessonPlanTemplate>(`/v1/lesson-plan-templates/${id}`),
createPlan: (templateId: string, data: Record<string, unknown>) =>
api.post<LessonPlan>(`/v1/lesson-plan-templates/${templateId}/create-plan`, data),
}
// --- Store Closures ---
export const storeClosureKeys = {
all: ['store-closures'] as const,
}
export function storeClosureListOptions() {
return queryOptions({
queryKey: storeClosureKeys.all,
queryFn: () => api.get<StoreClosure[]>('/v1/store-closures'),
})
}
export const storeClosureMutations = {
create: (data: Record<string, unknown>) =>
api.post<StoreClosure>('/v1/store-closures', data),
delete: (id: string) =>
api.del<StoreClosure>(`/v1/store-closures/${id}`),
}

View File

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { Member } from '@/types/account'
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
interface MemberWithAccount extends Member {
accountName: string | null

View File

@@ -0,0 +1,28 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
export interface ModuleConfig {
id: string
slug: string
name: string
description: string | null
licensed: boolean
enabled: boolean
createdAt: string
updatedAt: string
}
export const moduleKeys = {
list: ['modules'] as const,
}
export function moduleListOptions() {
return queryOptions({
queryKey: moduleKeys.list,
queryFn: () => api.get<{ data: ModuleConfig[] }>('/v1/modules'),
})
}
export const moduleMutations = {
toggle: (slug: string, enabled: boolean) => api.patch<ModuleConfig>(`/v1/modules/${slug}`, { enabled }),
}

View File

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { PaymentMethod } from '@/types/account'
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
export const paymentMethodKeys = {
all: (accountId: string) => ['accounts', accountId, 'payment-methods'] as const,

View File

@@ -0,0 +1,225 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
// --- Types ---
export interface Transaction {
id: string
locationId: string | null
transactionNumber: string
accountId: string | null
repairTicketId: string | null
repairBatchId: string | null
transactionType: string
status: string
subtotal: string
discountTotal: string
taxTotal: string
total: string
paymentMethod: string | null
amountTendered: string | null
changeGiven: string | null
checkNumber: string | null
roundingAdjustment: string
taxExempt: boolean
taxExemptReason: string | null
processedBy: string
drawerSessionId: string | null
notes: string | null
completedAt: string | null
createdAt: string
updatedAt: string
lineItems?: TransactionLineItem[]
}
export interface TransactionLineItem {
id: string
transactionId: string
productId: string | null
inventoryUnitId: string | null
description: string
qty: number
unitPrice: string
discountAmount: string
discountReason: string | null
taxRate: string
taxAmount: string
lineTotal: string
createdAt: string
}
export interface DrawerSession {
id: string
locationId: string | null
openedBy: string
closedBy: string | null
openingBalance: string
closingBalance: string | null
expectedBalance: string | null
overShort: string | null
denominations: Record<string, number> | null
status: string
notes: string | null
openedAt: string
closedAt: string | null
}
export interface Discount {
id: string
name: string
discountType: string
discountValue: string
appliesTo: string
requiresApprovalAbove: string | null
isActive: boolean
}
export interface Product {
id: string
name: string
sku: string | null
upc: string | null
description: string | null
price: string | null
costPrice: string | null
qtyOnHand: number | null
taxCategory: string
isSerialized: boolean
isActive: boolean
}
export interface Register {
id: string
locationId: string
name: string
isActive: boolean
createdAt: string
updatedAt: string
}
// --- Query Keys ---
export interface DrawerAdjustment {
id: string
drawerSessionId: string
type: string
amount: string
reason: string
createdBy: string
createdAt: string
}
export const posKeys = {
transaction: (id: string) => ['pos', 'transaction', id] 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,
discounts: ['pos', 'discounts'] as const,
}
// --- Query Options ---
export function transactionOptions(id: string | null) {
return queryOptions({
queryKey: posKeys.transaction(id ?? ''),
queryFn: () => api.get<Transaction>(`/v1/transactions/${id}`),
enabled: !!id,
})
}
export function currentDrawerOptions(locationId: string | null) {
return queryOptions({
queryKey: posKeys.drawer(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,
retry: false,
})
}
export function productSearchOptions(search: string) {
return queryOptions({
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, isConsumable: false }),
enabled: search.length >= 1,
})
}
export function discountListOptions() {
return queryOptions({
queryKey: posKeys.discounts,
queryFn: () => api.get<Discount[]>('/v1/discounts/all'),
})
}
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 ---
export const posMutations = {
createTransaction: (data: { transactionType: string; locationId?: string; accountId?: string }) =>
api.post<Transaction>('/v1/transactions', data),
addLineItem: (txnId: string, data: { productId?: string; inventoryUnitId?: string; description: string; qty: number; unitPrice: number }) =>
api.post<TransactionLineItem>(`/v1/transactions/${txnId}/line-items`, data),
removeLineItem: (txnId: string, lineItemId: string) =>
api.del<TransactionLineItem>(`/v1/transactions/${txnId}/line-items/${lineItemId}`),
applyDiscount: (txnId: string, data: { discountId?: string; amount: number; reason: string; lineItemId?: string }) =>
api.post<Transaction>(`/v1/transactions/${txnId}/discounts`, data),
complete: (txnId: string, data: { paymentMethod: string; amountTendered?: number; checkNumber?: string }) =>
api.post<Transaction>(`/v1/transactions/${txnId}/complete`, data),
void: (txnId: string) =>
api.post<Transaction>(`/v1/transactions/${txnId}/void`, {}),
openDrawer: (data: { locationId?: string; registerId?: string; openingBalance: number }) =>
api.post<DrawerSession>('/v1/drawer/open', data),
closeDrawer: (id: string, data: { closingBalance: number; denominations?: Record<string, number>; notes?: string }) =>
api.post<DrawerSession>(`/v1/drawer/${id}/close`, data),
lookupUpc: (upc: string) =>
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

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { ProcessorLink } from '@/types/account'
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
export const processorLinkKeys = {
all: (accountId: string) => ['accounts', accountId, 'processor-links'] as const,

View File

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { Permission, Role } from '@/types/rbac'
import type { PaginationInput, PaginatedResponse } from '@forte/shared/schemas'
import type { PaginationInput, PaginatedResponse } from '@lunarfront/shared/schemas'
export const rbacKeys = {
permissions: ['permissions'] as const,

View File

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { RepairTicket, RepairLineItem, RepairBatch, RepairNote, RepairServiceTemplate } from '@/types/repair'
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
// --- Repair Tickets ---

View File

@@ -0,0 +1,73 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { StorageFolder, StorageFolderPermission, StorageFile } from '@/types/storage'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
// --- Folders ---
export const storageFolderKeys = {
all: ['storage-folders'] as const,
tree: ['storage-folders', 'tree'] as const,
children: (parentId: string | null) => ['storage-folders', 'children', parentId] as const,
detail: (id: string) => ['storage-folders', 'detail', id] as const,
permissions: (id: string) => ['storage-folders', id, 'permissions'] as const,
}
export function storageFolderTreeOptions() {
return queryOptions({
queryKey: storageFolderKeys.tree,
queryFn: () => api.get<{ data: StorageFolder[] }>('/v1/storage/folders/tree'),
})
}
export function storageFolderChildrenOptions(parentId: string | null) {
return queryOptions({
queryKey: storageFolderKeys.children(parentId),
queryFn: () => api.get<{ data: StorageFolder[] }>('/v1/storage/folders', parentId ? { parentId } : {}),
})
}
export function storageFolderDetailOptions(id: string) {
return queryOptions({
queryKey: storageFolderKeys.detail(id),
queryFn: () => api.get<StorageFolder & { breadcrumbs: { id: string; name: string }[] }>(`/v1/storage/folders/${id}`),
enabled: !!id,
})
}
export function storageFolderPermissionsOptions(id: string) {
return queryOptions({
queryKey: storageFolderKeys.permissions(id),
queryFn: () => api.get<{ data: StorageFolderPermission[] }>(`/v1/storage/folders/${id}/permissions`),
enabled: !!id,
})
}
export const storageFolderMutations = {
create: (data: Record<string, unknown>) => api.post<StorageFolder>('/v1/storage/folders', data),
update: (id: string, data: Record<string, unknown>) => api.patch<StorageFolder>(`/v1/storage/folders/${id}`, data),
delete: (id: string) => api.del<StorageFolder>(`/v1/storage/folders/${id}`),
addPermission: (folderId: string, data: Record<string, unknown>) => api.post<StorageFolderPermission>(`/v1/storage/folders/${folderId}/permissions`, data),
removePermission: (permId: string) => api.del<StorageFolderPermission>(`/v1/storage/folder-permissions/${permId}`),
}
// --- Files ---
export const storageFileKeys = {
all: (folderId: string) => ['storage-files', folderId] as const,
list: (folderId: string, params: PaginationInput) => ['storage-files', folderId, params] as const,
search: (q: string) => ['storage-files', 'search', q] as const,
}
export function storageFileListOptions(folderId: string, params: PaginationInput) {
return queryOptions({
queryKey: storageFileKeys.list(folderId, params),
queryFn: () => api.get<PaginatedResponse<StorageFile>>(`/v1/storage/folders/${folderId}/files`, params),
enabled: !!folderId,
})
}
export const storageFileMutations = {
delete: (id: string) => api.del<StorageFile>(`/v1/storage/files/${id}`),
getSignedUrl: (id: string) => api.get<{ url: string }>(`/v1/storage/files/${id}/signed-url`),
}

View File

@@ -1,7 +1,7 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { TaxExemption } from '@/types/account'
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
export const taxExemptionKeys = {
all: (accountId: string) => ['accounts', accountId, 'tax-exemptions'] as const,

View File

@@ -1,6 +1,6 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { PaginationInput, PaginatedResponse } from '@forte/shared/schemas'
import type { PaginationInput, PaginatedResponse } from '@lunarfront/shared/schemas'
export interface UserRole {
id: string

View File

@@ -0,0 +1,91 @@
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import type { VaultStatus, VaultCategory, VaultCategoryPermission, VaultEntry } from '@/types/vault'
import type { PaginatedResponse, PaginationInput } from '@lunarfront/shared/schemas'
// --- Keys ---
export const vaultKeys = {
status: ['vault-status'] as const,
categories: ['vault-categories'] as const,
categoryDetail: (id: string) => ['vault-categories', id] as const,
categoryPermissions: (id: string) => ['vault-categories', id, 'permissions'] as const,
entries: (categoryId: string) => ['vault-entries', categoryId] as const,
entryList: (categoryId: string, params: PaginationInput) => ['vault-entries', categoryId, params] as const,
entryDetail: (id: string) => ['vault-entry', id] as const,
}
// --- Queries ---
export function vaultStatusOptions() {
return queryOptions({
queryKey: vaultKeys.status,
queryFn: () => api.get<VaultStatus>('/v1/vault/status'),
})
}
export function vaultCategoryListOptions() {
return queryOptions({
queryKey: vaultKeys.categories,
queryFn: () => api.get<{ data: VaultCategory[] }>('/v1/vault/categories'),
})
}
export function vaultCategoryDetailOptions(id: string) {
return queryOptions({
queryKey: vaultKeys.categoryDetail(id),
queryFn: () => api.get<VaultCategory>(`/v1/vault/categories/${id}`),
enabled: !!id,
})
}
export function vaultCategoryPermissionsOptions(id: string) {
return queryOptions({
queryKey: vaultKeys.categoryPermissions(id),
queryFn: () => api.get<{ data: VaultCategoryPermission[] }>(`/v1/vault/categories/${id}/permissions`),
enabled: !!id,
})
}
export function vaultEntryListOptions(categoryId: string, params: PaginationInput) {
return queryOptions({
queryKey: vaultKeys.entryList(categoryId, params),
queryFn: () => api.get<PaginatedResponse<VaultEntry>>(`/v1/vault/categories/${categoryId}/entries`, params),
enabled: !!categoryId,
})
}
export function vaultEntryDetailOptions(id: string) {
return queryOptions({
queryKey: vaultKeys.entryDetail(id),
queryFn: () => api.get<VaultEntry>(`/v1/vault/entries/${id}`),
enabled: !!id,
})
}
// --- Mutations ---
export const vaultMutations = {
initialize: (masterPassword: string) => api.post('/v1/vault/initialize', { masterPassword }),
unlock: (masterPassword: string) => api.post('/v1/vault/unlock', { masterPassword }),
lock: () => api.post('/v1/vault/lock', {}),
changeMasterPassword: (currentPassword: string, newPassword: string) =>
api.post('/v1/vault/change-master-password', { currentPassword, newPassword }),
}
export const vaultCategoryMutations = {
create: (data: Record<string, unknown>) => api.post<VaultCategory>('/v1/vault/categories', data),
update: (id: string, data: Record<string, unknown>) => api.patch<VaultCategory>(`/v1/vault/categories/${id}`, data),
delete: (id: string) => api.del<VaultCategory>(`/v1/vault/categories/${id}`),
addPermission: (categoryId: string, data: Record<string, unknown>) =>
api.post<VaultCategoryPermission>(`/v1/vault/categories/${categoryId}/permissions`, data),
removePermission: (permId: string) => api.del<VaultCategoryPermission>(`/v1/vault/category-permissions/${permId}`),
}
export const vaultEntryMutations = {
create: (categoryId: string, data: Record<string, unknown>) =>
api.post<VaultEntry>(`/v1/vault/categories/${categoryId}/entries`, data),
update: (id: string, data: Record<string, unknown>) => api.patch<VaultEntry>(`/v1/vault/entries/${id}`, data),
delete: (id: string) => api.del<VaultEntry>(`/v1/vault/entries/${id}`),
reveal: (id: string) => api.post<{ value: string | null }>(`/v1/vault/entries/${id}/reveal`, {}),
}

View File

@@ -88,3 +88,36 @@ body {
border-color: #2a3a52 !important;
transition: background-color 5000s ease-in-out 0s;
}
/* Scrollbars — themed to match sidebar/app palette */
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--border);
border-radius: 9999px;
}
*::-webkit-scrollbar-thumb:hover {
background-color: var(--muted-foreground);
}
/* Prevent browser autofill from overriding dark theme input colors */
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 1000px hsl(var(--background)) inset !important;
-webkit-text-fill-color: hsl(var(--foreground)) !important;
transition: background-color 5000s ease-in-out 0s;
}

View File

@@ -1,7 +1,7 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { AccountCreateSchema } from '@forte/shared/schemas'
import { AccountCreateSchema } from '@lunarfront/shared/schemas'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'

View File

@@ -1,7 +1,6 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { PaymentMethodCreateSchema } from '@forte/shared/schemas'
import type { PaymentMethodCreateInput } from '@forte/shared/schemas'
import { PaymentMethodCreateSchema } from '@lunarfront/shared/schemas'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'

View File

@@ -1,7 +1,7 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { ProcessorLinkCreateSchema } from '@forte/shared/schemas'
import type { ProcessorLinkCreateInput } from '@forte/shared/schemas'
import { ProcessorLinkCreateSchema } from '@lunarfront/shared/schemas'
import type { ProcessorLinkCreateInput } from '@lunarfront/shared/schemas'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'

View File

@@ -1,7 +1,6 @@
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { TaxExemptionCreateSchema } from '@forte/shared/schemas'
import type { TaxExemptionCreateInput } from '@forte/shared/schemas'
import { TaxExemptionCreateSchema } from '@lunarfront/shared/schemas'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'

View File

@@ -0,0 +1,91 @@
import { useForm } from 'react-hook-form'
import { useQuery } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { categoryAllOptions } from '@/api/inventory'
import type { Category } from '@/types/inventory'
interface Props {
defaultValues?: Partial<Category>
onSubmit: (data: Record<string, unknown>) => void
onDelete?: () => void
loading?: boolean
deleteLoading?: boolean
}
export function CategoryForm({ defaultValues, onSubmit, onDelete, loading, deleteLoading }: Props) {
const { data: allCats } = useQuery(categoryAllOptions())
const categories = (allCats?.data ?? []).filter((c) => c.id !== defaultValues?.id && c.isActive)
const { register, handleSubmit, setValue, watch } = useForm({
defaultValues: {
name: defaultValues?.name ?? '',
parentId: defaultValues?.parentId ?? '',
sortOrder: defaultValues?.sortOrder ?? 0,
isActive: defaultValues?.isActive ?? true,
},
})
const parentId = watch('parentId')
const isActive = watch('isActive')
function handleFormSubmit(data: { name: string; parentId: string; sortOrder: number; isActive: boolean }) {
onSubmit({
name: data.name,
parentId: data.parentId || undefined,
sortOrder: Number(data.sortOrder),
isActive: data.isActive,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cat-name">Name *</Label>
<Input id="cat-name" {...register('name')} placeholder="e.g. Guitars, Accessories" required />
</div>
<div className="space-y-2">
<Label>Parent Category</Label>
<Select value={parentId || 'none'} onValueChange={(v) => setValue('parentId', v === 'none' ? '' : v)}>
<SelectTrigger>
<SelectValue placeholder="None (Top Level)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (Top Level)</SelectItem>
{categories.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="cat-order">Sort Order</Label>
<Input id="cat-order" type="number" min={0} {...register('sortOrder')} />
</div>
<div className="flex items-center gap-2 pt-7">
<input
id="cat-active"
type="checkbox"
checked={isActive}
onChange={(e) => setValue('isActive', e.target.checked)}
className="h-4 w-4"
/>
<Label htmlFor="cat-active">Active</Label>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={loading} className="flex-1">
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Category'}
</Button>
{onDelete && (
<Button type="button" variant="destructive" disabled={deleteLoading} onClick={onDelete}>
{deleteLoading ? 'Deleting...' : 'Delete'}
</Button>
)}
</div>
</form>
)
}

View File

@@ -0,0 +1,118 @@
import { useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { InventoryUnit, UnitCondition, UnitStatus } from '@/types/inventory'
const CONDITIONS: { value: UnitCondition; label: string }[] = [
{ value: 'new', label: 'New' },
{ value: 'excellent', label: 'Excellent' },
{ value: 'good', label: 'Good' },
{ value: 'fair', label: 'Fair' },
{ value: 'poor', label: 'Poor' },
]
const STATUSES: { value: UnitStatus; label: string }[] = [
{ value: 'available', label: 'Available' },
{ value: 'sold', label: 'Sold' },
{ value: 'rented', label: 'Rented' },
{ value: 'on_trial', label: 'On Trial' },
{ value: 'in_repair', label: 'In Repair' },
{ value: 'layaway', label: 'Layaway' },
{ value: 'lost', label: 'Lost' },
{ value: 'retired', label: 'Retired' },
]
interface Props {
defaultValues?: Partial<InventoryUnit>
onSubmit: (data: Record<string, unknown>) => void
loading?: boolean
}
export function InventoryUnitForm({ defaultValues, onSubmit, loading }: Props) {
const { register, handleSubmit, setValue, watch } = useForm({
defaultValues: {
serialNumber: defaultValues?.serialNumber ?? '',
condition: (defaultValues?.condition ?? 'new') as UnitCondition,
status: (defaultValues?.status ?? 'available') as UnitStatus,
purchaseDate: defaultValues?.purchaseDate ?? '',
purchaseCost: defaultValues?.purchaseCost ?? '',
notes: defaultValues?.notes ?? '',
},
})
const condition = watch('condition')
const status = watch('status')
function handleFormSubmit(data: Record<string, unknown>) {
onSubmit({
serialNumber: (data.serialNumber as string) || undefined,
condition: data.condition,
status: data.status,
purchaseDate: (data.purchaseDate as string) || undefined,
purchaseCost: (data.purchaseCost as string) || undefined,
notes: (data.notes as string) || undefined,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="unit-serial">Serial Number</Label>
<Input id="unit-serial" {...register('serialNumber')} placeholder="e.g. US22041234" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Condition</Label>
<Select value={condition} onValueChange={(v) => setValue('condition', v as UnitCondition)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONDITIONS.map((c) => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select value={status} onValueChange={(v) => setValue('status', v as UnitStatus)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUSES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="unit-date">Purchase Date</Label>
<Input id="unit-date" type="date" {...register('purchaseDate')} />
</div>
<div className="space-y-2">
<Label htmlFor="unit-cost">Purchase Cost</Label>
<Input id="unit-cost" type="number" step="0.01" min="0" {...register('purchaseCost')} placeholder="0.00" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="unit-notes">Notes</Label>
<textarea
id="unit-notes"
{...register('notes')}
rows={2}
placeholder="Any notes about this unit..."
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
/>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Add Unit'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,179 @@
import { useForm } from 'react-hook-form'
import { useQuery } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { categoryAllOptions } from '@/api/inventory'
import type { Product } from '@/types/inventory'
interface Props {
defaultValues?: Partial<Product>
onSubmit: (data: Record<string, unknown>) => void
loading?: boolean
}
export function ProductForm({ defaultValues, onSubmit, loading }: Props) {
const { data: allCats } = useQuery(categoryAllOptions())
const categories = (allCats?.data ?? []).filter((c) => c.isActive)
const { register, handleSubmit, setValue, watch } = useForm({
defaultValues: {
name: defaultValues?.name ?? '',
sku: defaultValues?.sku ?? '',
upc: defaultValues?.upc ?? '',
brand: defaultValues?.brand ?? '',
model: defaultValues?.model ?? '',
description: defaultValues?.description ?? '',
categoryId: defaultValues?.categoryId ?? '',
price: defaultValues?.price ?? '',
minPrice: defaultValues?.minPrice ?? '',
rentalRateMonthly: defaultValues?.rentalRateMonthly ?? '',
qtyOnHand: defaultValues?.qtyOnHand ?? 0,
qtyReorderPoint: defaultValues?.qtyReorderPoint ?? '',
isSerialized: defaultValues?.isSerialized ?? false,
isRental: defaultValues?.isRental ?? false,
isDualUseRepair: defaultValues?.isDualUseRepair ?? false,
isConsumable: defaultValues?.isConsumable ?? false,
isActive: defaultValues?.isActive ?? true,
},
})
const categoryId = watch('categoryId')
const isRental = watch('isRental')
const isSerialized = watch('isSerialized')
const isDualUseRepair = watch('isDualUseRepair')
const isConsumable = watch('isConsumable')
const isActive = watch('isActive')
function handleFormSubmit(data: Record<string, unknown>) {
onSubmit({
name: data.name,
sku: (data.sku as string) || undefined,
upc: (data.upc as string) || undefined,
brand: (data.brand as string) || undefined,
model: (data.model as string) || undefined,
description: (data.description as string) || undefined,
categoryId: (data.categoryId as string) || undefined,
price: (data.price as string) ? Number(data.price) : undefined,
minPrice: (data.minPrice as string) ? Number(data.minPrice) : undefined,
rentalRateMonthly: isRental && (data.rentalRateMonthly as string) ? Number(data.rentalRateMonthly) : undefined,
qtyOnHand: Number(data.qtyOnHand),
qtyReorderPoint: (data.qtyReorderPoint as string) ? Number(data.qtyReorderPoint) : undefined,
isSerialized: data.isSerialized,
isRental: data.isRental,
isDualUseRepair: data.isDualUseRepair,
isConsumable: data.isConsumable,
isActive: data.isActive,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="p-name">Name *</Label>
<Input id="p-name" {...register('name')} placeholder="e.g. Fender Player Stratocaster" required />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="p-sku">SKU</Label>
<Input id="p-sku" {...register('sku')} placeholder="STR-001" />
</div>
<div className="space-y-2">
<Label htmlFor="p-upc">UPC / Barcode</Label>
<Input id="p-upc" {...register('upc')} placeholder="0123456789" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="p-brand">Brand</Label>
<Input id="p-brand" {...register('brand')} placeholder="Fender" />
</div>
<div className="space-y-2">
<Label htmlFor="p-model">Model</Label>
<Input id="p-model" {...register('model')} placeholder="Player Stratocaster" />
</div>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Select value={categoryId || 'none'} onValueChange={(v) => setValue('categoryId', v === 'none' ? '' : v)}>
<SelectTrigger>
<SelectValue placeholder="Select category..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No Category</SelectItem>
{categories.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="p-price">Price</Label>
<Input id="p-price" type="number" step="0.01" min="0" {...register('price')} placeholder="0.00" />
</div>
<div className="space-y-2">
<Label htmlFor="p-min-price">Min Price</Label>
<Input id="p-min-price" type="number" step="0.01" min="0" {...register('minPrice')} placeholder="0.00" />
</div>
{isRental && (
<div className="space-y-2">
<Label htmlFor="p-rental-rate">Rental / Month</Label>
<Input id="p-rental-rate" type="number" step="0.01" min="0" {...register('rentalRateMonthly')} placeholder="0.00" />
</div>
)}
</div>
{!isSerialized && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="p-qty">Qty On Hand</Label>
<Input id="p-qty" type="number" min="0" {...register('qtyOnHand')} />
</div>
<div className="space-y-2">
<Label htmlFor="p-reorder">Reorder Point</Label>
<Input id="p-reorder" type="number" min="0" {...register('qtyReorderPoint')} placeholder="—" />
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="p-desc">Description</Label>
<textarea
id="p-desc"
{...register('description')}
rows={3}
placeholder="Product description..."
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-medium">Options</Label>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isSerialized} onChange={(e) => setValue('isSerialized', e.target.checked)} className="h-4 w-4" />
Serialized (track individual units)
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isRental} onChange={(e) => setValue('isRental', e.target.checked)} className="h-4 w-4" />
Available for Rental
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isDualUseRepair} onChange={(e) => setValue('isDualUseRepair', e.target.checked)} className="h-4 w-4" />
Available as Repair Line Item
</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">
<input type="checkbox" checked={isActive} onChange={(e) => setValue('isActive', e.target.checked)} className="h-4 w-4" />
Active
</label>
</div>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Product'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,88 @@
import { useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { Supplier } from '@/types/inventory'
interface Props {
defaultValues?: Partial<Supplier>
onSubmit: (data: Record<string, unknown>) => void
onDelete?: () => void
loading?: boolean
deleteLoading?: boolean
}
export function SupplierForm({ defaultValues, onSubmit, onDelete, loading, deleteLoading }: Props) {
const { register, handleSubmit } = useForm({
defaultValues: {
name: defaultValues?.name ?? '',
contactName: defaultValues?.contactName ?? '',
email: defaultValues?.email ?? '',
phone: defaultValues?.phone ?? '',
website: defaultValues?.website ?? '',
accountNumber: defaultValues?.accountNumber ?? '',
paymentTerms: defaultValues?.paymentTerms ?? '',
},
})
function handleFormSubmit(data: Record<string, string>) {
onSubmit({
name: data.name,
contactName: data.contactName || undefined,
email: data.email || undefined,
phone: data.phone || undefined,
website: data.website || undefined,
accountNumber: data.accountNumber || undefined,
paymentTerms: data.paymentTerms || undefined,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="sup-name">Name *</Label>
<Input id="sup-name" {...register('name')} placeholder="e.g. Fender Musical Instruments" required />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sup-contact">Contact Name</Label>
<Input id="sup-contact" {...register('contactName')} placeholder="Jane Smith" />
</div>
<div className="space-y-2">
<Label htmlFor="sup-email">Email</Label>
<Input id="sup-email" type="email" {...register('email')} placeholder="orders@supplier.com" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sup-phone">Phone</Label>
<Input id="sup-phone" {...register('phone')} placeholder="555-0100" />
</div>
<div className="space-y-2">
<Label htmlFor="sup-website">Website</Label>
<Input id="sup-website" {...register('website')} placeholder="https://supplier.com" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="sup-acct">Account Number</Label>
<Input id="sup-acct" {...register('accountNumber')} placeholder="ACC-12345" />
</div>
<div className="space-y-2">
<Label htmlFor="sup-terms">Payment Terms</Label>
<Input id="sup-terms" {...register('paymentTerms')} placeholder="Net 30" />
</div>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" disabled={loading} className="flex-1">
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Supplier'}
</Button>
{onDelete && (
<Button type="button" variant="destructive" disabled={deleteLoading} onClick={onDelete}>
{deleteLoading ? 'Deleting...' : 'Delete'}
</Button>
)}
</div>
</form>
)
}

View File

@@ -0,0 +1,45 @@
import { useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface Props {
onSubmit: (data: Record<string, unknown>) => void
loading?: boolean
}
export function BlockedDateForm({ onSubmit, loading }: Props) {
const { register, handleSubmit } = useForm({
defaultValues: { startDate: '', endDate: '', reason: '' },
})
function handleFormSubmit(data: { startDate: string; endDate: string; reason: string }) {
onSubmit({
startDate: data.startDate,
endDate: data.endDate,
reason: data.reason || undefined,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="bd-start">Start Date *</Label>
<Input id="bd-start" type="date" {...register('startDate')} required />
</div>
<div className="space-y-2">
<Label htmlFor="bd-end">End Date *</Label>
<Input id="bd-end" type="date" {...register('endDate')} required />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bd-reason">Reason</Label>
<Input id="bd-reason" {...register('reason')} placeholder="e.g. Vacation, Conference" />
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Saving...' : 'Add Blocked Date'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,127 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { gradingScaleAllOptions, lessonPlanItemMutations, lessonPlanItemGradeHistoryOptions, lessonPlanItemKeys } from '@/api/lessons'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { toast } from 'sonner'
import type { LessonPlanItem } from '@/types/lesson'
interface Props {
item: LessonPlanItem
open: boolean
onClose: () => void
}
export function GradeEntryDialog({ item, open, onClose }: Props) {
const queryClient = useQueryClient()
const [selectedScaleId, setSelectedScaleId] = useState(item.gradingScaleId ?? '')
const [selectedValue, setSelectedValue] = useState('')
const [gradeNotes, setGradeNotes] = useState('')
const { data: scales } = useQuery(gradingScaleAllOptions())
const { data: history } = useQuery(lessonPlanItemGradeHistoryOptions(item.id))
const selectedScale = scales?.find((s) => s.id === selectedScaleId)
const gradeMutation = useMutation({
mutationFn: () =>
lessonPlanItemMutations.addGrade(item.id, {
gradingScaleId: selectedScaleId || undefined,
gradeValue: selectedValue,
notes: gradeNotes || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: lessonPlanItemKeys.gradeHistory(item.id) })
toast.success('Grade recorded')
setSelectedValue('')
setGradeNotes('')
},
onError: (err) => toast.error(err.message),
})
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose() }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Grade: {item.title}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Grading Scale</Label>
<Select value={selectedScaleId || 'none'} onValueChange={(v) => { setSelectedScaleId(v === 'none' ? '' : v); setSelectedValue('') }}>
<SelectTrigger>
<SelectValue placeholder="No scale (freeform)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No scale (freeform)</SelectItem>
{(scales ?? []).map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Grade Value *</Label>
{selectedScale ? (
<Select value={selectedValue} onValueChange={setSelectedValue}>
<SelectTrigger>
<SelectValue placeholder="Select grade..." />
</SelectTrigger>
<SelectContent>
{[...selectedScale.levels].sort((a, b) => a.sortOrder - b.sortOrder).map((level) => (
<SelectItem key={level.id} value={level.value}>
{level.value} {level.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={selectedValue}
onChange={(e) => setSelectedValue(e.target.value)}
placeholder="Enter grade (e.g. A, Pass, 85)"
/>
)}
</div>
<div className="space-y-2">
<Label>Notes</Label>
<Textarea value={gradeNotes} onChange={(e) => setGradeNotes(e.target.value)} rows={2} />
</div>
<Button
onClick={() => gradeMutation.mutate()}
disabled={!selectedValue || gradeMutation.isPending}
className="w-full"
>
{gradeMutation.isPending ? 'Recording...' : 'Record Grade'}
</Button>
</div>
{/* Grade History */}
{(history ?? []).length > 0 && (
<div className="border-t pt-4 space-y-2">
<p className="text-sm font-medium text-muted-foreground">Grade History</p>
<div className="space-y-2 max-h-40 overflow-y-auto">
{[...history!].reverse().map((h) => (
<div key={h.id} className="flex items-start justify-between text-sm">
<div>
<span className="font-medium">{h.gradeValue}</span>
{h.notes && <p className="text-xs text-muted-foreground">{h.notes}</p>}
</div>
<span className="text-xs text-muted-foreground">{new Date(h.createdAt).toLocaleDateString()}</span>
</div>
))}
</div>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,127 @@
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Trash2, Plus } from 'lucide-react'
interface LevelRow {
value: string
label: string
numericValue: string
colorHex: string
}
interface Props {
onSubmit: (data: Record<string, unknown>) => void
loading?: boolean
}
const DEFAULT_LEVELS: LevelRow[] = [
{ value: 'A', label: 'Excellent', numericValue: '4', colorHex: '#22c55e' },
{ value: 'B', label: 'Good', numericValue: '3', colorHex: '#84cc16' },
{ value: 'C', label: 'Developing', numericValue: '2', colorHex: '#eab308' },
{ value: 'D', label: 'Beginning', numericValue: '1', colorHex: '#f97316' },
]
export function GradingScaleForm({ onSubmit, loading }: Props) {
const { register, handleSubmit } = useForm({
defaultValues: { name: '', description: '', isDefault: false },
})
const [levels, setLevels] = useState<LevelRow[]>(DEFAULT_LEVELS)
function addLevel() {
setLevels((prev) => [...prev, { value: '', label: '', numericValue: String(prev.length + 1), colorHex: '' }])
}
function removeLevel(idx: number) {
setLevels((prev) => prev.filter((_, i) => i !== idx))
}
function updateLevel(idx: number, field: keyof LevelRow, value: string) {
setLevels((prev) => prev.map((l, i) => (i === idx ? { ...l, [field]: value } : l)))
}
function handleFormSubmit(data: { name: string; description: string; isDefault: boolean }) {
onSubmit({
name: data.name,
description: data.description || undefined,
isDefault: data.isDefault,
levels: levels.map((l, i) => ({
value: l.value,
label: l.label,
numericValue: Number(l.numericValue) || i + 1,
colorHex: l.colorHex || undefined,
sortOrder: i,
})),
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="gs-name">Name *</Label>
<Input id="gs-name" {...register('name')} placeholder="e.g. RCM Performance Scale" required />
</div>
<div className="space-y-2">
<Label htmlFor="gs-desc">Description</Label>
<Textarea id="gs-desc" {...register('description')} rows={2} />
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="gs-default" {...register('isDefault')} className="h-4 w-4" />
<Label htmlFor="gs-default">Set as default scale</Label>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Grade Levels</Label>
<Button type="button" variant="outline" size="sm" onClick={addLevel}>
<Plus className="h-3 w-3 mr-1" />Add Level
</Button>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto pr-1">
{levels.map((level, idx) => (
<div key={idx} className="grid grid-cols-[1fr_2fr_1fr_auto_auto] gap-2 items-center">
<Input
placeholder="Value"
value={level.value}
onChange={(e) => updateLevel(idx, 'value', e.target.value)}
required
/>
<Input
placeholder="Label"
value={level.label}
onChange={(e) => updateLevel(idx, 'label', e.target.value)}
required
/>
<Input
type="number"
placeholder="Score"
value={level.numericValue}
onChange={(e) => updateLevel(idx, 'numericValue', e.target.value)}
/>
<input
type="color"
value={level.colorHex || '#888888'}
onChange={(e) => updateLevel(idx, 'colorHex', e.target.value)}
className="h-9 w-9 rounded border border-input cursor-pointer"
title="Color"
/>
<Button type="button" variant="ghost" size="icon" onClick={() => removeLevel(idx)} className="h-9 w-9">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
))}
</div>
{levels.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-2">No levels add at least one.</p>
)}
</div>
<Button type="submit" disabled={loading || levels.length === 0} className="w-full">
{loading ? 'Saving...' : 'Create Grading Scale'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,52 @@
import { useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import type { Instructor } from '@/types/lesson'
interface Props {
defaultValues?: Partial<Instructor>
onSubmit: (data: Record<string, unknown>) => void
loading?: boolean
}
export function InstructorForm({ defaultValues, onSubmit, loading }: Props) {
const { register, handleSubmit } = useForm({
defaultValues: {
displayName: defaultValues?.displayName ?? '',
bio: defaultValues?.bio ?? '',
instruments: defaultValues?.instruments?.join(', ') ?? '',
},
})
function handleFormSubmit(data: { displayName: string; bio: string; instruments: string }) {
onSubmit({
displayName: data.displayName,
bio: data.bio || undefined,
instruments: data.instruments
? data.instruments.split(',').map((s) => s.trim()).filter(Boolean)
: undefined,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="displayName">Display Name *</Label>
<Input id="displayName" {...register('displayName')} required />
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio</Label>
<Textarea id="bio" {...register('bio')} rows={3} />
</div>
<div className="space-y-2">
<Label htmlFor="instruments">Instruments</Label>
<Input id="instruments" {...register('instruments')} placeholder="Piano, Guitar, Voice (comma-separated)" />
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Saving...' : defaultValues ? 'Save Changes' : 'Create Instructor'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,99 @@
import { useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { LessonType } from '@/types/lesson'
interface Props {
defaultValues?: Partial<LessonType>
onSubmit: (data: Record<string, unknown>) => void
loading?: boolean
}
export function LessonTypeForm({ defaultValues, onSubmit, loading }: Props) {
const { register, handleSubmit, setValue, watch } = useForm({
defaultValues: {
name: defaultValues?.name ?? '',
instrument: defaultValues?.instrument ?? '',
durationMinutes: defaultValues?.durationMinutes ?? 30,
lessonFormat: (defaultValues?.lessonFormat ?? 'private') as 'private' | 'group',
rateWeekly: defaultValues?.rateWeekly ?? '',
rateMonthly: defaultValues?.rateMonthly ?? '',
rateQuarterly: defaultValues?.rateQuarterly ?? '',
},
})
const lessonFormat = watch('lessonFormat')
function handleFormSubmit(data: {
name: string
instrument: string
durationMinutes: number
lessonFormat: string
rateWeekly: string
rateMonthly: string
rateQuarterly: string
}) {
onSubmit({
name: data.name,
instrument: data.instrument || undefined,
durationMinutes: Number(data.durationMinutes),
lessonFormat: data.lessonFormat,
rateWeekly: data.rateWeekly || undefined,
rateMonthly: data.rateMonthly || undefined,
rateQuarterly: data.rateQuarterly || undefined,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="lt-name">Name *</Label>
<Input id="lt-name" {...register('name')} placeholder="e.g. Piano — 30 min Private" required />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="lt-instrument">Instrument</Label>
<Input id="lt-instrument" {...register('instrument')} placeholder="e.g. Piano, Guitar" />
</div>
<div className="space-y-2">
<Label htmlFor="lt-duration">Duration (minutes) *</Label>
<Input id="lt-duration" type="number" min={5} step={5} {...register('durationMinutes')} required />
</div>
</div>
<div className="space-y-2">
<Label>Format *</Label>
<Select value={lessonFormat} onValueChange={(v) => setValue('lessonFormat', v as 'private' | 'group')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="private">Private</SelectItem>
<SelectItem value="group">Group</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="block mb-2">Default Rates (optional)</Label>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label htmlFor="lt-rate-weekly" className="text-xs text-muted-foreground">Weekly</Label>
<Input id="lt-rate-weekly" type="number" step="0.01" min="0" {...register('rateWeekly')} placeholder="—" />
</div>
<div className="space-y-1">
<Label htmlFor="lt-rate-monthly" className="text-xs text-muted-foreground">Monthly</Label>
<Input id="lt-rate-monthly" type="number" step="0.01" min="0" {...register('rateMonthly')} placeholder="—" />
</div>
<div className="space-y-1">
<Label htmlFor="lt-rate-quarterly" className="text-xs text-muted-foreground">Quarterly</Label>
<Input id="lt-rate-quarterly" type="number" step="0.01" min="0" {...register('rateQuarterly')} placeholder="—" />
</div>
</div>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Create Lesson Type'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,124 @@
import { useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { LessonType, ScheduleSlot } from '@/types/lesson'
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
interface Props {
lessonTypes: LessonType[]
defaultValues?: Partial<ScheduleSlot>
onSubmit: (data: Record<string, unknown>) => void
loading?: boolean
}
export function ScheduleSlotForm({ lessonTypes, defaultValues, onSubmit, loading }: Props) {
const { register, handleSubmit, setValue, watch } = useForm({
defaultValues: {
dayOfWeek: String(defaultValues?.dayOfWeek ?? 1),
startTime: defaultValues?.startTime ?? '',
lessonTypeId: defaultValues?.lessonTypeId ?? '',
room: defaultValues?.room ?? '',
maxStudents: String(defaultValues?.maxStudents ?? 1),
rateWeekly: defaultValues?.rateWeekly ?? '',
rateMonthly: defaultValues?.rateMonthly ?? '',
rateQuarterly: defaultValues?.rateQuarterly ?? '',
},
})
const dayOfWeek = watch('dayOfWeek')
const lessonTypeId = watch('lessonTypeId')
function handleFormSubmit(data: {
dayOfWeek: string
startTime: string
lessonTypeId: string
room: string
maxStudents: string
rateWeekly: string
rateMonthly: string
rateQuarterly: string
}) {
onSubmit({
dayOfWeek: Number(data.dayOfWeek),
startTime: data.startTime,
lessonTypeId: data.lessonTypeId,
room: data.room || undefined,
maxStudents: Number(data.maxStudents) || 1,
rateWeekly: data.rateWeekly || undefined,
rateMonthly: data.rateMonthly || undefined,
rateQuarterly: data.rateQuarterly || undefined,
})
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Day *</Label>
<Select value={dayOfWeek} onValueChange={(v) => setValue('dayOfWeek', v)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DAYS.map((day, i) => (
<SelectItem key={i} value={String(i)}>{day}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="slot-time">Start Time *</Label>
<Input id="slot-time" type="time" {...register('startTime')} required />
</div>
</div>
<div className="space-y-2">
<Label>Lesson Type *</Label>
<Select value={lessonTypeId} onValueChange={(v) => setValue('lessonTypeId', v)}>
<SelectTrigger>
<SelectValue placeholder="Select lesson type..." />
</SelectTrigger>
<SelectContent>
{lessonTypes.map((lt) => (
<SelectItem key={lt.id} value={lt.id}>
{lt.name} ({lt.durationMinutes} min)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="slot-room">Room</Label>
<Input id="slot-room" {...register('room')} placeholder="e.g. Studio A" />
</div>
<div className="space-y-2">
<Label htmlFor="slot-max">Max Students</Label>
<Input id="slot-max" type="number" min={1} {...register('maxStudents')} />
</div>
</div>
<div>
<Label className="block mb-2">Instructor Rates (override lesson type defaults)</Label>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label htmlFor="slot-rate-weekly" className="text-xs text-muted-foreground">Weekly</Label>
<Input id="slot-rate-weekly" type="number" step="0.01" min="0" {...register('rateWeekly')} placeholder="—" />
</div>
<div className="space-y-1">
<Label htmlFor="slot-rate-monthly" className="text-xs text-muted-foreground">Monthly</Label>
<Input id="slot-rate-monthly" type="number" step="0.01" min="0" {...register('rateMonthly')} placeholder="—" />
</div>
<div className="space-y-1">
<Label htmlFor="slot-rate-quarterly" className="text-xs text-muted-foreground">Quarterly</Label>
<Input id="slot-rate-quarterly" type="number" step="0.01" min="0" {...register('rateQuarterly')} placeholder="—" />
</div>
</div>
</div>
<Button type="submit" disabled={loading || !lessonTypeId} className="w-full">
{loading ? 'Saving...' : defaultValues?.id ? 'Save Changes' : 'Add Slot'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,41 @@
import { useForm } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface Props {
onSubmit: (data: Record<string, unknown>) => void
loading?: boolean
}
export function StoreClosureForm({ onSubmit, loading }: Props) {
const { register, handleSubmit } = useForm({
defaultValues: { name: '', startDate: '', endDate: '' },
})
function handleFormSubmit(data: { name: string; startDate: string; endDate: string }) {
onSubmit({ name: data.name, startDate: data.startDate, endDate: data.endDate })
}
return (
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="closure-name">Name *</Label>
<Input id="closure-name" {...register('name')} placeholder="e.g. Thanksgiving Break" required />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="closure-start">Start Date *</Label>
<Input id="closure-start" type="date" {...register('startDate')} required />
</div>
<div className="space-y-2">
<Label htmlFor="closure-end">End Date *</Label>
<Input id="closure-end" type="date" {...register('endDate')} required />
</div>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading ? 'Saving...' : 'Add Closure'}
</Button>
</form>
)
}

View File

@@ -0,0 +1,161 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Trash2, Plus, ChevronUp, ChevronDown } from 'lucide-react'
interface TemplateItemRow {
id: string
title: string
description: string
}
interface TemplateSectionRow {
id: string
title: string
description: string
items: TemplateItemRow[]
}
interface Props {
sections: TemplateSectionRow[]
onChange: (sections: TemplateSectionRow[]) => void
}
function uid() {
return Math.random().toString(36).slice(2)
}
export function TemplateSectionBuilder({ sections, onChange }: Props) {
function addSection() {
onChange([...sections, { id: uid(), title: '', description: '', items: [] }])
}
function removeSection(idx: number) {
onChange(sections.filter((_, i) => i !== idx))
}
function moveSection(idx: number, dir: -1 | 1) {
const next = [...sections]
const [removed] = next.splice(idx, 1)
next.splice(idx + dir, 0, removed)
onChange(next)
}
function updateSection(idx: number, field: 'title' | 'description', value: string) {
onChange(sections.map((s, i) => (i === idx ? { ...s, [field]: value } : s)))
}
function addItem(sIdx: number) {
onChange(sections.map((s, i) =>
i === sIdx ? { ...s, items: [...s.items, { id: uid(), title: '', description: '' }] } : s,
))
}
function removeItem(sIdx: number, iIdx: number) {
onChange(sections.map((s, i) =>
i === sIdx ? { ...s, items: s.items.filter((_, j) => j !== iIdx) } : s,
))
}
function moveItem(sIdx: number, iIdx: number, dir: -1 | 1) {
onChange(sections.map((s, i) => {
if (i !== sIdx) return s
const next = [...s.items]
const [removed] = next.splice(iIdx, 1)
next.splice(iIdx + dir, 0, removed)
return { ...s, items: next }
}))
}
function updateItem(sIdx: number, iIdx: number, field: 'title' | 'description', value: string) {
onChange(sections.map((s, i) =>
i === sIdx
? { ...s, items: s.items.map((item, j) => (j === iIdx ? { ...item, [field]: value } : item)) }
: s,
))
}
return (
<div className="space-y-4">
{sections.map((section, sIdx) => (
<div key={section.id} className="border rounded-lg overflow-hidden">
<div className="bg-muted/40 px-3 py-2 flex items-center gap-2">
<div className="flex flex-col gap-0.5">
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={sIdx === 0} onClick={() => moveSection(sIdx, -1)}>
<ChevronUp className="h-3 w-3" />
</Button>
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={sIdx === sections.length - 1} onClick={() => moveSection(sIdx, 1)}>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<Input
className="h-7 text-sm font-medium flex-1"
placeholder="Section title *"
value={section.title}
onChange={(e) => updateSection(sIdx, 'title', e.target.value)}
required
/>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7" onClick={() => removeSection(sIdx)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
<div className="p-3 space-y-2">
<Textarea
className="text-xs resize-none"
placeholder="Section description (optional)"
rows={1}
value={section.description}
onChange={(e) => updateSection(sIdx, 'description', e.target.value)}
/>
<div className="space-y-1.5">
{section.items.map((item, iIdx) => (
<div key={item.id} className="flex items-center gap-2">
<div className="flex flex-col gap-0.5">
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={iIdx === 0} onClick={() => moveItem(sIdx, iIdx, -1)}>
<ChevronUp className="h-3 w-3" />
</Button>
<Button type="button" variant="ghost" size="icon" className="h-4 w-4" disabled={iIdx === section.items.length - 1} onClick={() => moveItem(sIdx, iIdx, 1)}>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
<Input
className="h-7 text-xs flex-1"
placeholder="Item title *"
value={item.title}
onChange={(e) => updateItem(sIdx, iIdx, 'title', e.target.value)}
required
/>
<Input
className="h-7 text-xs flex-1"
placeholder="Description (optional)"
value={item.description}
onChange={(e) => updateItem(sIdx, iIdx, 'description', e.target.value)}
/>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => removeItem(sIdx, iIdx)}>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
))}
</div>
<Button type="button" variant="outline" size="sm" className="text-xs h-7" onClick={() => addItem(sIdx)}>
<Plus className="h-3 w-3 mr-1" />Add Item
</Button>
</div>
</div>
))}
<Button type="button" variant="outline" size="sm" onClick={addSection}>
<Plus className="h-4 w-4 mr-1" />Add Section
</Button>
{sections.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-2">No sections yet add one above.</p>
)}
</div>
)
}
export type { TemplateSectionRow, TemplateItemRow }

View File

@@ -0,0 +1,87 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Pencil, Trash2 } from 'lucide-react'
import type { ScheduleSlot, LessonType } from '@/types/lesson'
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
interface Props {
slots: ScheduleSlot[]
lessonTypes: LessonType[]
onEdit: (slot: ScheduleSlot) => void
onDelete: (slot: ScheduleSlot) => void
}
function formatTime(t: string) {
const [h, m] = t.split(':').map(Number)
const ampm = h >= 12 ? 'PM' : 'AM'
const hour = h % 12 || 12
return `${hour}:${String(m).padStart(2, '0')} ${ampm}`
}
export function WeeklySlotGrid({ slots, lessonTypes, onEdit, onDelete }: Props) {
const ltMap = new Map(lessonTypes.map((lt) => [lt.id, lt]))
const slotsByDay = DAYS.map((_, day) =>
slots.filter((s) => s.dayOfWeek === day).sort((a, b) => a.startTime.localeCompare(b.startTime)),
)
const hasAny = slots.length > 0
return (
<div className="grid grid-cols-7 gap-2">
{DAYS.map((day, idx) => (
<div key={day} className="min-h-[120px]">
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide text-center mb-2 py-1 border-b">
{day}
</div>
<div className="space-y-2">
{slotsByDay[idx].map((slot) => {
const lt = ltMap.get(slot.lessonTypeId)
return (
<div
key={slot.id}
className="bg-sidebar-accent rounded-md p-2 text-xs group relative"
>
<div className="font-medium">{formatTime(slot.startTime)}</div>
<div className="text-muted-foreground truncate">{lt?.name ?? 'Unknown'}</div>
{slot.room && <div className="text-muted-foreground">{slot.room}</div>}
{lt && (
<Badge variant="outline" className="mt-1 text-[10px] py-0">
{lt.lessonFormat}
</Badge>
)}
<div className="absolute top-1 right-1 hidden group-hover:flex gap-1">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => onEdit(slot)}
title="Edit"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => onDelete(slot)}
title="Delete"
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
</div>
)
})}
</div>
</div>
))}
{!hasAny && (
<div className="col-span-7 text-center text-sm text-muted-foreground py-8">
No schedule slots yet add one to get started.
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,522 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { usePOSStore } from '@/stores/pos.store'
import { posMutations, posKeys, type Transaction } from '@/api/pos'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
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 { useState } from 'react'
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 {
transaction: Transaction | null
}
export function POSCartPanel({ transaction }: POSCartPanelProps) {
const queryClient = useQueryClient()
const { currentTransactionId, setTransaction, accountName, accountPhone, accountEmail } = usePOSStore()
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 drawerSessionId = usePOSStore((s) => s.drawerSessionId)
const drawerOpen = !!drawerSessionId
const removeItemMutation = useMutation({
mutationFn: (lineItemId: string) =>
posMutations.removeLineItem(currentTransactionId!, lineItemId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
},
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({
mutationFn: () => posMutations.void(currentTransactionId!),
onSuccess: () => {
setTransaction(null)
toast.success('Transaction voided')
},
onError: (err) => toast.error(err.message),
})
const subtotal = parseFloat(transaction?.subtotal ?? '0')
const discountTotal = parseFloat(transaction?.discountTotal ?? '0')
const taxTotal = parseFloat(transaction?.taxTotal ?? '0')
const total = parseFloat(transaction?.total ?? '0')
const hasItems = lineItems.length > 0
const isPending = transaction?.status === 'pending'
function handlePaymentComplete() {
setPaymentMethod(null)
setTransaction(null)
}
return (
<div className="flex flex-col h-full bg-card">
{/* Header */}
<div className="p-3 border-b border-border">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-base">Current Sale</h2>
{transaction && (
<span className="text-xs text-muted-foreground font-mono">
{transaction.transactionNumber}
</span>
)}
</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>
{/* Line items */}
<div className="flex-1 overflow-y-auto">
{lineItems.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
No items yet
</div>
) : (
<div className="divide-y divide-border">
{lineItems.map((item) => {
const unitPrice = parseFloat(item.unitPrice)
const discount = parseFloat(item.discountAmount)
const hasDiscount = discount > 0
const listTotal = unitPrice * item.qty
const discountPct = listTotal > 0 ? Math.round((discount / listTotal) * 100) : 0
return (
<div key={item.id} className="flex items-center gap-2 px-3 py-2.5 group">
<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>
)}
</div>
{/* Totals + payment */}
<div className="shrink-0 border-t border-border">
<div className="px-3 py-2 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Subtotal</span>
<span className="tabular-nums">${subtotal.toFixed(2)}</span>
</div>
{discountTotal > 0 && (
<div className="flex justify-between text-green-600">
<span>Discount</span>
<span className="tabular-nums">-${discountTotal.toFixed(2)}</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Tax</span>
<span className="tabular-nums">${taxTotal.toFixed(2)}</span>
</div>
<Separator />
<div className="flex justify-between text-lg font-bold pt-1">
<span>Total</span>
<span className="tabular-nums">${total.toFixed(2)}</span>
</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 */}
<div className="p-3 space-y-2">
{!drawerOpen && hasItems && (
<p className="text-xs text-destructive text-center">Open the drawer before accepting payment</p>
)}
<div className="grid grid-cols-2 gap-2">
<Button
className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending || !drawerOpen}
onClick={() => setPaymentMethod('cash')}
>
<Banknote className="h-4 w-4" />
Cash
</Button>
<Button
className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending || !drawerOpen}
onClick={() => setPaymentMethod('card_present')}
>
<CreditCard className="h-4 w-4" />
Card
</Button>
<Button
variant="outline"
className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending || !drawerOpen}
onClick={() => setPaymentMethod('check')}
>
<FileText className="h-4 w-4" />
Check
</Button>
<Button
variant="destructive"
className="h-12 text-sm gap-2"
disabled={!hasItems || !isPending}
onClick={() => {
if (requiresOverride('void_transaction')) {
setOverrideOpen(true)
} else {
voidMutation.mutate()
}
}}
>
<Ban className="h-4 w-4" />
Void
</Button>
</div>
</div>
</div>
{/* Payment dialog */}
{paymentMethod && transaction && (
<POSPaymentDialog
open={!!paymentMethod}
onOpenChange={(open) => { if (!open) setPaymentMethod(null) }}
paymentMethod={paymentMethod}
transaction={transaction}
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>
)
}
// --- 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

@@ -0,0 +1,471 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { usePOSStore } from '@/stores/pos.store'
import { posMutations, posKeys, drawerReportOptions, type DrawerSession } from '@/api/pos'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Badge } from '@/components/ui/badge'
import { ArrowDownToLine, ArrowUpFromLine } from 'lucide-react'
import { toast } from 'sonner'
import { ManagerOverrideDialog, requiresOverride } from './pos-manager-override'
interface POSDrawerDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
drawer: DrawerSession | null
}
export function POSDrawerDialog({ open, onOpenChange, drawer }: POSDrawerDialogProps) {
const queryClient = useQueryClient()
const { locationId, registerId, setDrawerSession } = usePOSStore()
const isOpen = drawer?.status === 'open'
const [openingBalance, setOpeningBalance] = useState('200')
const [closingBalance, setClosingBalance] = 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({
mutationFn: () =>
posMutations.openDrawer({
locationId: locationId ?? undefined,
registerId: registerId ?? undefined,
openingBalance: parseFloat(openingBalance) || 0,
}),
onSuccess: async (session) => {
setDrawerSession(session.id)
await queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') })
toast.success('Drawer opened')
onOpenChange(false)
},
onError: (err) => toast.error(err.message),
})
const closeMutation = useMutation({
mutationFn: () =>
posMutations.closeDrawer(drawer!.id, {
closingBalance: parseFloat(closingBalance) || 0,
notes: notes || undefined,
}),
onSuccess: async (session) => {
setDrawerSession(null)
const overShort = parseFloat(session.overShort ?? '0')
if (Math.abs(overShort) < 0.01) {
toast.success('Drawer closed - balanced')
} else {
toast.warning(`Drawer closed - ${overShort > 0 ? 'over' : 'short'} $${Math.abs(overShort).toFixed(2)}`)
}
// Show Z report
setClosedDrawerId(session.id)
setShowZReport(true)
await queryClient.invalidateQueries({ queryKey: posKeys.drawer(locationId ?? '') })
},
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 (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{isOpen ? 'Drawer' : 'Open Drawer'}</DialogTitle>
</DialogHeader>
{isOpen ? (
<div className="space-y-4">
<div className="text-sm space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Opening Balance</span>
<span>${parseFloat(drawer!.openingBalance).toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Opened</span>
<span>{new Date(drawer!.openedAt).toLocaleTimeString()}</span>
</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 />
{/* Close drawer */}
<div className="space-y-2">
<Label>Closing Balance *</Label>
<Input
type="number"
step="0.01"
min="0"
value={closingBalance}
onChange={(e) => setClosingBalance(e.target.value)}
placeholder="Count the cash in the drawer"
className="h-11 text-lg"
/>
</div>
<div className="space-y-2">
<Label>Notes</Label>
<Input
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="End of shift notes"
className="h-11"
/>
</div>
<Button
className="w-full h-12"
onClick={() => closeMutation.mutate()}
disabled={!closingBalance || closeMutation.isPending}
>
{closeMutation.isPending ? 'Closing...' : 'Close Drawer'}
</Button>
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label>Opening Balance *</Label>
<Input
type="number"
step="0.01"
min="0"
value={openingBalance}
onChange={(e) => setOpeningBalance(e.target.value)}
placeholder="Starting cash amount"
className="h-11 text-lg"
autoFocus
/>
</div>
<Button
className="w-full h-12"
onClick={() => openMutation.mutate()}
disabled={!openingBalance || openMutation.isPending}
>
{openMutation.isPending ? 'Opening...' : 'Open Drawer'}
</Button>
</div>
)}
</DialogContent>
</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

@@ -0,0 +1,285 @@
import { useState, useRef, useCallback } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { usePOSStore } from '@/stores/pos.store'
import { productSearchOptions, posMutations, posKeys, type Transaction, type Product } from '@/api/pos'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { Skeleton } from '@/components/ui/skeleton'
import { Search, ScanBarcode, Wrench, PenLine, ClipboardList } from 'lucide-react'
import { toast } from 'sonner'
import { POSTransactionsDialog } from './pos-transactions-dialog'
import { POSRepairDialog } from './pos-repair-dialog'
interface POSItemPanelProps {
transaction: Transaction | null
}
export function POSItemPanel({ transaction: _transaction }: POSItemPanelProps) {
const queryClient = useQueryClient()
const { currentTransactionId, setTransaction, locationId, accountId } = usePOSStore()
const [search, setSearch] = useState('')
const [customOpen, setCustomOpen] = useState(false)
const [txnDialogOpen, setTxnDialogOpen] = useState(false)
const [repairOpen, setRepairOpen] = useState(false)
const [customDesc, setCustomDesc] = useState('')
const [customPrice, setCustomPrice] = useState('')
const [customQty, setCustomQty] = useState('1')
const searchRef = useRef<HTMLInputElement>(null)
// Debounced product search
const { data: productsData, isLoading: searchLoading } = useQuery({
...productSearchOptions(search),
enabled: search.length >= 1,
})
const products = productsData?.data ?? []
// Add line item mutation
const addItemMutation = useMutation({
mutationFn: async (product: Product) => {
let txnId = currentTransactionId
// Auto-create transaction if none exists
if (!txnId) {
const txn = await posMutations.createTransaction({
transactionType: 'sale',
locationId: locationId ?? undefined,
accountId: accountId ?? undefined,
})
txnId = txn.id
setTransaction(txnId)
}
return posMutations.addLineItem(txnId, {
productId: product.id,
description: product.name,
qty: 1,
unitPrice: parseFloat(product.price ?? '0'),
})
},
onSuccess: () => {
const txnId = usePOSStore.getState().currentTransactionId
queryClient.invalidateQueries({ queryKey: posKeys.transaction(txnId ?? '') })
},
onError: (err) => toast.error(err.message),
})
// Custom item mutation
const addCustomMutation = useMutation({
mutationFn: async () => {
let txnId = currentTransactionId
if (!txnId) {
const txn = await posMutations.createTransaction({
transactionType: 'sale',
locationId: locationId ?? undefined,
accountId: accountId ?? undefined,
})
txnId = txn.id
setTransaction(txnId)
}
return posMutations.addLineItem(txnId, {
description: customDesc,
qty: parseInt(customQty) || 1,
unitPrice: parseFloat(customPrice) || 0,
})
},
onSuccess: () => {
const txnId = usePOSStore.getState().currentTransactionId
queryClient.invalidateQueries({ queryKey: posKeys.transaction(txnId ?? '') })
setCustomOpen(false)
setCustomDesc('')
setCustomPrice('')
setCustomQty('1')
},
onError: (err) => toast.error(err.message),
})
// UPC scan
const scanMutation = useMutation({
mutationFn: async (upc: string) => {
const product = await posMutations.lookupUpc(upc)
let txnId = currentTransactionId
if (!txnId) {
const txn = await posMutations.createTransaction({
transactionType: 'sale',
locationId: locationId ?? undefined,
accountId: accountId ?? undefined,
})
txnId = txn.id
setTransaction(txnId)
}
return posMutations.addLineItem(txnId, {
productId: product.id,
description: product.name,
qty: 1,
unitPrice: parseFloat(product.price ?? '0'),
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId ?? '') })
setSearch('')
toast.success('Item scanned')
},
onError: (err) => toast.error(err.message),
})
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
// Barcode scanners typically send Enter after the code
if (e.key === 'Enter' && search.length >= 6) {
// Looks like a UPC — try scanning
scanMutation.mutate(search)
}
}, [search, scanMutation])
return (
<div className="flex flex-col h-full">
{/* Search bar */}
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchRef}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="Search products or scan barcode..."
className="pl-10 h-11 text-base"
autoFocus
/>
</div>
</div>
{/* Product grid */}
<div className="flex-1 overflow-y-auto p-3">
{searchLoading ? (
<div className="grid grid-cols-3 gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-24 rounded-lg" />
))}
</div>
) : products.length > 0 ? (
<div className="grid grid-cols-3 gap-3">
{products.map((product) => (
<button
key={product.id}
onClick={() => addItemMutation.mutate(product)}
disabled={addItemMutation.isPending}
className="flex flex-col items-start p-3 rounded-lg border border-border bg-card hover:bg-accent active:bg-accent/80 transition-colors text-left min-h-[80px]"
>
<span className="font-medium text-sm line-clamp-2">{product.name}</span>
<div className="mt-auto flex items-center justify-between w-full pt-1">
<span className="text-base font-semibold">${parseFloat(product.price ?? '0').toFixed(2)}</span>
{product.sku && <span className="text-xs text-muted-foreground">{product.sku}</span>}
</div>
{product.isSerialized ? (
<span className="text-[10px] text-muted-foreground">Serialized</span>
) : product.qtyOnHand !== null ? (
<span className="text-[10px] text-muted-foreground">{product.qtyOnHand} in stock</span>
) : null}
</button>
))}
</div>
) : search.length >= 1 ? (
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
No products found for "{search}"
</div>
) : (
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
Search for products to add to the sale
</div>
)}
</div>
{/* Quick action buttons */}
<div className="p-3 border-t border-border flex gap-2">
<Button
variant="outline"
className="flex-1 h-11 text-sm gap-2"
onClick={() => searchRef.current?.focus()}
>
<ScanBarcode className="h-4 w-4" />
Scan
</Button>
<Button
variant="outline"
className="flex-1 h-11 text-sm gap-2"
onClick={() => setRepairOpen(true)}
>
<Wrench className="h-4 w-4" />
Repairs
</Button>
<Button
variant="outline"
className="flex-1 h-11 text-sm gap-2"
onClick={() => setCustomOpen(true)}
>
<PenLine className="h-4 w-4" />
Custom
</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>
{/* Custom item dialog */}
<Dialog open={customOpen} onOpenChange={setCustomOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Custom Item</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => { e.preventDefault(); addCustomMutation.mutate() }}
className="space-y-4"
>
<div className="space-y-2">
<Label>Description *</Label>
<Input
value={customDesc}
onChange={(e) => setCustomDesc(e.target.value)}
placeholder="Item description"
required
className="h-11"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Price *</Label>
<Input
type="number"
step="0.01"
min="0"
value={customPrice}
onChange={(e) => setCustomPrice(e.target.value)}
placeholder="0.00"
required
className="h-11"
/>
</div>
<div className="space-y-2">
<Label>Qty</Label>
<Input
type="number"
min="1"
value={customQty}
onChange={(e) => setCustomQty(e.target.value)}
className="h-11"
/>
</div>
</div>
<Button type="submit" className="w-full h-11" disabled={addCustomMutation.isPending}>
{addCustomMutation.isPending ? 'Adding...' : 'Add Item'}
</Button>
</form>
</DialogContent>
</Dialog>
{/* Transactions dialog */}
<POSTransactionsDialog open={txnDialogOpen} onOpenChange={setTxnDialogOpen} />
<POSRepairDialog open={repairOpen} onOpenChange={setRepairOpen} />
</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

@@ -0,0 +1,298 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { usePOSStore } from '@/stores/pos.store'
import { api } from '@/lib/api-client'
import { posMutations, posKeys, type Transaction } from '@/api/pos'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { CheckCircle, Printer, Mail } from 'lucide-react'
import { toast } from 'sonner'
import { POSReceipt, downloadReceiptPDF, printReceipt } from './pos-receipt'
interface POSPaymentDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
paymentMethod: string
transaction: Transaction
onComplete: () => void
}
export function POSPaymentDialog({ open, onOpenChange, paymentMethod, transaction, onComplete }: POSPaymentDialogProps) {
const queryClient = useQueryClient()
const { currentTransactionId } = usePOSStore()
const total = parseFloat(transaction.total)
const [amountTendered, setAmountTendered] = useState('')
const [checkNumber, setCheckNumber] = useState('')
const [completed, setCompleted] = useState(false)
const [result, setResult] = useState<Transaction | null>(null)
const completeMutation = useMutation({
mutationFn: () => {
const data: { paymentMethod: string; amountTendered?: number; checkNumber?: string } = {
paymentMethod,
}
if (paymentMethod === 'cash') {
data.amountTendered = parseFloat(amountTendered) || 0
}
if (paymentMethod === 'check') {
data.checkNumber = checkNumber || undefined
}
return posMutations.complete(currentTransactionId!, data)
},
onSuccess: (txn) => {
queryClient.invalidateQueries({ queryKey: posKeys.transaction(currentTransactionId!) })
queryClient.invalidateQueries({ queryKey: ['pos', 'products'] })
setResult(txn)
setCompleted(true)
},
onError: (err) => toast.error(err.message),
})
const tenderedAmount = parseFloat(amountTendered) || 0
const changeDue = paymentMethod === 'cash' ? Math.max(0, tenderedAmount - total) : 0
const canComplete = paymentMethod === 'cash'
? tenderedAmount >= total
: true
function handleDone() {
onComplete()
onOpenChange(false)
}
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 }[] }
customerEmail: string | null
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)
const [emailMode, setEmailMode] = useState(false)
const [emailAddress, setEmailAddress] = useState('')
const [emailSent, setEmailSent] = useState(false)
const emailReceiptMutation = useMutation({
mutationFn: () => api.post<{ message: string }>(`/v1/transactions/${result!.id}/email-receipt`, { email: emailAddress }),
onSuccess: () => {
toast.success('Receipt emailed')
setEmailMode(false)
setEmailSent(true)
},
onError: (err) => toast.error(err.message),
})
if (completed && result) {
const changeGiven = parseFloat(result.changeGiven ?? '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 (
<Dialog open={open} onOpenChange={() => handleDone()}>
<DialogContent className="max-w-sm">
<div className="flex flex-col items-center text-center space-y-4 py-4">
<CheckCircle className="h-12 w-12 text-green-500" />
<h2 className="text-xl font-bold">Sale Complete</h2>
<p className="text-muted-foreground text-sm">{result.transactionNumber}</p>
<div className="w-full text-sm space-y-1">
<div className="flex justify-between font-semibold text-base">
<span>Total</span>
<span>${parseFloat(result.total).toFixed(2)}</span>
</div>
{paymentMethod === 'cash' && changeGiven > 0 && (
<div className="flex justify-between text-lg font-bold text-green-600">
<span>Change Due</span>
<span>${changeGiven.toFixed(2)}</span>
</div>
)}
</div>
{emailMode ? (
<div className="w-full space-y-2">
<Label className="text-xs text-muted-foreground">Email receipt to:</Label>
<div className="flex gap-2">
<Input
type="email"
value={emailAddress}
onChange={(e) => setEmailAddress(e.target.value)}
placeholder="customer@example.com"
className="h-9"
autoFocus
/>
<Button
size="sm"
className="h-9 px-4"
onClick={() => emailReceiptMutation.mutate()}
disabled={!emailAddress || emailReceiptMutation.isPending}
>
{emailReceiptMutation.isPending ? 'Sending...' : 'Send'}
</Button>
</div>
<Button variant="ghost" size="sm" className="text-xs" onClick={() => setEmailMode(false)}>Cancel</Button>
</div>
) : (
<div className="w-full grid grid-cols-2 gap-2">
<Button variant="outline" className="h-11 gap-2" onClick={() => setShowReceipt(true)}>
<Printer className="h-4 w-4" />Receipt
</Button>
<Button
variant="outline"
className="h-11 gap-2"
onClick={() => {
setEmailAddress(receiptData?.customerEmail ?? '')
setEmailMode(true)
}}
disabled={emailSent}
>
<Mail className="h-4 w-4" />{emailSent ? 'Sent' : 'Email'}
</Button>
</div>
)}
<Button className="w-full h-12 text-base" onClick={handleDone}>
New Sale
</Button>
</div>
</DialogContent>
</Dialog>
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>
{paymentMethod === 'cash' ? 'Cash Payment' : paymentMethod === 'check' ? 'Check Payment' : 'Card Payment'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex justify-between text-lg font-bold">
<span>Total Due</span>
<span>${total.toFixed(2)}</span>
</div>
<Separator />
{paymentMethod === 'cash' && (
<>
<div className="space-y-2">
<Label>Amount Tendered</Label>
<Input
type="number"
step="0.01"
min="0"
value={amountTendered}
onChange={(e) => setAmountTendered(e.target.value)}
placeholder="0.00"
className="h-12 text-xl text-right font-mono"
autoFocus
/>
</div>
<div className="grid grid-cols-3 gap-2">
{QUICK_AMOUNTS.map((amt) => (
<Button
key={amt}
variant="outline"
className="h-11"
onClick={() => setAmountTendered(String(amt))}
>
${amt}
</Button>
))}
</div>
<Button
variant="outline"
className="w-full h-11"
onClick={() => setAmountTendered(total.toFixed(2))}
>
Exact ${total.toFixed(2)}
</Button>
{tenderedAmount >= total && (
<div className="flex justify-between text-lg font-bold text-green-600">
<span>Change</span>
<span>${changeDue.toFixed(2)}</span>
</div>
)}
</>
)}
{paymentMethod === 'check' && (
<div className="space-y-2">
<Label>Check Number</Label>
<Input
value={checkNumber}
onChange={(e) => setCheckNumber(e.target.value)}
placeholder="Check #"
className="h-11"
autoFocus
/>
</div>
)}
{paymentMethod === 'card_present' && (
<p className="text-sm text-muted-foreground text-center py-4">
Process card payment on terminal, then confirm below.
</p>
)}
<Button
className="w-full h-12 text-base"
disabled={!canComplete || completeMutation.isPending}
onClick={() => completeMutation.mutate()}
>
{completeMutation.isPending ? 'Processing...' : `Complete ${paymentMethod === 'cash' ? 'Cash' : paymentMethod === 'check' ? 'Check' : 'Card'} Sale`}
</Button>
</div>
</DialogContent>
</Dialog>
)
}

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

@@ -0,0 +1,138 @@
import { useEffect, useRef, 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 { currentDrawerOptions, transactionOptions } from '@/api/pos'
import { POSTopBar } from './pos-top-bar'
import { POSItemPanel } from './pos-item-panel'
import { POSCartPanel } from './pos-cart-panel'
import { POSLockScreen } from './pos-lock-screen'
interface Location {
id: string
name: string
}
interface AppConfigEntry {
key: string
value: string | null
}
function locationsOptions() {
return queryOptions({
queryKey: ['locations'],
queryFn: () => api.get<{ data: Location[] }>('/v1/locations'),
})
}
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() {
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
const { data: locationsData } = useQuery({
...locationsOptions(),
enabled: !!token,
})
const locations = locationsData?.data ?? []
// Auto-select first location
useEffect(() => {
if (!locationId && locations.length > 0) {
setLocation(locations[0].id)
}
}, [locationId, locations, setLocation])
// Fetch current drawer for selected location
const { data: drawer } = useQuery({
...currentDrawerOptions(locationId),
retry: false,
enabled: !!locationId && !!token,
})
// Sync drawer session ID
useEffect(() => {
if (drawer?.id && drawer.status === 'open') {
setDrawerSession(drawer.id)
} else {
setDrawerSession(null)
}
}, [drawer, setDrawerSession])
// Fetch current transaction
const { data: transaction } = useQuery({
...transactionOptions(currentTransactionId),
enabled: !!currentTransactionId && !!token,
})
return (
<div
className="relative flex flex-col h-full"
onPointerDown={handleActivity}
onKeyDown={handleActivity}
>
{locked && <POSLockScreen />}
<POSTopBar
locations={locations}
locationId={locationId}
onLocationChange={setLocation}
drawer={drawer ?? null}
/>
<div className="flex flex-1 min-h-0">
<div className="w-[60%] border-r border-border overflow-hidden">
<POSItemPanel transaction={transaction ?? null} />
</div>
<div className="w-[40%] overflow-hidden">
<POSCartPanel transaction={transaction ?? null} />
</div>
</div>
</div>
)
}

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

@@ -0,0 +1,98 @@
import { Link } from '@tanstack/react-router'
import { usePOSStore } from '@/stores/pos.store'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { ArrowLeft, Lock, DollarSign, Receipt, FileText } from 'lucide-react'
import type { DrawerSession } from '@/api/pos'
import { useState } from 'react'
import { POSDrawerDialog } from './pos-drawer-dialog'
interface POSTopBarProps {
locations: { id: string; name: string }[]
locationId: string | null
onLocationChange: (id: string) => void
drawer: DrawerSession | null
}
export function POSTopBar({ locations, locationId, onLocationChange, drawer }: POSTopBarProps) {
const cashier = usePOSStore((s) => s.cashier)
const lockFn = usePOSStore((s) => s.lock)
const receiptFormat = usePOSStore((s) => s.receiptFormat)
const setReceiptFormat = usePOSStore((s) => s.setReceiptFormat)
const [drawerDialogOpen, setDrawerDialogOpen] = useState(false)
const drawerOpen = drawer?.status === 'open'
const isThermal = receiptFormat === 'thermal'
return (
<>
<div className="h-12 border-b border-border bg-card flex items-center justify-between px-3 shrink-0">
{/* Left: back + location */}
<div className="flex items-center gap-3">
<Link to="/login" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">Admin</span>
</Link>
{locations.length > 1 ? (
<Select value={locationId ?? ''} onValueChange={onLocationChange}>
<SelectTrigger className="h-8 w-48 text-sm">
<SelectValue placeholder="Select location" />
</SelectTrigger>
<SelectContent>
{locations.map((loc) => (
<SelectItem key={loc.id} value={loc.id}>{loc.name}</SelectItem>
))}
</SelectContent>
</Select>
) : locations.length === 1 ? (
<span className="text-sm font-medium">{locations[0].name}</span>
) : 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>
{/* Center: drawer status */}
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2"
onClick={() => setDrawerDialogOpen(true)}
>
<DollarSign className="h-4 w-4" />
{drawerOpen ? (
<Badge variant="default" className="text-xs">Drawer Open</Badge>
) : (
<Badge variant="outline" className="text-xs">Drawer Closed</Badge>
)}
</Button>
{/* Right: cashier + lock */}
<div className="flex items-center gap-2">
{cashier && (
<span className="text-sm text-muted-foreground">{cashier.firstName}</span>
)}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={lockFn} title="Lock POS">
<Lock className="h-4 w-4" />
</Button>
</div>
</div>
<POSDrawerDialog
open={drawerDialogOpen}
onOpenChange={setDrawerDialogOpen}
drawer={drawer}
/>
</>
)
}

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

@@ -1,5 +1,4 @@
import jsPDF from 'jspdf'
import { api } from '@/lib/api-client'
import type { RepairTicket, RepairLineItem, RepairNote } from '@/types/repair'
const STATUS_LABELS: Record<string, string> = {
@@ -24,7 +23,7 @@ interface GeneratePdfOptions {
companyName?: string
}
export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, companyName = 'Forte Music' }: GeneratePdfOptions): jsPDF {
export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, companyName = 'LunarFront' }: GeneratePdfOptions): jsPDF {
const doc = new jsPDF()
let y = 20
@@ -57,11 +56,11 @@ export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, comp
doc.setFontSize(10)
doc.setFont('helvetica', 'bold')
doc.text('Customer', 14, y)
doc.text('Instrument', 110, y)
doc.text('Item', 110, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.text(ticket.customerName, 14, y)
doc.text(ticket.instrumentDescription ?? '-', 110, y)
doc.text(ticket.itemDescription ?? '-', 110, y)
y += 5
if (ticket.customerPhone) { doc.text(ticket.customerPhone, 14, y); y += 5 }
if (ticket.serialNumber) { doc.text(`S/N: ${ticket.serialNumber}`, 110, y - 5) }
@@ -86,8 +85,9 @@ export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, comp
doc.text(dateInfo, 14, y)
y += 8
// Line items table
if (lineItems.length > 0) {
// Line items table (exclude consumables — internal only)
const billableItems = lineItems.filter((i) => i.itemType !== 'consumable')
if (billableItems.length > 0) {
doc.setDrawColor(200)
doc.line(14, y, 196, y)
y += 6
@@ -110,7 +110,7 @@ export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, comp
// Table rows
doc.setFont('helvetica', 'normal')
for (const item of lineItems) {
for (const item of billableItems) {
if (y > 270) { doc.addPage(); y = 20 }
doc.text(item.itemType.replace('_', ' '), 16, y)
const descLines = doc.splitTextToSize(item.description, 85)
@@ -128,7 +128,7 @@ export function generateRepairPdf({ ticket, lineItems, notes, includeNotes, comp
y += 5
doc.setFont('helvetica', 'bold')
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.toFixed(2)}`, 190, y, { align: 'right' })
y += 4

View File

@@ -9,9 +9,9 @@ import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { FileText, Download, Check, Eye, Lock } from 'lucide-react'
import { FileText, Download, Check, Eye } from 'lucide-react'
import { toast } from 'sonner'
import type { RepairTicket, RepairLineItem, RepairNote } from '@/types/repair'
import type { RepairTicket, RepairLineItem } from '@/types/repair'
interface FileRecord {
id: string
@@ -65,7 +65,7 @@ export function PdfModal({ ticket, lineItems, ticketId }: PdfModalProps) {
function toggleNote(id: string) {
setSelectedNoteIds((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
if (next.has(id)) { next.delete(id) } else { next.add(id) }
return next
})
}
@@ -73,7 +73,7 @@ export function PdfModal({ ticket, lineItems, ticketId }: PdfModalProps) {
function togglePhoto(id: string) {
setSelectedPhotoIds((prev) => {
const next = new Set(prev)
next.has(id) ? next.delete(id) : next.add(id)
if (next.has(id)) { next.delete(id) } else { next.add(id) }
return next
})
}
@@ -102,7 +102,7 @@ export function PdfModal({ ticket, lineItems, ticketId }: PdfModalProps) {
)
toast.success('PDF generated and saved to documents')
setOpen(false)
} catch (err) {
} catch {
toast.error('Failed to generate PDF')
} finally {
setGenerating(false)

View File

@@ -1,4 +1,4 @@
import { Check, Truck, ClipboardList, Search, Clock, ThumbsUp, Wrench, Package, HandMetal, Ban, FilePlus } from 'lucide-react'
import { Check, ClipboardList, Search, Clock, ThumbsUp, Wrench, Package, HandMetal, Ban, FilePlus } from 'lucide-react'
const STEPS = [
{ key: 'new', label: 'New', icon: FilePlus },

View File

@@ -4,7 +4,7 @@ import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { useAuthStore } from '@/stores/auth.store'
import { Button } from '@/components/ui/button'
import { FileText, ImageIcon, Plus, Trash2 } from 'lucide-react'
import { FileText, Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
function AuthImage({ path, alt, className, onClick }: { path: string; alt: string; className?: string; onClick?: () => void }) {

View File

@@ -1,5 +1,5 @@
import { useRef, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { queryOptions } from '@tanstack/react-query'
import { api } from '@/lib/api-client'
import { useAuthStore } from '@/stores/auth.store'
@@ -23,9 +23,11 @@ function entityFilesOptions(entityType: string, entityId: string) {
}
interface AvatarUploadProps {
entityType: 'user' | 'member'
entityType: 'user' | 'member' | 'company'
entityId: string
size?: 'sm' | 'md' | 'lg'
category?: string
placeholderIcon?: React.ComponentType<{ className?: string }>
}
const sizeClasses = {
@@ -40,16 +42,17 @@ const iconSizes = {
lg: 'h-12 w-12',
}
export function AvatarUpload({ entityType, entityId, size = 'lg' }: AvatarUploadProps) {
export function AvatarUpload({ entityType, entityId, size = 'lg', category = 'profile', placeholderIcon: PlaceholderIcon }: AvatarUploadProps) {
const queryClient = useQueryClient()
const token = useAuthStore((s) => s.token)
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const IconComponent = PlaceholderIcon ?? User
const { data: filesData } = useQuery(entityFilesOptions(entityType, entityId))
// Find profile image from files
const profileFile = filesData?.data?.find((f) => f.path.includes('/profile-'))
// Find image by category
const profileFile = filesData?.data?.find((f) => f.path.includes(`/${category}-`))
const imageUrl = profileFile ? `/v1/files/serve/${profileFile.path}` : null
async function handleUpload(file: File) {
@@ -59,7 +62,7 @@ export function AvatarUpload({ entityType, entityId, size = 'lg' }: AvatarUpload
formData.append('file', file)
formData.append('entityType', entityType)
formData.append('entityId', entityId)
formData.append('category', 'profile')
formData.append('category', category)
// Delete existing profile image first
if (profileFile) {
@@ -105,7 +108,7 @@ export function AvatarUpload({ entityType, entityId, size = 'lg' }: AvatarUpload
className="h-full w-full object-cover"
/>
) : (
<User className={`${iconSizes[size]} text-muted-foreground`} />
<IconComponent className={`${iconSizes[size]} text-muted-foreground`} />
)}
</div>
<Button

View File

@@ -0,0 +1,29 @@
import { FileText, Image, FileSpreadsheet, File, FileType, Film } from 'lucide-react'
const ICON_MAP: Record<string, { icon: typeof FileText; color: string }> = {
'application/pdf': { icon: FileText, color: 'text-red-500' },
'image/jpeg': { icon: Image, color: 'text-blue-500' },
'image/png': { icon: Image, color: 'text-blue-500' },
'image/webp': { icon: Image, color: 'text-blue-500' },
'image/gif': { icon: Image, color: 'text-blue-500' },
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': { icon: FileType, color: 'text-blue-600' },
'application/msword': { icon: FileType, color: 'text-blue-600' },
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': { icon: FileSpreadsheet, color: 'text-green-600' },
'application/vnd.ms-excel': { icon: FileSpreadsheet, color: 'text-green-600' },
'text/csv': { icon: FileSpreadsheet, color: 'text-green-600' },
'text/plain': { icon: FileText, color: 'text-muted-foreground' },
'video/mp4': { icon: Film, color: 'text-purple-500' },
}
export function FileIcon({ contentType, className = 'h-8 w-8' }: { contentType: string; className?: string }) {
const match = ICON_MAP[contentType] ?? { icon: File, color: 'text-muted-foreground' }
const Icon = match.icon
return <Icon className={`${className} ${match.color}`} />
}
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
}

View File

@@ -0,0 +1,236 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
storageFolderPermissionsOptions, storageFolderMutations, storageFolderKeys,
} from '@/api/storage'
import { roleListOptions } from '@/api/rbac'
import { userListOptions } from '@/api/users'
import type { UserRecord } from '@/api/users'
import type { Role } from '@/types/rbac'
import type { StorageFolderPermission } from '@/types/storage'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { Trash2, Shield, Users, User } from 'lucide-react'
import { toast } from 'sonner'
interface FolderPermissionsDialogProps {
folderId: string
folderName: string
isPublic: boolean
open: boolean
onOpenChange: (open: boolean) => void
}
const ACCESS_LEVELS = [
{ value: 'traverse', label: 'Traverse', variant: 'outline' as const },
{ value: 'view', label: 'View', variant: 'secondary' as const },
{ value: 'edit', label: 'Edit', variant: 'default' as const },
{ value: 'admin', label: 'Admin', variant: 'destructive' as const },
]
export function FolderPermissionsDialog({ folderId, folderName, isPublic, open, onOpenChange }: FolderPermissionsDialogProps) {
const queryClient = useQueryClient()
const [assigneeType, setAssigneeType] = useState<'role' | 'user'>('role')
const [assigneeId, setAssigneeId] = useState('')
const [accessLevel, setAccessLevel] = useState('view')
const { data: permissionsData, isLoading: permsLoading } = useQuery({
...storageFolderPermissionsOptions(folderId),
enabled: open && !!folderId,
})
const { data: rolesData } = useQuery({ ...roleListOptions(), enabled: open })
const { data: usersData } = useQuery({
...userListOptions({ page: 1, limit: 100, order: 'asc' }),
enabled: open && assigneeType === 'user',
})
const permissions = permissionsData?.data ?? []
const roles = rolesData?.data ?? []
const users = usersData?.data ?? []
const togglePublicMutation = useMutation({
mutationFn: (newIsPublic: boolean) => storageFolderMutations.update(folderId, { isPublic: newIsPublic }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storageFolderKeys.all })
queryClient.invalidateQueries({ queryKey: storageFolderKeys.detail(folderId) })
toast.success(isPublic ? 'Folder set to private' : 'Folder set to public')
},
onError: (err) => toast.error(err.message),
})
const addPermissionMutation = useMutation({
mutationFn: () => storageFolderMutations.addPermission(folderId, {
...(assigneeType === 'role' ? { roleId: assigneeId } : { userId: assigneeId }),
accessLevel,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storageFolderKeys.permissions(folderId) })
setAssigneeId('')
setAccessLevel('view')
toast.success('Permission added')
},
onError: (err) => toast.error(err.message),
})
const removePermissionMutation = useMutation({
mutationFn: (permId: string) => storageFolderMutations.removePermission(permId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: storageFolderKeys.permissions(folderId) })
toast.success('Permission removed')
},
onError: (err) => toast.error(err.message),
})
function getPermissionLabel(perm: StorageFolderPermission): { icon: typeof Shield; name: string } {
if (perm.roleId) {
const role = roles.find((r: Role) => r.id === perm.roleId)
return { icon: Users, name: role?.name ?? 'Unknown role' }
}
const user = users.find((u: UserRecord) => u.id === perm.userId)
return { icon: User, name: user ? `${user.firstName} ${user.lastName}` : 'Unknown user' }
}
function handleAdd(e: React.FormEvent) {
e.preventDefault()
if (!assigneeId) return
addPermissionMutation.mutate()
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Permissions {folderName}
</DialogTitle>
</DialogHeader>
<div className="space-y-5">
{/* Public toggle */}
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label className="text-sm font-medium">Public folder</Label>
<p className="text-xs text-muted-foreground mt-0.5">
Public folders are viewable by all users with file access
</p>
</div>
<Switch
checked={isPublic}
onCheckedChange={(checked) => togglePublicMutation.mutate(checked)}
disabled={togglePublicMutation.isPending}
/>
</div>
{/* Current permissions */}
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Permissions
</Label>
<div className="mt-2 space-y-1.5">
{permsLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : permissions.length === 0 ? (
<p className="text-sm text-muted-foreground">No specific permissions assigned</p>
) : (
permissions.map((perm) => {
const { icon: Icon, name } = getPermissionLabel(perm)
const level = ACCESS_LEVELS.find((l) => l.value === perm.accessLevel)
return (
<div key={perm.id} className="flex items-center justify-between rounded-md border px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-sm truncate">{name}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant={level?.variant ?? 'secondary'} className="text-xs">
{level?.label ?? perm.accessLevel}
</Badge>
<button
type="button"
onClick={() => removePermissionMutation.mutate(perm.id)}
className="text-muted-foreground hover:text-destructive transition-colors"
disabled={removePermissionMutation.isPending}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
})
)}
</div>
</div>
{/* Add permission form */}
<form onSubmit={handleAdd} className="space-y-3">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Add Permission
</Label>
{/* Role / User toggle */}
<div className="flex gap-1 rounded-md border p-0.5">
<button
type="button"
onClick={() => { setAssigneeType('role'); setAssigneeId('') }}
className={`flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${assigneeType === 'role' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
>
Role
</button>
<button
type="button"
onClick={() => { setAssigneeType('user'); setAssigneeId('') }}
className={`flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${assigneeType === 'user' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}
>
User
</button>
</div>
<div className="flex gap-2">
{/* Assignee select */}
<Select value={assigneeId} onValueChange={setAssigneeId}>
<SelectTrigger className="flex-1">
<SelectValue placeholder={assigneeType === 'role' ? 'Select role...' : 'Select user...'} />
</SelectTrigger>
<SelectContent>
{assigneeType === 'role'
? roles.map((role: Role) => (
<SelectItem key={role.id} value={role.id}>{role.name}</SelectItem>
))
: users.map((user: UserRecord) => (
<SelectItem key={user.id} value={user.id}>
{user.firstName} {user.lastName}
</SelectItem>
))
}
</SelectContent>
</Select>
{/* Access level select */}
<Select value={accessLevel} onValueChange={setAccessLevel}>
<SelectTrigger className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ACCESS_LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value}>{level.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm" disabled={!assigneeId || addPermissionMutation.isPending} className="w-full">
{addPermissionMutation.isPending ? 'Adding...' : 'Add Permission'}
</Button>
</form>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,91 @@
import { useState } from 'react'
import { ChevronRight, ChevronDown, Folder, FolderOpen, Lock } from 'lucide-react'
import type { StorageFolder } from '@/types/storage'
interface FolderTreeProps {
folders: StorageFolder[]
selectedFolderId: string | null
onSelect: (folderId: string | null) => void
}
interface TreeNode {
folder: StorageFolder
children: TreeNode[]
}
function buildTree(folders: StorageFolder[]): TreeNode[] {
const map = new Map<string, TreeNode>()
const roots: TreeNode[] = []
for (const folder of folders) {
map.set(folder.id, { folder, children: [] })
}
for (const folder of folders) {
const node = map.get(folder.id)!
if (folder.parentId && map.has(folder.parentId)) {
map.get(folder.parentId)!.children.push(node)
} else {
roots.push(node)
}
}
return roots
}
export function FolderTree({ folders, selectedFolderId, onSelect }: FolderTreeProps) {
const tree = buildTree(folders)
return (
<div className="space-y-0.5">
<button
type="button"
onClick={() => onSelect(null)}
className={`flex items-center gap-2 w-full text-left px-2 py-1.5 rounded-md text-sm transition-colors ${
selectedFolderId === null ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
}`}
>
<Folder className="h-4 w-4 shrink-0" />
<span>All Files</span>
</button>
{tree.map((node) => (
<TreeItem key={node.folder.id} node={node} depth={0} selectedFolderId={selectedFolderId} onSelect={onSelect} />
))}
</div>
)
}
function TreeItem({ node, depth, selectedFolderId, onSelect }: { node: TreeNode; depth: number; selectedFolderId: string | null; onSelect: (id: string) => void }) {
const [expanded, setExpanded] = useState(depth < 2)
const hasChildren = node.children.length > 0
const isSelected = selectedFolderId === node.folder.id
return (
<div>
<button
type="button"
onClick={() => { onSelect(node.folder.id); if (hasChildren) setExpanded(!expanded) }}
className={`flex items-center gap-1 w-full text-left px-2 py-1.5 rounded-md text-sm transition-colors ${
isSelected ? 'bg-accent text-accent-foreground font-medium' : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
}`}
style={{ paddingLeft: `${8 + depth * 16}px` }}
>
{hasChildren ? (
expanded ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />
) : (
<span className="w-3 shrink-0" />
)}
{isSelected ? <FolderOpen className="h-4 w-4 shrink-0 text-primary" /> : <Folder className="h-4 w-4 shrink-0" />}
<span className="truncate">{node.folder.name}</span>
{!node.folder.isPublic && <Lock className="h-3 w-3 shrink-0 text-muted-foreground/50" />}
</button>
{expanded && hasChildren && (
<div>
{node.children.map((child) => (
<TreeItem key={child.folder.id} node={child} depth={depth + 1} selectedFolderId={selectedFolderId} onSelect={onSelect} />
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,142 @@
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

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,30 @@
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary dark:bg-input/30 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

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

@@ -0,0 +1,33 @@
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,174 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { vaultCategoryPermissionsOptions, vaultCategoryMutations, vaultKeys } from '@/api/vault'
import { roleListOptions } from '@/api/rbac'
import { userListOptions } from '@/api/users'
import type { UserRecord } from '@/api/users'
import type { Role } from '@/types/rbac'
import type { VaultCategoryPermission } from '@/types/vault'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Trash2, Shield, Users, User } from 'lucide-react'
import { toast } from 'sonner'
interface Props {
categoryId: string
categoryName: string
isPublic: boolean
open: boolean
onOpenChange: (open: boolean) => void
}
const ACCESS_LEVELS = [
{ value: 'view', label: 'View', variant: 'secondary' as const },
{ value: 'edit', label: 'Edit', variant: 'default' as const },
{ value: 'admin', label: 'Admin', variant: 'destructive' as const },
]
export function CategoryPermissionsDialog({ categoryId, categoryName, isPublic, open, onOpenChange }: Props) {
const queryClient = useQueryClient()
const [assigneeType, setAssigneeType] = useState<'role' | 'user'>('role')
const [assigneeId, setAssigneeId] = useState('')
const [accessLevel, setAccessLevel] = useState('view')
const { data: permissionsData, isLoading: permsLoading } = useQuery({
...vaultCategoryPermissionsOptions(categoryId),
enabled: open && !!categoryId,
})
const { data: rolesData } = useQuery({ ...roleListOptions(), enabled: open })
const { data: usersData } = useQuery({
...userListOptions({ page: 1, limit: 100, order: 'asc' }),
enabled: open && assigneeType === 'user',
})
const permissions = permissionsData?.data ?? []
const roles = rolesData?.data ?? []
const users = usersData?.data ?? []
const togglePublicMutation = useMutation({
mutationFn: (newIsPublic: boolean) => vaultCategoryMutations.update(categoryId, { isPublic: newIsPublic }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: vaultKeys.categories })
queryClient.invalidateQueries({ queryKey: vaultKeys.categoryDetail(categoryId) })
toast.success(isPublic ? 'Category set to private' : 'Category set to public')
},
onError: (err) => toast.error(err.message),
})
const addPermissionMutation = useMutation({
mutationFn: () => vaultCategoryMutations.addPermission(categoryId, {
...(assigneeType === 'role' ? { roleId: assigneeId } : { userId: assigneeId }),
accessLevel,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: vaultKeys.categoryPermissions(categoryId) })
setAssigneeId('')
setAccessLevel('view')
toast.success('Permission added')
},
onError: (err) => toast.error(err.message),
})
const removePermissionMutation = useMutation({
mutationFn: (permId: string) => vaultCategoryMutations.removePermission(permId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: vaultKeys.categoryPermissions(categoryId) })
toast.success('Permission removed')
},
onError: (err) => toast.error(err.message),
})
function getPermissionLabel(perm: VaultCategoryPermission) {
if (perm.roleId) {
const role = roles.find((r: Role) => r.id === perm.roleId)
return { icon: Users, name: role?.name ?? 'Unknown role' }
}
const user = users.find((u: UserRecord) => u.id === perm.userId)
return { icon: User, name: user ? `${user.firstName} ${user.lastName}` : 'Unknown user' }
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Permissions {categoryName}
</DialogTitle>
</DialogHeader>
<div className="space-y-5">
<div className="flex items-center justify-between rounded-lg border p-3">
<div>
<Label className="text-sm font-medium">Public category</Label>
<p className="text-xs text-muted-foreground mt-0.5">Public categories are viewable by all users with vault access</p>
</div>
<Switch checked={isPublic} onCheckedChange={(checked) => togglePublicMutation.mutate(checked)} disabled={togglePublicMutation.isPending} />
</div>
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Permissions</Label>
<div className="mt-2 space-y-1.5">
{permsLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : permissions.length === 0 ? (
<p className="text-sm text-muted-foreground">No specific permissions assigned</p>
) : (
permissions.map((perm) => {
const { icon: Icon, name } = getPermissionLabel(perm)
const level = ACCESS_LEVELS.find((l) => l.value === perm.accessLevel)
return (
<div key={perm.id} className="flex items-center justify-between rounded-md border px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-sm truncate">{name}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant={level?.variant ?? 'secondary'} className="text-xs">{level?.label ?? perm.accessLevel}</Badge>
<button type="button" onClick={() => removePermissionMutation.mutate(perm.id)} className="text-muted-foreground hover:text-destructive transition-colors" disabled={removePermissionMutation.isPending}>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
})
)}
</div>
</div>
<form onSubmit={(e) => { e.preventDefault(); if (assigneeId) addPermissionMutation.mutate() }} className="space-y-3">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Add Permission</Label>
<div className="flex gap-1 rounded-md border p-0.5">
<button type="button" onClick={() => { setAssigneeType('role'); setAssigneeId('') }} className={`flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${assigneeType === 'role' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}>Role</button>
<button type="button" onClick={() => { setAssigneeType('user'); setAssigneeId('') }} className={`flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${assigneeType === 'user' ? 'bg-primary text-primary-foreground' : 'hover:bg-muted'}`}>User</button>
</div>
<div className="flex gap-2">
<Select value={assigneeId} onValueChange={setAssigneeId}>
<SelectTrigger className="flex-1"><SelectValue placeholder={assigneeType === 'role' ? 'Select role...' : 'Select user...'} /></SelectTrigger>
<SelectContent>
{assigneeType === 'role'
? roles.map((role: Role) => <SelectItem key={role.id} value={role.id}>{role.name}</SelectItem>)
: users.map((user: UserRecord) => <SelectItem key={user.id} value={user.id}>{user.firstName} {user.lastName}</SelectItem>)
}
</SelectContent>
</Select>
<Select value={accessLevel} onValueChange={setAccessLevel}>
<SelectTrigger className="w-28"><SelectValue /></SelectTrigger>
<SelectContent>
{ACCESS_LEVELS.map((level) => <SelectItem key={level.value} value={level.value}>{level.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<Button type="submit" size="sm" disabled={!assigneeId || addPermissionMutation.isPending} className="w-full">
{addPermissionMutation.isPending ? 'Adding...' : 'Add Permission'}
</Button>
</form>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,5 +1,5 @@
import { useNavigate, useSearch } from '@tanstack/react-router'
import type { PaginationInput } from '@forte/shared/schemas'
import type { PaginationInput } from '@lunarfront/shared/schemas'
interface PaginationSearch {
page?: number
@@ -23,12 +23,13 @@ export function usePagination() {
function setParams(updates: Partial<PaginationSearch>) {
navigate({
search: ((prev: PaginationSearch) => ({
// @ts-expect-error: navigate without a route context resolves search as never; safe here since we use strict:false
search: (prev: any) => ({
...prev,
...updates,
// Reset to page 1 when search or sort changes
page: updates.q !== undefined || updates.sort !== undefined ? 1 : (updates.page ?? prev.page),
})) as any,
page: updates.q !== undefined || updates.sort !== undefined ? 1 : (updates.page ?? (prev as PaginationSearch).page),
}),
replace: true,
})
}

View File

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

Some files were not shown because too many files have changed in this diff Show More