first commit
This commit is contained in:
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
||||
DATABASE_URL="file:./dev.db"
|
||||
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
.next
|
||||
node_modules
|
||||
.env
|
||||
.env.local
|
||||
coverage
|
||||
dist
|
||||
*.log
|
||||
playwright-report
|
||||
test-results
|
||||
prisma/*.db
|
||||
prisma/*.db-journal
|
||||
164
North_star.md
Normal file
164
North_star.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# La Burbuja — Laundry POS System Specification
|
||||
|
||||
## Project North Star
|
||||
|
||||
This is a **local-first POS system** for a single-location attended laundromat. A cashier operates the system from a touchscreen PC or tablet. The software controls power to residential washing machines and dryers via USB relay board (connected to a Raspberry Pi or mini PC). The system does NOT need internet to function — it runs on localhost. Keep it simple, reliable, and fast.
|
||||
|
||||
**If a feature doesn't directly help the cashier process a transaction or help the owner see how the business is doing, it probably doesn't belong in v1.**
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend + Backend:** Next.js with TypeScript (App Router)
|
||||
- **Database:** SQLite (via Prisma or Drizzle) — single file, no external DB server
|
||||
- **Relay Communication:** Serial (USB) to relay board — simple write commands via `serialport` npm package
|
||||
- **Runs on:** Raspberry Pi 4/5 or any mini PC running Node.js, serving on `localhost:3000`
|
||||
- **UI:** Tailwind CSS, optimized for touch (large buttons, clear status colors)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
[Touchscreen / Browser]
|
||||
|
|
||||
localhost:3000
|
||||
|
|
||||
[Next.js App on Raspi / Mini PC]
|
||||
|
|
||||
[SQLite DB] [USB Serial → Relay Board]
|
||||
|
|
||||
[12 Contactors → Machine Power Lines]
|
||||
```
|
||||
|
||||
There is ONE user interface. The cashier sees everything on one screen or minimal navigation. No customer-facing UI in v1.
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Machines
|
||||
- Each machine has: `id`, `name` (e.g., "Lavadora 1", "Secadora 3"), `type` (washer | dryer), `status` (available | running | out_of_service), `relayChannel` (0-15), `defaultPrice`, `defaultDurationMinutes`
|
||||
- Machines are configured once at setup, rarely changed
|
||||
- Status is derived: if a timer is active → running. If not → available. Manual override for out_of_service.
|
||||
|
||||
### Transactions
|
||||
- Each transaction records: `id`, `machineId`, `amount`, `paymentMethod` (cash | card | transfer), `startedAt`, `expectedEndAt`, `employeeId`, `createdAt`
|
||||
- A transaction = one machine activation. If a customer uses 1 washer + 1 dryer, that's 2 transactions.
|
||||
- Transactions are immutable once created (no editing, only voiding with reason).
|
||||
|
||||
### Employees / Shifts
|
||||
- Simple employee list with PIN login (4-digit)
|
||||
- Shift = period between cash register open and close (corte de caja)
|
||||
- Each shift tracks: `employeeId`, `startTime`, `endTime`, `startingCash`, `cashDeposits`, `cashWithdrawals`, `expectedCash` (calculated), `actualCash` (entered at corte)
|
||||
|
||||
---
|
||||
|
||||
## Screens
|
||||
|
||||
### 1. Main Dashboard (primary screen — cashier lives here)
|
||||
- Grid of all 12 machines as large, tappable cards
|
||||
- Each card shows: machine name, type icon (washer/dryer), status color (green=available, blue=running with countdown timer, red=out of service), remaining time if running
|
||||
- Tapping an available machine opens the **Activate Modal**
|
||||
- Tapping a running machine shows transaction details + option to add time
|
||||
- This screen should feel like a control panel, not a spreadsheet
|
||||
|
||||
### 2. Activate Modal
|
||||
- Shows: machine name, default price (editable), default duration (editable), payment method selector (cash / card / transfer)
|
||||
- Big "ACTIVAR" button
|
||||
- On confirm: creates transaction, sends relay command to power on the machine, starts countdown timer
|
||||
- Timer runs in the app — when it hits zero, relay command powers off the machine
|
||||
- **Keep this to 2 taps maximum: select machine → confirm activation**
|
||||
|
||||
### 3. Cash Register / Corte de Caja
|
||||
- Current shift summary: total sales, breakdown by payment method, number of transactions
|
||||
- Button to register cash deposit or withdrawal (with reason field)
|
||||
- "Cerrar Turno" button: prompts for actual cash count, calculates difference vs expected, prints/saves corte summary
|
||||
- Historical cortes viewable by date
|
||||
|
||||
### 4. Reports / Metrics
|
||||
- Date range selector (today / this week / this month / custom)
|
||||
- Key metrics: total revenue, transaction count, average ticket, revenue by machine, revenue by payment method
|
||||
- Machine utilization: % of operating hours each machine was running
|
||||
- Simple bar charts or summary cards — no complex dashboards
|
||||
- Export to CSV option
|
||||
|
||||
### 5. Settings
|
||||
- Machine configuration (add/edit/disable machines, assign relay channels, set prices)
|
||||
- Employee management (add/remove, reset PIN)
|
||||
- Serial port configuration (select USB port for relay board)
|
||||
- Business info (store name, for receipt headers)
|
||||
|
||||
---
|
||||
|
||||
## Relay Board Communication
|
||||
|
||||
- The relay board connects via USB and appears as a serial port (e.g., `/dev/ttyUSB0`)
|
||||
- Communication is simple serial write commands — the exact protocol depends on the board chosen, but typically:
|
||||
- Turn on relay N: send a specific byte sequence
|
||||
- Turn off relay N: send a different byte sequence
|
||||
- Some boards use ASCII commands like `relay on 3\n`
|
||||
- **Abstract this behind a simple interface:**
|
||||
```typescript
|
||||
interface RelayController {
|
||||
connect(port: string, baudRate: number): Promise<void>
|
||||
turnOn(channel: number): Promise<void>
|
||||
turnOff(channel: number): Promise<void>
|
||||
getStatus(channel: number): Promise<boolean>
|
||||
disconnect(): Promise<void>
|
||||
}
|
||||
```
|
||||
- Include a **mock/simulator mode** for development and testing without hardware
|
||||
- On application startup, restore state: check DB for any transactions with unexpired timers and re-activate those relays
|
||||
- On unexpected shutdown/restart: same recovery logic — check timers, restore relay states
|
||||
- **Timer expiration must trigger relay off even if nobody is looking at the screen.** Use a server-side interval/scheduler, not just frontend timers.
|
||||
|
||||
---
|
||||
|
||||
## Critical Reliability Rules
|
||||
|
||||
1. **The relay off-command on timer expiry is the most important operation in the system.** If the app crashes, the machine keeps running on the owner's dime. Use a server-side scheduler (e.g., `node-cron` or `setTimeout` with persistence) and verify relay state on restart.
|
||||
2. **Database writes before relay commands.** Always save the transaction first, then activate the relay. If the relay command fails, the transaction exists and can be retried. Never the reverse.
|
||||
3. **No internet dependency.** Everything works offline. The clock is the Raspi's system clock.
|
||||
4. **Graceful serial port handling.** If the relay board disconnects, show a clear error on screen but don't crash the app. Allow reconnection without restart.
|
||||
|
||||
---
|
||||
|
||||
## What This Project is NOT
|
||||
|
||||
- NOT a customer-facing self-service kiosk (cashier operates everything)
|
||||
- NOT a multi-location system (single store, single database)
|
||||
- NOT a billing/invoicing system (no CFDI, no tax calculations in v1)
|
||||
- NOT an inventory management system
|
||||
- NOT connected to the internet for operation (metrics export is manual/CSV)
|
||||
- NOT integrated with a bill acceptor in v1 (cash is handled by the cashier physically, software just records the payment method)
|
||||
|
||||
---
|
||||
|
||||
## Language & Locale
|
||||
|
||||
- UI text in **Spanish (Mexico)**
|
||||
- Currency: **MXN**, formatted as `$XX.XX`
|
||||
- Dates: `DD/MMM/YYYY` format
|
||||
- Timezone: `America/Monterrey` (CST/CDT)
|
||||
|
||||
---
|
||||
|
||||
## v2 Ideas (do NOT build these now, but don't make architecture decisions that prevent them)
|
||||
|
||||
- Bill acceptor integration (USB serial, similar to relay board)
|
||||
- Customer-facing status screen (second monitor showing machine availability)
|
||||
- SMS/WhatsApp notification when cycle is done
|
||||
- Remote monitoring dashboard (simple web view of today's metrics, requires internet)
|
||||
- NFC loyalty card system
|
||||
- Multi-store support
|
||||
- Washer/dryer current sensing for actual cycle-complete detection instead of timer-only
|
||||
|
||||
---
|
||||
|
||||
## Summary for the AI
|
||||
|
||||
You are building a laundry POS that controls washing machines via relay. Think of it as a **timer-based power switch with a cash register attached.** The cashier taps a machine, confirms payment, and the machine gets power for X minutes. When time is up, power cuts. Everything is logged. At end of shift, cashier counts cash and closes out. Owner can see reports.
|
||||
|
||||
Keep the codebase small. Keep the UI obvious. Keep the system reliable. That's it.
|
||||
40
README.md
Normal file
40
README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# La Burbuja POS
|
||||
|
||||
Sistema local-first para lavanderia atendida, construido con Next.js + TypeScript + SQLite (Prisma), siguiendo [`North_star.md`](./North_star.md).
|
||||
|
||||
## Stack
|
||||
- Next.js (App Router) + TypeScript
|
||||
- SQLite + Prisma
|
||||
- Tailwind CSS
|
||||
- Relay USB serial (`serialport`) + simulador
|
||||
|
||||
## Quick Start
|
||||
```powershell
|
||||
npm install
|
||||
Copy-Item .env.example .env
|
||||
npm run prisma:migrate
|
||||
npm run prisma:generate
|
||||
npm run prisma:seed
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Comandos
|
||||
- `npm run dev` desarrollo
|
||||
- `npm run build` build produccion
|
||||
- `npm run start` servidor produccion
|
||||
- `npm run test` tests unitarios
|
||||
- `npm run test:e2e` tests e2e
|
||||
|
||||
## Cobertura funcional implementada
|
||||
- Panel principal de maquinas (disponible/running/fuera de servicio)
|
||||
- Activacion con orden critico: DB antes de relay
|
||||
- Scheduler server-side para expiracion y apagado relay
|
||||
- Recovery al reiniciar para timers/transacciones activas
|
||||
- Agregar tiempo a transacciones activas
|
||||
- Apertura/cierre de turno, movimientos de caja y calculo de esperado vs real
|
||||
- Reportes con resumen, utilizacion y export CSV
|
||||
- Configuracion basica de maquinas, empleados, serial y modo simulador
|
||||
|
||||
## Operacion y despliegue
|
||||
- Ver [`docs/DEPLOYMENT.md`](./docs/DEPLOYMENT.md)
|
||||
- Ver [`docs/RUNBOOK.md`](./docs/RUNBOOK.md)
|
||||
34
docs/DEPLOYMENT.md
Normal file
34
docs/DEPLOYMENT.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Deployment Guide (Raspberry Pi / Mini PC)
|
||||
|
||||
## 1. Requisitos
|
||||
- Node.js 20+ (LTS recomendado).
|
||||
- `npm` disponible.
|
||||
- Puerto USB del relay identificado.
|
||||
- Sistema operativo con zona horaria `America/Monterrey`.
|
||||
|
||||
## 2. Instalacion
|
||||
```powershell
|
||||
npm install
|
||||
Copy-Item .env.example .env
|
||||
npm run prisma:migrate
|
||||
npm run prisma:generate
|
||||
npm run prisma:seed
|
||||
```
|
||||
|
||||
## 3. Arranque en produccion
|
||||
```powershell
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
## 4. Verificaciones de salida
|
||||
- Abrir `http://localhost:3000`.
|
||||
- Validar banner de relay conectado (mock o serial).
|
||||
- Activar una maquina en mock mode y revisar countdown.
|
||||
- Esperar expiracion y confirmar que transaccion pasa a `completed`.
|
||||
|
||||
## 5. Reconexion serial
|
||||
- Ir a `Configuracion`.
|
||||
- Capturar puerto (`COMx` o `/dev/ttyUSB0`) y baud rate.
|
||||
- Desactivar modo simulador.
|
||||
- Ejecutar `Reconectar relay`.
|
||||
33
docs/RUNBOOK.md
Normal file
33
docs/RUNBOOK.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Offline Operations Runbook
|
||||
|
||||
## Inicio diario
|
||||
1. Encender equipo local.
|
||||
2. Ejecutar `npm run start` (o servicio equivalente).
|
||||
3. Entrar al POS con PIN.
|
||||
4. Abrir turno con efectivo inicial.
|
||||
|
||||
## Operacion normal
|
||||
1. Seleccionar maquina disponible.
|
||||
2. Confirmar importe, tiempo y metodo de pago.
|
||||
3. Verificar que el estado cambie a `running`.
|
||||
|
||||
## Incidencias
|
||||
### Relay desconectado
|
||||
1. Revisar cable USB y alimentacion.
|
||||
2. Ir a `Configuracion > Serial / Relay`.
|
||||
3. Ejecutar `Reconectar relay`.
|
||||
4. Si falla, activar `Modo simulador` temporalmente para seguir cobrando y registrar manualmente encendidos.
|
||||
|
||||
### Reinicio inesperado
|
||||
1. Reiniciar aplicacion.
|
||||
2. Validar que timers activos fueron recuperados.
|
||||
3. Confirmar que transacciones vencidas quedaron en `completed`.
|
||||
|
||||
## Corte de caja
|
||||
1. En `Corte`, registrar depositos/retiros con motivo.
|
||||
2. Capturar efectivo contado al cierre.
|
||||
3. Ejecutar `Cerrar Turno`.
|
||||
4. Descargar CSV de reportes si se requiere respaldo manual.
|
||||
|
||||
## Respaldo
|
||||
- Respaldar archivo SQLite (`prisma/dev.db`) al cierre del dia en USB o carpeta segura.
|
||||
137
docs/pricing.md
Normal file
137
docs/pricing.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# La Burbuja — Pricing System Prompt
|
||||
|
||||
## Business context
|
||||
|
||||
La Burbuja is a laundromat in Santa Catarina, Nuevo León (Monterrey metro). It operates three service lines: self-service, drop-off (encargo), and dry cleaning (tintorería). The POS system must handle all three with distinct pricing logic.
|
||||
|
||||
---
|
||||
|
||||
## Machine inventory
|
||||
|
||||
| Machine ID | Type | Service line | Notes |
|
||||
|---|---|---|---|
|
||||
| Lavadora 1–12 | LG WM22VV2S6R combo (washer + dryer stacked) | Self-service | Client-facing, coin/app activated |
|
||||
| Lavadora 13–15 | LG WM22VV2S6R combo | Drop-off (encargo) | Staff-operated, back room |
|
||||
| Lavadora 16 | XL combo (larger capacity) | Edredones / bulky items | Staff-operated, back room |
|
||||
|
||||
---
|
||||
|
||||
## Service line 1: Self-service (autoservicio)
|
||||
|
||||
**Pricing model:** Fixed price per cycle. Customer activates machine directly.
|
||||
|
||||
| Service | Price (MXN) | Duration | Machine |
|
||||
|---|---|---|---|
|
||||
| Wash | $45 | 50 min | Lavadora 1–12 |
|
||||
| Dry | $45 | 50 min | Lavadora 1–12 |
|
||||
|
||||
- **Total per load:** $90 (wash + dry)
|
||||
- **Payment methods:** Cash (coin changer), card, app (future)
|
||||
- **Detergent/softener:** Included in price OR vending machine add-on (TBD)
|
||||
|
||||
---
|
||||
|
||||
## Service line 2: Drop-off / encargo
|
||||
|
||||
**Pricing model:** Per-kilogram, with minimum charge. Staff weighs intake, calculates price, activates machine at that price.
|
||||
|
||||
| Parameter | Value |
|
||||
|---|---|
|
||||
| Price per kg | $33 MXN |
|
||||
| Minimum charge | $120 MXN (~3.6 kg) |
|
||||
| Service includes | Wash + dry + fold |
|
||||
| Turnaround | 24 hours standard |
|
||||
|
||||
### Activation logic
|
||||
|
||||
1. Staff receives customer's bag
|
||||
2. Weigh on scale → get weight in kg
|
||||
3. Calculate: `price = max(weight × $33, $120)`
|
||||
4. Enter calculated price in POS activation field
|
||||
5. Activate encargo machine (Lavadora 13–15) at that price
|
||||
6. Price recorded in POS = actual revenue for that order
|
||||
|
||||
### Simulated weight (pre-scale)
|
||||
|
||||
Until a physical scale is purchased/connected, the system can generate a simulated weight for testing purposes:
|
||||
|
||||
- Random weight between 1–10 kg (uniform distribution)
|
||||
- Display simulated weight to staff for confirmation
|
||||
- Staff can override manually
|
||||
- Flag all simulated-weight transactions for later reconciliation
|
||||
|
||||
### Edredones / bulky items (Lavadora 16 — XL)
|
||||
|
||||
**Pricing model:** Fixed price per item (premium, not per-kg).
|
||||
|
||||
| Item | Price (MXN) | Notes |
|
||||
|---|---|---|
|
||||
| Edredón individual | $150 | Single/twin comforter |
|
||||
| Edredón matrimonial | $180 | Queen size |
|
||||
| Edredón king | $200 | King size |
|
||||
| Cobija gruesa | $120 | Heavy blanket |
|
||||
| Almohada (par) | $80 | Pillow pair |
|
||||
|
||||
- These are drop-off only (staff-operated)
|
||||
- Machine activated at the fixed item price
|
||||
- If customer brings multiple items, sum the individual prices
|
||||
|
||||
---
|
||||
|
||||
## Service line 3: Dry cleaning / tintorería
|
||||
|
||||
**Pricing model:** Per-piece, fixed price by garment type. This is a future service line — prices are estimates based on Monterrey market research and will be confirmed before launch. Dry cleaning will likely be outsourced to a partner tintorería initially, with La Burbuja acting as a drop-off/pick-up point and adding a margin.
|
||||
|
||||
### Estimated price list (MXN)
|
||||
|
||||
| Category | Item | Estimated price |
|
||||
|---|---|---|
|
||||
| **Básico** | Camisa / blusa | $65 |
|
||||
| | Pantalón | $65 |
|
||||
| | Falda | $65 |
|
||||
| | Suéter / sudadera | $75 |
|
||||
| **Formal** | Saco | $80 |
|
||||
| | Traje 2 piezas | $150 |
|
||||
| | Traje 3 piezas | $200 |
|
||||
| | Corbata | $55 |
|
||||
| | Chaleco | $65 |
|
||||
| **Vestidos** | Vestido sencillo | $100 |
|
||||
| | Vestido de noche | $150 |
|
||||
| | Vestido con aplicaciones | $200 |
|
||||
| **Abrigos** | Chamarra ligera | $100 |
|
||||
| | Chamarra gruesa / pluma | $180 |
|
||||
| | Abrigo / gabardina | $130 |
|
||||
| **Hogar** | Mantel (pieza) | $60 |
|
||||
| | Juego de sábanas | $80 |
|
||||
| | Cortinas (por metro) | $50 |
|
||||
| **Especial** | Tenis / zapatos | $120 |
|
||||
| | Vestido de novia (consultar) | $500+ |
|
||||
|
||||
### Operational notes
|
||||
|
||||
- Turnaround: 48–72 hours (dependent on outsource partner schedule)
|
||||
- Minimum order: 3 pieces or $150 MXN
|
||||
- Urgent service (24 hrs): +50% surcharge
|
||||
- Items received with visible stains: customer notified, desmanchado quoted separately
|
||||
- Items received on hangers with plastic cover (cubre polvo)
|
||||
|
||||
---
|
||||
|
||||
## Pricing summary by service line
|
||||
|
||||
| Service | Pricing logic | Who operates | Where |
|
||||
|---|---|---|---|
|
||||
| Self-service | Fixed per cycle ($45 wash / $45 dry) | Customer | Main floor, Lavadoras 1–12 |
|
||||
| Drop-off (ropa) | Per-kg ($33/kg, min $120) | Staff | Back room, Lavadoras 13–15 |
|
||||
| Drop-off (edredones) | Fixed per item ($120–$200) | Staff | Back room, Lavadora 16 XL |
|
||||
| Dry cleaning | Fixed per piece ($55–$200+) | Staff (outsourced) | Reception at cashier |
|
||||
|
||||
---
|
||||
|
||||
## Future considerations
|
||||
|
||||
- **Loyalty / frequency discounts:** e.g., 10th wash free, or bulk kg discount for encargo regulars
|
||||
- **Express encargo:** 4-hour turnaround at +30% premium
|
||||
- **Detergent upsell:** premium detergent/softener options at $15–25 per dose via vending
|
||||
- **Scale integration:** digital scale connected to POS for automatic weight → price calculation
|
||||
- **Seasonal pricing:** edredón wash promotions in spring/fall (seasonal demand spikes)
|
||||
6
next-env.d.ts
vendored
Normal file
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
14
next.config.ts
Normal file
14
next.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const useStandaloneOutput = process.env.NEXT_OUTPUT_STANDALONE === "1";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
...(useStandaloneOutput ? { output: "standalone" } : {}),
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "2mb"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
8986
package-lock.json
generated
Normal file
8986
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
package.json
Normal file
46
package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "lavanderia-checo-sistema",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "prisma db seed"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.3",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.1.3",
|
||||
"next": "^15.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"serialport": "^13.0.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^15.5.14",
|
||||
"postcss": "^8.4.49",
|
||||
"prisma": "^6.19.3",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
15
playwright.config.ts
Normal file
15
playwright.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
timeout: 60_000,
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:3000",
|
||||
headless: true
|
||||
},
|
||||
webServer: {
|
||||
command: "npm run dev",
|
||||
port: 3000,
|
||||
reuseExistingServer: !process.env.CI
|
||||
}
|
||||
});
|
||||
6
postcss.config.mjs
Normal file
6
postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
121
prisma/migrations/20260406193800_init/migration.sql
Normal file
121
prisma/migrations/20260406193800_init/migration.sql
Normal file
@@ -0,0 +1,121 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Machine" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"relayChannel" INTEGER NOT NULL,
|
||||
"defaultPriceCents" INTEGER NOT NULL,
|
||||
"defaultDurationMinutes" INTEGER NOT NULL,
|
||||
"outOfService" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Employee" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"pin" TEXT NOT NULL,
|
||||
"isAdmin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Transaction" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"machineId" TEXT NOT NULL,
|
||||
"employeeId" TEXT NOT NULL,
|
||||
"amountCents" INTEGER NOT NULL,
|
||||
"paymentMethod" TEXT NOT NULL,
|
||||
"startedAt" DATETIME NOT NULL,
|
||||
"expectedEndAt" DATETIME NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending_relay',
|
||||
"endedAt" DATETIME,
|
||||
"relayOnAttemptedAt" DATETIME,
|
||||
"relayTurnedOnAt" DATETIME,
|
||||
"relayOffAttemptedAt" DATETIME,
|
||||
"relayTurnedOffAt" DATETIME,
|
||||
"relayFailureReason" TEXT,
|
||||
"voidReason" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Transaction_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Transaction_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TransactionExtension" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"transactionId" TEXT NOT NULL,
|
||||
"employeeId" TEXT NOT NULL,
|
||||
"extraMinutes" INTEGER NOT NULL,
|
||||
"extraAmountCents" INTEGER NOT NULL,
|
||||
"reason" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "TransactionExtension_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "Transaction" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "TransactionExtension_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Shift" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"employeeId" TEXT NOT NULL,
|
||||
"startTime" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"endTime" DATETIME,
|
||||
"startingCashCents" INTEGER NOT NULL,
|
||||
"expectedCashCents" INTEGER,
|
||||
"actualCashCents" INTEGER,
|
||||
"differenceCashCents" INTEGER,
|
||||
"notes" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Shift_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "CashMovement" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"shiftId" TEXT NOT NULL,
|
||||
"employeeId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"amountCents" INTEGER NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "CashMovement_shiftId_fkey" FOREIGN KEY ("shiftId") REFERENCES "Shift" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "CashMovement_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AppConfig" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT DEFAULT 1,
|
||||
"businessName" TEXT NOT NULL DEFAULT 'La Burbuja',
|
||||
"timezone" TEXT NOT NULL DEFAULT 'America/Monterrey',
|
||||
"currency" TEXT NOT NULL DEFAULT 'MXN',
|
||||
"serialPortPath" TEXT NOT NULL DEFAULT 'COM3',
|
||||
"serialBaudRate" INTEGER NOT NULL DEFAULT 9600,
|
||||
"relayMockMode" BOOLEAN NOT NULL DEFAULT true,
|
||||
"relayConnected" BOOLEAN NOT NULL DEFAULT false,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Machine_name_key" ON "Machine"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Machine_relayChannel_key" ON "Machine"("relayChannel");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Transaction_expectedEndAt_idx" ON "Transaction"("expectedEndAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Transaction_status_idx" ON "Transaction"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Shift_startTime_idx" ON "Shift"("startTime");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CashMovement_createdAt_idx" ON "CashMovement"("createdAt");
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_AppConfig" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT DEFAULT 1,
|
||||
"businessName" TEXT NOT NULL DEFAULT 'La Burbuja',
|
||||
"timezone" TEXT NOT NULL DEFAULT 'America/Monterrey',
|
||||
"currency" TEXT NOT NULL DEFAULT 'MXN',
|
||||
"serialPortPath" TEXT NOT NULL DEFAULT 'COM3',
|
||||
"serialBaudRate" INTEGER NOT NULL DEFAULT 9600,
|
||||
"relayMockMode" BOOLEAN NOT NULL DEFAULT true,
|
||||
"relayConnected" BOOLEAN NOT NULL DEFAULT false,
|
||||
"selfServiceWashPriceCents" INTEGER NOT NULL DEFAULT 4500,
|
||||
"selfServiceDryPriceCents" INTEGER NOT NULL DEFAULT 4500,
|
||||
"selfServiceCycleMinutes" INTEGER NOT NULL DEFAULT 50,
|
||||
"encargoPricePerKgCents" INTEGER NOT NULL DEFAULT 3300,
|
||||
"encargoMinimumChargeCents" INTEGER NOT NULL DEFAULT 12000,
|
||||
"xlEdredonIndividualCents" INTEGER NOT NULL DEFAULT 15000,
|
||||
"xlEdredonMatrimonialCents" INTEGER NOT NULL DEFAULT 18000,
|
||||
"xlEdredonKingCents" INTEGER NOT NULL DEFAULT 20000,
|
||||
"xlCobijaGruesaCents" INTEGER NOT NULL DEFAULT 12000,
|
||||
"xlAlmohadaParCents" INTEGER NOT NULL DEFAULT 8000,
|
||||
"dryCleaningMinimumCents" INTEGER NOT NULL DEFAULT 15000,
|
||||
"dryCleaningUrgentSurchargePct" INTEGER NOT NULL DEFAULT 50,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_AppConfig" ("businessName", "currency", "id", "relayConnected", "relayMockMode", "serialBaudRate", "serialPortPath", "timezone", "updatedAt") SELECT "businessName", "currency", "id", "relayConnected", "relayMockMode", "serialBaudRate", "serialPortPath", "timezone", "updatedAt" FROM "AppConfig";
|
||||
DROP TABLE "AppConfig";
|
||||
ALTER TABLE "new_AppConfig" RENAME TO "AppConfig";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Added the required column `baseAmountCents` to the `Transaction` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `customerId` to the `Transaction` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `ticketNumber` to the `Transaction` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- CreateTable
|
||||
CREATE TABLE "Customer" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "Customer" ("id", "firstName", "lastName", "phone", "email", "isActive", "createdAt", "updatedAt")
|
||||
VALUES ('legacy-customer', 'Cliente', 'Mostrador', 'LEGACY-UNASSIGNED', NULL, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_AppConfig" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT DEFAULT 1,
|
||||
"businessName" TEXT NOT NULL DEFAULT 'La Burbuja',
|
||||
"timezone" TEXT NOT NULL DEFAULT 'America/Monterrey',
|
||||
"currency" TEXT NOT NULL DEFAULT 'MXN',
|
||||
"serialPortPath" TEXT NOT NULL DEFAULT 'COM3',
|
||||
"serialBaudRate" INTEGER NOT NULL DEFAULT 9600,
|
||||
"relayMockMode" BOOLEAN NOT NULL DEFAULT true,
|
||||
"relayConnected" BOOLEAN NOT NULL DEFAULT false,
|
||||
"selfServiceWashPriceCents" INTEGER NOT NULL DEFAULT 4500,
|
||||
"selfServiceDryPriceCents" INTEGER NOT NULL DEFAULT 4500,
|
||||
"selfServiceCycleMinutes" INTEGER NOT NULL DEFAULT 50,
|
||||
"encargoPricePerKgCents" INTEGER NOT NULL DEFAULT 3300,
|
||||
"encargoMinimumChargeCents" INTEGER NOT NULL DEFAULT 12000,
|
||||
"xlEdredonIndividualCents" INTEGER NOT NULL DEFAULT 15000,
|
||||
"xlEdredonMatrimonialCents" INTEGER NOT NULL DEFAULT 18000,
|
||||
"xlEdredonKingCents" INTEGER NOT NULL DEFAULT 20000,
|
||||
"xlCobijaGruesaCents" INTEGER NOT NULL DEFAULT 12000,
|
||||
"xlAlmohadaParCents" INTEGER NOT NULL DEFAULT 8000,
|
||||
"dryCleaningMinimumCents" INTEGER NOT NULL DEFAULT 15000,
|
||||
"dryCleaningUrgentSurchargePct" INTEGER NOT NULL DEFAULT 50,
|
||||
"detergentAddonCents" INTEGER NOT NULL DEFAULT 500,
|
||||
"softenerAddonCents" INTEGER NOT NULL DEFAULT 500,
|
||||
"bleachAddonCents" INTEGER NOT NULL DEFAULT 500,
|
||||
"loyaltyEveryNTransactions" INTEGER NOT NULL DEFAULT 10,
|
||||
"loyaltyDiscountPct" INTEGER NOT NULL DEFAULT 50,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_AppConfig" ("businessName", "currency", "dryCleaningMinimumCents", "dryCleaningUrgentSurchargePct", "encargoMinimumChargeCents", "encargoPricePerKgCents", "id", "relayConnected", "relayMockMode", "selfServiceCycleMinutes", "selfServiceDryPriceCents", "selfServiceWashPriceCents", "serialBaudRate", "serialPortPath", "timezone", "updatedAt", "xlAlmohadaParCents", "xlCobijaGruesaCents", "xlEdredonIndividualCents", "xlEdredonKingCents", "xlEdredonMatrimonialCents") SELECT "businessName", "currency", "dryCleaningMinimumCents", "dryCleaningUrgentSurchargePct", "encargoMinimumChargeCents", "encargoPricePerKgCents", "id", "relayConnected", "relayMockMode", "selfServiceCycleMinutes", "selfServiceDryPriceCents", "selfServiceWashPriceCents", "serialBaudRate", "serialPortPath", "timezone", "updatedAt", "xlAlmohadaParCents", "xlCobijaGruesaCents", "xlEdredonIndividualCents", "xlEdredonKingCents", "xlEdredonMatrimonialCents" FROM "AppConfig";
|
||||
DROP TABLE "AppConfig";
|
||||
ALTER TABLE "new_AppConfig" RENAME TO "AppConfig";
|
||||
CREATE TABLE "new_Transaction" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"ticketNumber" INTEGER NOT NULL,
|
||||
"machineId" TEXT NOT NULL,
|
||||
"employeeId" TEXT NOT NULL,
|
||||
"customerId" TEXT NOT NULL,
|
||||
"baseAmountCents" INTEGER NOT NULL,
|
||||
"discountCents" INTEGER NOT NULL DEFAULT 0,
|
||||
"loyaltyDiscountApplied" BOOLEAN NOT NULL DEFAULT false,
|
||||
"addonDetergentQty" INTEGER NOT NULL DEFAULT 0,
|
||||
"addonSoftenerQty" INTEGER NOT NULL DEFAULT 0,
|
||||
"addonBleachQty" INTEGER NOT NULL DEFAULT 0,
|
||||
"addonAmountCents" INTEGER NOT NULL DEFAULT 0,
|
||||
"amountCents" INTEGER NOT NULL,
|
||||
"paymentMethod" TEXT NOT NULL,
|
||||
"startedAt" DATETIME NOT NULL,
|
||||
"expectedEndAt" DATETIME NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending_relay',
|
||||
"endedAt" DATETIME,
|
||||
"relayOnAttemptedAt" DATETIME,
|
||||
"relayTurnedOnAt" DATETIME,
|
||||
"relayOffAttemptedAt" DATETIME,
|
||||
"relayTurnedOffAt" DATETIME,
|
||||
"relayFailureReason" TEXT,
|
||||
"voidReason" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Transaction_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Transaction_employeeId_fkey" FOREIGN KEY ("employeeId") REFERENCES "Employee" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "Transaction_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Transaction" (
|
||||
"id",
|
||||
"ticketNumber",
|
||||
"machineId",
|
||||
"employeeId",
|
||||
"customerId",
|
||||
"baseAmountCents",
|
||||
"discountCents",
|
||||
"loyaltyDiscountApplied",
|
||||
"addonDetergentQty",
|
||||
"addonSoftenerQty",
|
||||
"addonBleachQty",
|
||||
"addonAmountCents",
|
||||
"amountCents",
|
||||
"paymentMethod",
|
||||
"startedAt",
|
||||
"expectedEndAt",
|
||||
"status",
|
||||
"endedAt",
|
||||
"relayOnAttemptedAt",
|
||||
"relayTurnedOnAt",
|
||||
"relayOffAttemptedAt",
|
||||
"relayTurnedOffAt",
|
||||
"relayFailureReason",
|
||||
"voidReason",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
)
|
||||
SELECT
|
||||
"id",
|
||||
ROW_NUMBER() OVER (ORDER BY "createdAt", "id") AS "ticketNumber",
|
||||
"machineId",
|
||||
"employeeId",
|
||||
'legacy-customer' AS "customerId",
|
||||
"amountCents" AS "baseAmountCents",
|
||||
0 AS "discountCents",
|
||||
false AS "loyaltyDiscountApplied",
|
||||
0 AS "addonDetergentQty",
|
||||
0 AS "addonSoftenerQty",
|
||||
0 AS "addonBleachQty",
|
||||
0 AS "addonAmountCents",
|
||||
"amountCents",
|
||||
"paymentMethod",
|
||||
"startedAt",
|
||||
"expectedEndAt",
|
||||
"status",
|
||||
"endedAt",
|
||||
"relayOnAttemptedAt",
|
||||
"relayTurnedOnAt",
|
||||
"relayOffAttemptedAt",
|
||||
"relayTurnedOffAt",
|
||||
"relayFailureReason",
|
||||
"voidReason",
|
||||
"createdAt",
|
||||
"updatedAt"
|
||||
FROM "Transaction";
|
||||
DROP TABLE "Transaction";
|
||||
ALTER TABLE "new_Transaction" RENAME TO "Transaction";
|
||||
CREATE UNIQUE INDEX "Transaction_ticketNumber_key" ON "Transaction"("ticketNumber");
|
||||
CREATE INDEX "Transaction_expectedEndAt_idx" ON "Transaction"("expectedEndAt");
|
||||
CREATE INDEX "Transaction_status_idx" ON "Transaction"("status");
|
||||
CREATE INDEX "Transaction_customerId_idx" ON "Transaction"("customerId");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Customer_phone_key" ON "Customer"("phone");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Customer_lastName_firstName_idx" ON "Customer"("lastName", "firstName");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "Transaction" ADD COLUMN "serviceType" TEXT NOT NULL DEFAULT 'autoservicio';
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
162
prisma/schema.prisma
Normal file
162
prisma/schema.prisma
Normal file
@@ -0,0 +1,162 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
engineType = "binary"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Machine {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
type String
|
||||
relayChannel Int @unique
|
||||
defaultPriceCents Int
|
||||
defaultDurationMinutes Int
|
||||
outOfService Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
transactions Transaction[]
|
||||
}
|
||||
|
||||
model Employee {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
pin String
|
||||
isAdmin Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
shifts Shift[]
|
||||
transactions Transaction[]
|
||||
cashMovements CashMovement[]
|
||||
extensions TransactionExtension[]
|
||||
}
|
||||
|
||||
model Customer {
|
||||
id String @id @default(cuid())
|
||||
firstName String
|
||||
lastName String
|
||||
phone String @unique
|
||||
email String?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
transactions Transaction[]
|
||||
|
||||
@@index([lastName, firstName])
|
||||
}
|
||||
|
||||
model Transaction {
|
||||
id String @id @default(cuid())
|
||||
ticketNumber Int @unique
|
||||
machineId String
|
||||
machine Machine @relation(fields: [machineId], references: [id])
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id])
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
baseAmountCents Int
|
||||
discountCents Int @default(0)
|
||||
loyaltyDiscountApplied Boolean @default(false)
|
||||
addonDetergentQty Int @default(0)
|
||||
addonSoftenerQty Int @default(0)
|
||||
addonBleachQty Int @default(0)
|
||||
addonAmountCents Int @default(0)
|
||||
serviceType String @default("autoservicio")
|
||||
amountCents Int
|
||||
paymentMethod String
|
||||
startedAt DateTime
|
||||
expectedEndAt DateTime
|
||||
status String @default("pending_relay")
|
||||
endedAt DateTime?
|
||||
relayOnAttemptedAt DateTime?
|
||||
relayTurnedOnAt DateTime?
|
||||
relayOffAttemptedAt DateTime?
|
||||
relayTurnedOffAt DateTime?
|
||||
relayFailureReason String?
|
||||
voidReason String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
extensions TransactionExtension[]
|
||||
|
||||
@@index([expectedEndAt])
|
||||
@@index([status])
|
||||
@@index([customerId])
|
||||
}
|
||||
|
||||
model TransactionExtension {
|
||||
id String @id @default(cuid())
|
||||
transactionId String
|
||||
transaction Transaction @relation(fields: [transactionId], references: [id])
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id])
|
||||
extraMinutes Int
|
||||
extraAmountCents Int
|
||||
reason String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Shift {
|
||||
id String @id @default(cuid())
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id])
|
||||
startTime DateTime @default(now())
|
||||
endTime DateTime?
|
||||
startingCashCents Int
|
||||
expectedCashCents Int?
|
||||
actualCashCents Int?
|
||||
differenceCashCents Int?
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
cashMovements CashMovement[]
|
||||
|
||||
@@index([startTime])
|
||||
}
|
||||
|
||||
model CashMovement {
|
||||
id String @id @default(cuid())
|
||||
shiftId String
|
||||
shift Shift @relation(fields: [shiftId], references: [id])
|
||||
employeeId String
|
||||
employee Employee @relation(fields: [employeeId], references: [id])
|
||||
type String
|
||||
amountCents Int
|
||||
reason String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model AppConfig {
|
||||
id Int @id @default(1)
|
||||
businessName String @default("La Burbuja")
|
||||
timezone String @default("America/Monterrey")
|
||||
currency String @default("MXN")
|
||||
serialPortPath String @default("COM3")
|
||||
serialBaudRate Int @default(9600)
|
||||
relayMockMode Boolean @default(true)
|
||||
relayConnected Boolean @default(false)
|
||||
selfServiceWashPriceCents Int @default(4500)
|
||||
selfServiceDryPriceCents Int @default(4500)
|
||||
selfServiceCycleMinutes Int @default(50)
|
||||
encargoPricePerKgCents Int @default(3300)
|
||||
encargoMinimumChargeCents Int @default(12000)
|
||||
xlEdredonIndividualCents Int @default(15000)
|
||||
xlEdredonMatrimonialCents Int @default(18000)
|
||||
xlEdredonKingCents Int @default(20000)
|
||||
xlCobijaGruesaCents Int @default(12000)
|
||||
xlAlmohadaParCents Int @default(8000)
|
||||
dryCleaningMinimumCents Int @default(15000)
|
||||
dryCleaningUrgentSurchargePct Int @default(50)
|
||||
detergentAddonCents Int @default(500)
|
||||
softenerAddonCents Int @default(500)
|
||||
bleachAddonCents Int @default(500)
|
||||
loyaltyEveryNTransactions Int @default(10)
|
||||
loyaltyDiscountPct Int @default(50)
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
76
prisma/seed.ts
Normal file
76
prisma/seed.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
type ComboLabel = "Encargo" | "XL";
|
||||
|
||||
function buildMachineName(type: "washer" | "dryer", number: number, label?: ComboLabel) {
|
||||
const base = type === "washer" ? `Lavadora ${number}` : `Secadora ${number}`;
|
||||
return label ? `${base} (${label})` : base;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await prisma.appConfig.upsert({
|
||||
where: { id: 1 },
|
||||
update: {},
|
||||
create: {
|
||||
id: 1,
|
||||
businessName: "La Burbuja",
|
||||
timezone: "America/Monterrey",
|
||||
currency: "MXN",
|
||||
serialPortPath: "COM3",
|
||||
serialBaudRate: 9600,
|
||||
relayMockMode: true
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.employee.upsert({
|
||||
where: { id: "admin-default" },
|
||||
update: {},
|
||||
create: {
|
||||
id: "admin-default",
|
||||
name: "Administrador",
|
||||
pin: "1234",
|
||||
isAdmin: true
|
||||
}
|
||||
});
|
||||
|
||||
const existingMachineCount = await prisma.machine.count();
|
||||
|
||||
if (existingMachineCount === 0) {
|
||||
const combos = [
|
||||
...Array.from({ length: 12 }, (_, index) => ({ number: index + 1 as number, label: undefined as ComboLabel | undefined })),
|
||||
{ number: 13, label: "Encargo" as const },
|
||||
{ number: 14, label: "Encargo" as const },
|
||||
{ number: 15, label: "Encargo" as const },
|
||||
{ number: 16, label: "XL" as const }
|
||||
];
|
||||
|
||||
const washers = combos.map((combo, index) => ({
|
||||
name: buildMachineName("washer", combo.number, combo.label),
|
||||
type: "washer" as const,
|
||||
relayChannel: index,
|
||||
defaultPriceCents: 8000,
|
||||
defaultDurationMinutes: 35
|
||||
}));
|
||||
const dryers = combos.map((combo, index) => ({
|
||||
name: buildMachineName("dryer", combo.number, combo.label),
|
||||
type: "dryer" as const,
|
||||
relayChannel: index + combos.length,
|
||||
defaultPriceCents: 6000,
|
||||
defaultDurationMinutes: 45
|
||||
}));
|
||||
const machines = [...washers, ...dryers];
|
||||
await prisma.machine.createMany({ data: machines });
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
await prisma.$disconnect();
|
||||
})
|
||||
.catch(async (error) => {
|
||||
console.error(error);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
11
scripts/start-local.ps1
Normal file
11
scripts/start-local.ps1
Normal file
@@ -0,0 +1,11 @@
|
||||
param(
|
||||
[switch]$Migrate
|
||||
)
|
||||
|
||||
if ($Migrate) {
|
||||
npm run prisma:migrate
|
||||
}
|
||||
|
||||
npm run prisma:generate
|
||||
npm run prisma:seed
|
||||
npm run dev
|
||||
43
src/app/api/auth/change-pin/route.ts
Normal file
43
src/app/api/auth/change-pin/route.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { loginWithPin } from "@/server/services/authService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const schema = z.object({
|
||||
employeeId: z.string().min(1),
|
||||
currentPin: z.string().regex(/^\d{4}$/),
|
||||
newPin: z.string().regex(/^\d{4}$/)
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
const payload = schema.parse(await request.json());
|
||||
const requester = await loginWithPin(payload.currentPin);
|
||||
|
||||
if (requester.id !== payload.employeeId && !requester.isAdmin) {
|
||||
return fail("No autorizado para cambiar este PIN", 403);
|
||||
}
|
||||
|
||||
if (payload.currentPin === payload.newPin && requester.id === payload.employeeId) {
|
||||
return fail("El nuevo PIN debe ser diferente al actual", 400);
|
||||
}
|
||||
|
||||
const employee = await prisma.employee.update({
|
||||
where: { id: payload.employeeId },
|
||||
data: { pin: payload.newPin }
|
||||
});
|
||||
|
||||
return ok({
|
||||
employee: {
|
||||
id: employee.id,
|
||||
name: employee.name,
|
||||
isAdmin: employee.isAdmin
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return fail("No fue posible cambiar PIN", 400, String(error));
|
||||
}
|
||||
}
|
||||
24
src/app/api/auth/pin/route.ts
Normal file
24
src/app/api/auth/pin/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { loginWithPin } from "@/server/services/authService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const schema = z.object({
|
||||
pin: z.string().length(4)
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
const payload = schema.parse(await request.json());
|
||||
const employee = await loginWithPin(payload.pin);
|
||||
return ok({
|
||||
id: employee.id,
|
||||
name: employee.name,
|
||||
isAdmin: employee.isAdmin
|
||||
});
|
||||
} catch (error) {
|
||||
return fail("No fue posible autenticar empleado", 401, String(error));
|
||||
}
|
||||
}
|
||||
52
src/app/api/customers/route.ts
Normal file
52
src/app/api/customers/route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { createCustomer, listCustomers } from "@/server/services/customerService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const createSchema = z.object({
|
||||
firstName: z.string().trim().min(2).max(80),
|
||||
lastName: z.string().trim().min(2).max(80),
|
||||
phone: z.string().trim().min(8).max(30),
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.email()
|
||||
.max(120)
|
||||
.optional()
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const query = url.searchParams.get("query") ?? undefined;
|
||||
const limitRaw = url.searchParams.get("limit");
|
||||
const limit = limitRaw ? Number(limitRaw) : undefined;
|
||||
const payload = await listCustomers({ query, limit });
|
||||
return ok(payload);
|
||||
} catch (error) {
|
||||
return fail("No fue posible cargar clientes", 400, String(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
const payload = createSchema.parse(await request.json());
|
||||
const customer = await createCustomer(payload);
|
||||
const lookup = await listCustomers({
|
||||
query: customer.phone,
|
||||
limit: 1
|
||||
});
|
||||
return ok(
|
||||
{
|
||||
customer: lookup.customers[0] ?? null,
|
||||
loyalty: lookup.loyalty
|
||||
},
|
||||
201
|
||||
);
|
||||
} catch (error) {
|
||||
return fail("No fue posible registrar cliente", 400, String(error));
|
||||
}
|
||||
}
|
||||
32
src/app/api/machines/[id]/route.ts
Normal file
32
src/app/api/machines/[id]/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { requireAdminFromRequest } from "@/server/services/authService";
|
||||
import { updateMachineConfig } from "@/server/services/machineService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const patchSchema = z.object({
|
||||
name: z.string().min(2).optional(),
|
||||
relayChannel: z.number().int().min(0).max(63).optional(),
|
||||
defaultPriceCents: z.number().int().positive().optional(),
|
||||
defaultDurationMinutes: z.number().int().positive().optional(),
|
||||
outOfService: z.boolean().optional(),
|
||||
isActive: z.boolean().optional()
|
||||
});
|
||||
|
||||
type Context = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function PATCH(request: Request, context: Context) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
await requireAdminFromRequest(request);
|
||||
const { id } = await context.params;
|
||||
const payload = patchSchema.parse(await request.json());
|
||||
const machine = await updateMachineConfig(id, payload);
|
||||
return ok({ machine });
|
||||
} catch (error) {
|
||||
return fail("No fue posible actualizar maquina", 403, String(error));
|
||||
}
|
||||
}
|
||||
27
src/app/api/machines/bulk/route.ts
Normal file
27
src/app/api/machines/bulk/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { requireAdminFromRequest } from "@/server/services/authService";
|
||||
import { updateAllMachineDefaults } from "@/server/services/machineService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const bulkPatchSchema = z
|
||||
.object({
|
||||
defaultPriceCents: z.number().int().positive().optional(),
|
||||
defaultDurationMinutes: z.number().int().positive().optional()
|
||||
})
|
||||
.refine((value) => value.defaultPriceCents !== undefined || value.defaultDurationMinutes !== undefined, {
|
||||
message: "Debe enviar al menos un campo para actualizar"
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
await requireAdminFromRequest(request);
|
||||
const payload = bulkPatchSchema.parse(await request.json());
|
||||
const result = await updateAllMachineDefaults(payload);
|
||||
return ok({ updated: result.count });
|
||||
} catch (error) {
|
||||
return fail("No fue posible actualizar maquinas", 403, String(error));
|
||||
}
|
||||
}
|
||||
35
src/app/api/machines/route.ts
Normal file
35
src/app/api/machines/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdminFromRequest } from "@/server/services/authService";
|
||||
import { getDashboardMachines } from "@/server/services/machineService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
type: z.enum(["washer", "dryer"]),
|
||||
relayChannel: z.number().int().min(0).max(63),
|
||||
defaultPriceCents: z.number().int().positive(),
|
||||
defaultDurationMinutes: z.number().int().positive()
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
await ensureSystemBootstrapped();
|
||||
const data = await getDashboardMachines();
|
||||
return ok({ machines: data });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
await requireAdminFromRequest(request);
|
||||
const payload = createSchema.parse(await request.json());
|
||||
const machine = await prisma.machine.create({
|
||||
data: payload
|
||||
});
|
||||
return ok({ machine }, 201);
|
||||
} catch (error) {
|
||||
return fail("No fue posible crear maquina", 403, String(error));
|
||||
}
|
||||
}
|
||||
24
src/app/api/reports/export/route.ts
Normal file
24
src/app/api/reports/export/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { parseDateRange } from "@/server/api/dateRange";
|
||||
import { fail } from "@/lib/http";
|
||||
import { requireAdminFromRequest } from "@/server/services/authService";
|
||||
import { getReportCsv } from "@/server/services/reportService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
await requireAdminFromRequest(request);
|
||||
const url = new URL(request.url);
|
||||
const range = parseDateRange(url.searchParams);
|
||||
const csv = await getReportCsv(range);
|
||||
return new Response(csv, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": "attachment; filename=\"reporte.csv\""
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return fail("No fue posible exportar reporte", 403, String(error));
|
||||
}
|
||||
}
|
||||
18
src/app/api/reports/summary/route.ts
Normal file
18
src/app/api/reports/summary/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { parseDateRange } from "@/server/api/dateRange";
|
||||
import { requireAdminFromRequest } from "@/server/services/authService";
|
||||
import { getReportSummary } from "@/server/services/reportService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
await requireAdminFromRequest(request);
|
||||
const url = new URL(request.url);
|
||||
const range = parseDateRange(url.searchParams);
|
||||
const summary = await getReportSummary(range);
|
||||
return ok(summary);
|
||||
} catch (error) {
|
||||
return fail("No fue posible generar reporte", 403, String(error));
|
||||
}
|
||||
}
|
||||
18
src/app/api/reports/utilization/route.ts
Normal file
18
src/app/api/reports/utilization/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { parseDateRange } from "@/server/api/dateRange";
|
||||
import { requireAdminFromRequest } from "@/server/services/authService";
|
||||
import { getUtilizationReport } from "@/server/services/reportService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
await requireAdminFromRequest(request);
|
||||
const url = new URL(request.url);
|
||||
const range = parseDateRange(url.searchParams);
|
||||
const utilization = await getUtilizationReport(range);
|
||||
return ok({ utilization, range });
|
||||
} catch (error) {
|
||||
return fail("No fue posible generar utilizacion", 403, String(error));
|
||||
}
|
||||
}
|
||||
38
src/app/api/settings/business/route.ts
Normal file
38
src/app/api/settings/business/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { requireAdminFromRequest } from "@/server/services/authService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const patchSchema = z.object({
|
||||
businessName: z.string().min(2).max(80),
|
||||
timezone: z.string().min(3).max(50),
|
||||
currency: z.string().min(3).max(3)
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
await requireAdminFromRequest(request);
|
||||
const config = await prisma.appConfig.findUnique({ where: { id: 1 } });
|
||||
return ok({ config });
|
||||
} catch (error) {
|
||||
return fail("No autorizado", 403, String(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
await requireAdminFromRequest(request);
|
||||
const payload = patchSchema.parse(await request.json());
|
||||
const config = await prisma.appConfig.update({
|
||||
where: { id: 1 },
|
||||
data: payload
|
||||
});
|
||||
return ok({ config });
|
||||
} catch (error) {
|
||||
return fail("No fue posible actualizar negocio", 403, String(error));
|
||||
}
|
||||
}
|
||||
33
src/app/api/settings/employees/[id]/route.ts
Normal file
33
src/app/api/settings/employees/[id]/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { requireAdminFromRequest } from "@/server/services/authService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const patchSchema = z.object({
|
||||
name: z.string().min(2).optional(),
|
||||
pin: z.string().length(4).optional(),
|
||||
isAdmin: z.boolean().optional(),
|
||||
isActive: z.boolean().optional()
|
||||
});
|
||||
|
||||
type Context = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function PATCH(request: Request, context: Context) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
await requireAdminFromRequest(request);
|
||||
const { id } = await context.params;
|
||||
const payload = patchSchema.parse(await request.json());
|
||||
const employee = await prisma.employee.update({
|
||||
where: { id },
|
||||
data: payload
|
||||
});
|
||||
return ok({ employee });
|
||||
} catch (error) {
|
||||
return fail("No fue posible actualizar empleado", 403, String(error));
|
||||
}
|
||||
}
|
||||
40
src/app/api/settings/employees/route.ts
Normal file
40
src/app/api/settings/employees/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { requireAdminFromRequest } from "@/server/services/authService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const createSchema = z.object({
|
||||
name: z.string().min(2),
|
||||
pin: z.string().length(4),
|
||||
isAdmin: z.boolean().default(false)
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
await requireAdminFromRequest(request);
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { name: "asc" }
|
||||
});
|
||||
return ok({ employees });
|
||||
} catch (error) {
|
||||
return fail("No autorizado", 403, String(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
await requireAdminFromRequest(request);
|
||||
const payload = createSchema.parse(await request.json());
|
||||
const employee = await prisma.employee.create({
|
||||
data: payload
|
||||
});
|
||||
return ok({ employee }, 201);
|
||||
} catch (error) {
|
||||
return fail("No fue posible crear empleado", 403, String(error));
|
||||
}
|
||||
}
|
||||
78
src/app/api/settings/pricing/route.ts
Normal file
78
src/app/api/settings/pricing/route.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { requireAdminFromRequest } from "@/server/services/authService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const pricingSchema = z.object({
|
||||
selfServiceWashPriceCents: z.number().int().positive(),
|
||||
selfServiceDryPriceCents: z.number().int().positive(),
|
||||
selfServiceCycleMinutes: z.number().int().positive(),
|
||||
encargoPricePerKgCents: z.number().int().positive(),
|
||||
encargoMinimumChargeCents: z.number().int().positive(),
|
||||
xlEdredonIndividualCents: z.number().int().positive(),
|
||||
xlEdredonMatrimonialCents: z.number().int().positive(),
|
||||
xlEdredonKingCents: z.number().int().positive(),
|
||||
xlCobijaGruesaCents: z.number().int().positive(),
|
||||
xlAlmohadaParCents: z.number().int().positive(),
|
||||
dryCleaningMinimumCents: z.number().int().positive(),
|
||||
dryCleaningUrgentSurchargePct: z.number().int().min(0).max(300),
|
||||
detergentAddonCents: z.number().int().min(0).max(10_000),
|
||||
softenerAddonCents: z.number().int().min(0).max(10_000),
|
||||
bleachAddonCents: z.number().int().min(0).max(10_000),
|
||||
loyaltyEveryNTransactions: z.number().int().min(1).max(200),
|
||||
loyaltyDiscountPct: z.number().int().min(0).max(100)
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
const config = await prisma.appConfig.findUnique({ where: { id: 1 } });
|
||||
if (!config) {
|
||||
return fail("Configuracion no disponible", 404);
|
||||
}
|
||||
|
||||
return ok({
|
||||
pricing: {
|
||||
selfServiceWashPriceCents: config.selfServiceWashPriceCents,
|
||||
selfServiceDryPriceCents: config.selfServiceDryPriceCents,
|
||||
selfServiceCycleMinutes: config.selfServiceCycleMinutes,
|
||||
encargoPricePerKgCents: config.encargoPricePerKgCents,
|
||||
encargoMinimumChargeCents: config.encargoMinimumChargeCents,
|
||||
xlEdredonIndividualCents: config.xlEdredonIndividualCents,
|
||||
xlEdredonMatrimonialCents: config.xlEdredonMatrimonialCents,
|
||||
xlEdredonKingCents: config.xlEdredonKingCents,
|
||||
xlCobijaGruesaCents: config.xlCobijaGruesaCents,
|
||||
xlAlmohadaParCents: config.xlAlmohadaParCents,
|
||||
dryCleaningMinimumCents: config.dryCleaningMinimumCents,
|
||||
dryCleaningUrgentSurchargePct: config.dryCleaningUrgentSurchargePct,
|
||||
detergentAddonCents: config.detergentAddonCents,
|
||||
softenerAddonCents: config.softenerAddonCents,
|
||||
bleachAddonCents: config.bleachAddonCents,
|
||||
loyaltyEveryNTransactions: config.loyaltyEveryNTransactions,
|
||||
loyaltyDiscountPct: config.loyaltyDiscountPct
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
return fail("No fue posible cargar precios", 400, String(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
await requireAdminFromRequest(request);
|
||||
const payload = pricingSchema.parse(await request.json());
|
||||
const config = await prisma.appConfig.update({
|
||||
where: { id: 1 },
|
||||
data: payload
|
||||
});
|
||||
return ok({
|
||||
pricing: payload,
|
||||
updatedAt: config.updatedAt
|
||||
});
|
||||
} catch (error) {
|
||||
return fail("No fue posible actualizar precios", 403, String(error));
|
||||
}
|
||||
}
|
||||
44
src/app/api/settings/serial/route.ts
Normal file
44
src/app/api/settings/serial/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { relayManager } from "@/server/relay/relayManager";
|
||||
import { requireAdminFromRequest } from "@/server/services/authService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const patchSchema = z.object({
|
||||
relayMockMode: z.boolean(),
|
||||
serialPortPath: z.string().min(1),
|
||||
serialBaudRate: z.number().int().positive()
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
await requireAdminFromRequest(request);
|
||||
const config = await prisma.appConfig.findUnique({ where: { id: 1 } });
|
||||
const health = await relayManager.getHealth();
|
||||
const ports = await relayManager.listSerialPorts();
|
||||
return ok({ config, health, ports });
|
||||
} catch (error) {
|
||||
return fail("No autorizado", 403, String(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
await requireAdminFromRequest(request);
|
||||
const payload = patchSchema.parse(await request.json());
|
||||
await prisma.appConfig.update({
|
||||
where: { id: 1 },
|
||||
data: payload
|
||||
});
|
||||
await relayManager.connectWithSettings(payload.relayMockMode, payload.serialPortPath, payload.serialBaudRate);
|
||||
const config = await prisma.appConfig.findUnique({ where: { id: 1 } });
|
||||
const health = await relayManager.getHealth();
|
||||
return ok({ config, health });
|
||||
} catch (error) {
|
||||
return fail("No fue posible actualizar serial", 403, String(error));
|
||||
}
|
||||
}
|
||||
17
src/app/api/shifts/active/route.ts
Normal file
17
src/app/api/shifts/active/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { getActiveShift, getShiftSummary } from "@/server/services/shiftService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
export async function GET() {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
const shift = await getActiveShift();
|
||||
if (!shift) {
|
||||
return ok({ shift: null, summary: null });
|
||||
}
|
||||
const summary = await getShiftSummary(shift.id);
|
||||
return ok({ shift, summary });
|
||||
} catch (error) {
|
||||
return fail("No fue posible cargar turno activo", 400, String(error));
|
||||
}
|
||||
}
|
||||
22
src/app/api/shifts/close/route.ts
Normal file
22
src/app/api/shifts/close/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { closeShift } from "@/server/services/shiftService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const schema = z.object({
|
||||
shiftId: z.string(),
|
||||
actualCashCents: z.number().int().min(0),
|
||||
notes: z.string().max(300).optional()
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
const payload = schema.parse(await request.json());
|
||||
const shift = await closeShift(payload);
|
||||
return ok({ shift });
|
||||
} catch (error) {
|
||||
return fail("No fue posible cerrar turno", 400, String(error));
|
||||
}
|
||||
}
|
||||
18
src/app/api/shifts/history/route.ts
Normal file
18
src/app/api/shifts/history/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { parseDateRange } from "@/server/api/dateRange";
|
||||
import { requireAdminFromRequest } from "@/server/services/authService";
|
||||
import { getShiftHistory } from "@/server/services/shiftService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
await requireAdminFromRequest(request);
|
||||
const url = new URL(request.url);
|
||||
const range = parseDateRange(url.searchParams);
|
||||
const shifts = await getShiftHistory(range);
|
||||
return ok({ shifts });
|
||||
} catch (error) {
|
||||
return fail("No fue posible obtener historial de turnos", 403, String(error));
|
||||
}
|
||||
}
|
||||
24
src/app/api/shifts/movements/route.ts
Normal file
24
src/app/api/shifts/movements/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { addCashMovement } from "@/server/services/shiftService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const schema = z.object({
|
||||
shiftId: z.string(),
|
||||
employeeId: z.string(),
|
||||
type: z.enum(["deposit", "withdrawal"]),
|
||||
amountCents: z.number().int().positive(),
|
||||
reason: z.string().min(3).max(120)
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
const payload = schema.parse(await request.json());
|
||||
const movement = await addCashMovement(payload);
|
||||
return ok({ movement }, 201);
|
||||
} catch (error) {
|
||||
return fail("No fue posible registrar movimiento", 400, String(error));
|
||||
}
|
||||
}
|
||||
21
src/app/api/shifts/open/route.ts
Normal file
21
src/app/api/shifts/open/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { openShift } from "@/server/services/shiftService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const schema = z.object({
|
||||
employeeId: z.string(),
|
||||
startingCashCents: z.number().int().min(0)
|
||||
});
|
||||
|
||||
export async function POST(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
const payload = schema.parse(await request.json());
|
||||
const shift = await openShift(payload);
|
||||
return ok({ shift }, 201);
|
||||
} catch (error) {
|
||||
return fail("No fue posible abrir turno", 400, String(error));
|
||||
}
|
||||
}
|
||||
38
src/app/api/system/relay/route.ts
Normal file
38
src/app/api/system/relay/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { relayManager } from "@/server/relay/relayManager";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const reconnectSchema = z.object({
|
||||
relayMockMode: z.boolean().optional(),
|
||||
serialPortPath: z.string().optional(),
|
||||
serialBaudRate: z.number().int().positive().optional()
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await ensureSystemBootstrapped();
|
||||
const health = await relayManager.getHealth();
|
||||
const ports = await relayManager.listSerialPorts();
|
||||
return ok({ health, ports });
|
||||
} catch (error) {
|
||||
return fail("No fue posible consultar relay", 500, String(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
const payload = reconnectSchema.parse(await request.json());
|
||||
if (payload.relayMockMode !== undefined && payload.serialPortPath && payload.serialBaudRate) {
|
||||
await relayManager.connectWithSettings(payload.relayMockMode, payload.serialPortPath, payload.serialBaudRate);
|
||||
} else {
|
||||
await relayManager.reconnect();
|
||||
}
|
||||
const health = await relayManager.getHealth();
|
||||
return ok({ health });
|
||||
} catch (error) {
|
||||
return fail("No fue posible reconectar relay", 400, String(error));
|
||||
}
|
||||
}
|
||||
31
src/app/api/transactions/[id]/extend/route.ts
Normal file
31
src/app/api/transactions/[id]/extend/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { addTimeToTransaction } from "@/server/services/activationService";
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const schema = z.object({
|
||||
employeeId: z.string(),
|
||||
extraMinutes: z.number().int().positive(),
|
||||
extraAmountCents: z.number().int().min(0),
|
||||
reason: z.string().max(120).optional()
|
||||
});
|
||||
|
||||
type Context = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: Request, context: Context) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const payload = schema.parse(await request.json());
|
||||
const transaction = await addTimeToTransaction({
|
||||
transactionId: id,
|
||||
...payload
|
||||
});
|
||||
return ok({ transaction });
|
||||
} catch (error) {
|
||||
return fail("No fue posible agregar tiempo", 400, String(error));
|
||||
}
|
||||
}
|
||||
18
src/app/api/transactions/[id]/retry-relay/route.ts
Normal file
18
src/app/api/transactions/[id]/retry-relay/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { retryRelayOn } from "@/server/services/activationService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
type Context = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(_request: Request, context: Context) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const transaction = await retryRelayOn(id);
|
||||
return ok({ transaction });
|
||||
} catch (error) {
|
||||
return fail("No fue posible reintentar relay", 400, String(error));
|
||||
}
|
||||
}
|
||||
28
src/app/api/transactions/[id]/void/route.ts
Normal file
28
src/app/api/transactions/[id]/void/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { voidTransaction } from "@/server/services/activationService";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const schema = z.object({
|
||||
reason: z.string().min(4).max(200)
|
||||
});
|
||||
|
||||
type Context = {
|
||||
params: Promise<{ id: string }>;
|
||||
};
|
||||
|
||||
export async function POST(request: Request, context: Context) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
const payload = schema.parse(await request.json());
|
||||
const transaction = await voidTransaction({
|
||||
transactionId: id,
|
||||
reason: payload.reason
|
||||
});
|
||||
return ok({ transaction });
|
||||
} catch (error) {
|
||||
return fail("No fue posible anular transaccion", 400, String(error));
|
||||
}
|
||||
}
|
||||
70
src/app/api/transactions/route.ts
Normal file
70
src/app/api/transactions/route.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { fail, ok } from "@/lib/http";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { SERVICE_TYPES } from "@/server/domain/constants";
|
||||
import { activateMachine } from "@/server/services/activationService";
|
||||
import { parseDateRange } from "@/server/api/dateRange";
|
||||
import { ensureSystemBootstrapped } from "@/server/system/bootstrap";
|
||||
|
||||
const activationSchema = z.object({
|
||||
machineId: z.string(),
|
||||
employeeId: z.string(),
|
||||
customerId: z.string(),
|
||||
baseAmountCents: z.number().int().positive(),
|
||||
durationMinutes: z.number().int().positive(),
|
||||
serviceType: z.enum([SERVICE_TYPES.autoservicio, SERVICE_TYPES.encargo, SERVICE_TYPES.xl]),
|
||||
paymentMethod: z.enum(["cash", "card", "transfer"]),
|
||||
addons: z.object({
|
||||
detergentQty: z.number().int().min(0).max(50),
|
||||
softenerQty: z.number().int().min(0).max(50),
|
||||
bleachQty: z.number().int().min(0).max(50)
|
||||
})
|
||||
});
|
||||
|
||||
export async function GET(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const range = parseDateRange(url.searchParams);
|
||||
const status = url.searchParams.get("status");
|
||||
const customerId = url.searchParams.get("customerId");
|
||||
const transactions = await prisma.transaction.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: range.from,
|
||||
lte: range.to
|
||||
},
|
||||
status: status ?? undefined,
|
||||
customerId: customerId ?? undefined
|
||||
},
|
||||
include: {
|
||||
machine: {
|
||||
select: { name: true }
|
||||
},
|
||||
employee: {
|
||||
select: { name: true }
|
||||
},
|
||||
customer: {
|
||||
select: { firstName: true, lastName: true, phone: true, email: true }
|
||||
},
|
||||
extensions: true
|
||||
},
|
||||
orderBy: { createdAt: "desc" }
|
||||
});
|
||||
return ok({ transactions });
|
||||
} catch (error) {
|
||||
return fail("No fue posible obtener transacciones", 400, String(error));
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
await ensureSystemBootstrapped();
|
||||
try {
|
||||
const payload = activationSchema.parse(await request.json());
|
||||
const result = await activateMachine(payload);
|
||||
return ok(result, 201);
|
||||
} catch (error) {
|
||||
return fail("No fue posible activar maquina", 400, String(error));
|
||||
}
|
||||
}
|
||||
21
src/app/globals.css
Normal file
21
src/app/globals.css
Normal file
@@ -0,0 +1,21 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
background: radial-gradient(circle at top, #eef8f4 0%, #f8f6ee 45%, #efe8d4 100%);
|
||||
color: #111827;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
16
src/app/layout.tsx
Normal file
16
src/app/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import "@/app/globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "La Burbuja POS",
|
||||
description: "Sistema local de lavanderia con control de relevadores"
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="es-MX">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
5
src/app/page.tsx
Normal file
5
src/app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { POSDashboard } from "@/components/POSDashboard";
|
||||
|
||||
export default function HomePage() {
|
||||
return <POSDashboard />;
|
||||
}
|
||||
401
src/components/POSDashboard.tsx
Normal file
401
src/components/POSDashboard.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/components/pos/api";
|
||||
import { LoginScreen } from "@/components/pos/LoginScreen";
|
||||
import { PanelTab } from "@/components/pos/PanelTab";
|
||||
import { ReportsTab } from "@/components/pos/ReportsTab";
|
||||
import { SettingsTab } from "@/components/pos/SettingsTab";
|
||||
import { ShiftTab } from "@/components/pos/ShiftTab";
|
||||
import type { ActiveShiftPayload, Employee, Machine, RelayHealth, ReportSummary, TicketPreviewData, UtilizationRow } from "@/components/pos/types";
|
||||
import { ActivateModal } from "@/components/pos/modals/ActivateModal";
|
||||
import { ChangePinModal } from "@/components/pos/modals/ChangePinModal";
|
||||
import { RunningModal } from "@/components/pos/modals/RunningModal";
|
||||
import { TicketPreviewModal } from "@/components/pos/modals/TicketPreviewModal";
|
||||
|
||||
type TabId = "panel" | "corte" | "reportes" | "config";
|
||||
|
||||
const tabLabels: Record<TabId, string> = {
|
||||
panel: "Panel",
|
||||
corte: "Corte",
|
||||
reportes: "Reportes",
|
||||
config: "Configuracion"
|
||||
};
|
||||
|
||||
type ActivationApiResponse = {
|
||||
transaction: {
|
||||
ticketNumber: number;
|
||||
addonDetergentQty: number;
|
||||
addonSoftenerQty: number;
|
||||
addonBleachQty: number;
|
||||
discountCents: number;
|
||||
loyaltyDiscountApplied: boolean;
|
||||
amountCents: number;
|
||||
serviceType: "autoservicio" | "encargo" | "xl";
|
||||
paymentMethod: "cash" | "card" | "transfer";
|
||||
createdAt: string;
|
||||
customer?: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
employee?: {
|
||||
name: string;
|
||||
};
|
||||
machine?: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
relayOk: boolean;
|
||||
relayError?: string;
|
||||
};
|
||||
|
||||
export function POSDashboard() {
|
||||
const [tab, setTab] = useState<TabId>("panel");
|
||||
const [pin, setPin] = useState("");
|
||||
const [sessionPin, setSessionPin] = useState<string | null>(null);
|
||||
const [employee, setEmployee] = useState<Employee | null>(null);
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [machines, setMachines] = useState<Machine[]>([]);
|
||||
const [relayHealth, setRelayHealth] = useState<RelayHealth | null>(null);
|
||||
const [activeShift, setActiveShift] = useState<ActiveShiftPayload>({ shift: null, summary: null });
|
||||
const [activateMachineId, setActivateMachineId] = useState<string | null>(null);
|
||||
const [runningMachineId, setRunningMachineId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [ticker, setTicker] = useState(Date.now());
|
||||
const [reportFrom, setReportFrom] = useState(() => {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.toISOString().slice(0, 16);
|
||||
});
|
||||
const [reportTo, setReportTo] = useState(() => new Date().toISOString().slice(0, 16));
|
||||
const [reportSummary, setReportSummary] = useState<ReportSummary | null>(null);
|
||||
const [utilization, setUtilization] = useState<UtilizationRow[]>([]);
|
||||
const [showChangePin, setShowChangePin] = useState(false);
|
||||
const [ticketPreview, setTicketPreview] = useState<TicketPreviewData | null>(null);
|
||||
const isAdmin = employee?.isAdmin ?? false;
|
||||
const adminHeaders = useMemo<Record<string, string>>(
|
||||
() => (sessionPin ? { "x-admin-pin": sessionPin } : ({} as Record<string, string>)),
|
||||
[sessionPin]
|
||||
);
|
||||
const availableTabs = useMemo(() => (isAdmin ? (["panel", "corte", "reportes", "config"] as TabId[]) : (["panel", "corte"] as TabId[])), [isAdmin]);
|
||||
|
||||
const selectedAvailable = useMemo(() => machines.find((machine) => machine.id === activateMachineId) ?? null, [activateMachineId, machines]);
|
||||
const selectedRunning = useMemo(() => machines.find((machine) => machine.id === runningMachineId) ?? null, [runningMachineId, machines]);
|
||||
|
||||
const loadDashboard = useCallback(async () => {
|
||||
const [machinesPayload, relayPayload, shiftPayload] = await Promise.all([
|
||||
apiFetch<{ machines: Machine[] }>("/api/machines"),
|
||||
apiFetch<{ health: RelayHealth }>("/api/system/relay"),
|
||||
apiFetch<ActiveShiftPayload>("/api/shifts/active")
|
||||
]);
|
||||
setMachines(machinesPayload.machines);
|
||||
setRelayHealth(relayPayload.health);
|
||||
setActiveShift(shiftPayload);
|
||||
}, []);
|
||||
|
||||
const loadEmployees = useCallback(async () => {
|
||||
if (!isAdmin) {
|
||||
setEmployees([]);
|
||||
return;
|
||||
}
|
||||
const payload = await apiFetch<{ employees: Employee[] }>("/api/settings/employees", {
|
||||
headers: adminHeaders
|
||||
});
|
||||
setEmployees(payload.employees);
|
||||
}, [adminHeaders, isAdmin]);
|
||||
|
||||
const loadReports = useCallback(async () => {
|
||||
if (!isAdmin) {
|
||||
setReportSummary(null);
|
||||
setUtilization([]);
|
||||
return;
|
||||
}
|
||||
const query = `from=${encodeURIComponent(new Date(reportFrom).toISOString())}&to=${encodeURIComponent(new Date(reportTo).toISOString())}`;
|
||||
const [summaryPayload, utilizationPayload] = await Promise.all([
|
||||
apiFetch<ReportSummary>(`/api/reports/summary?${query}`, {
|
||||
headers: adminHeaders
|
||||
}),
|
||||
apiFetch<{ utilization: UtilizationRow[] }>(`/api/reports/utilization?${query}`, {
|
||||
headers: adminHeaders
|
||||
})
|
||||
]);
|
||||
setReportSummary(summaryPayload);
|
||||
setUtilization(utilizationPayload.utilization);
|
||||
}, [adminHeaders, isAdmin, reportFrom, reportTo]);
|
||||
|
||||
const exportReports = useCallback(async () => {
|
||||
if (!isAdmin || !sessionPin) {
|
||||
throw new Error("Solo administrador puede exportar");
|
||||
}
|
||||
|
||||
const query = `from=${encodeURIComponent(new Date(reportFrom).toISOString())}&to=${encodeURIComponent(new Date(reportTo).toISOString())}`;
|
||||
const response = await fetch(`/api/reports/export?${query}`, {
|
||||
headers: {
|
||||
"x-admin-pin": sessionPin
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || "No fue posible exportar reporte");
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = objectUrl;
|
||||
link.download = "reporte.csv";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}, [isAdmin, reportFrom, reportTo, sessionPin]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all(isAdmin ? [loadDashboard(), loadEmployees()] : [loadDashboard()])
|
||||
.catch((err) => setError(err instanceof Error ? err.message : "No fue posible cargar datos"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [isAdmin, loadDashboard, loadEmployees]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
setTicker(Date.now());
|
||||
loadDashboard().catch(() => undefined);
|
||||
}, 5000);
|
||||
return () => clearInterval(id);
|
||||
}, [loadDashboard]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTicker(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const login = async () => {
|
||||
try {
|
||||
const payload = await apiFetch<Employee>("/api/auth/pin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ pin })
|
||||
});
|
||||
setEmployee(payload);
|
||||
setSessionPin(pin);
|
||||
setTab("panel");
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "PIN invalido");
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setEmployee(null);
|
||||
setSessionPin(null);
|
||||
setPin("");
|
||||
setTab("panel");
|
||||
setShowChangePin(false);
|
||||
setError(null);
|
||||
setMachines([]);
|
||||
setEmployees([]);
|
||||
setRelayHealth(null);
|
||||
setActiveShift({ shift: null, summary: null });
|
||||
setTicketPreview(null);
|
||||
};
|
||||
|
||||
const activate = async (
|
||||
machine: Machine,
|
||||
form: {
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
baseAmountCents: number;
|
||||
durationMinutes: number;
|
||||
serviceType: "autoservicio" | "encargo" | "xl";
|
||||
paymentMethod: "cash" | "card" | "transfer";
|
||||
addons: {
|
||||
detergentQty: number;
|
||||
softenerQty: number;
|
||||
bleachQty: number;
|
||||
};
|
||||
}
|
||||
) => {
|
||||
if (!employee) {
|
||||
return;
|
||||
}
|
||||
const result = await apiFetch<ActivationApiResponse>("/api/transactions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
machineId: machine.id,
|
||||
employeeId: employee.id,
|
||||
customerId: form.customerId,
|
||||
baseAmountCents: form.baseAmountCents,
|
||||
durationMinutes: form.durationMinutes,
|
||||
serviceType: form.serviceType,
|
||||
paymentMethod: form.paymentMethod,
|
||||
addons: form.addons
|
||||
})
|
||||
});
|
||||
|
||||
const totalCents = result.transaction.amountCents;
|
||||
const subtotalCents = Math.round(totalCents / 1.16);
|
||||
const ivaCents = totalCents - subtotalCents;
|
||||
const resolvedCustomerName = result.transaction.customer
|
||||
? `${result.transaction.customer.firstName} ${result.transaction.customer.lastName}`.trim()
|
||||
: form.customerName;
|
||||
|
||||
setTicketPreview({
|
||||
ticketNumber: result.transaction.ticketNumber,
|
||||
customerName: resolvedCustomerName,
|
||||
serviceType: result.transaction.serviceType ?? form.serviceType,
|
||||
addons: {
|
||||
detergentQty: result.transaction.addonDetergentQty,
|
||||
softenerQty: result.transaction.addonSoftenerQty,
|
||||
bleachQty: result.transaction.addonBleachQty
|
||||
},
|
||||
loyaltyApplied: result.transaction.loyaltyDiscountApplied,
|
||||
discountCents: result.transaction.discountCents,
|
||||
subtotalCents,
|
||||
ivaCents,
|
||||
totalCents,
|
||||
dateTimeIso: result.transaction.createdAt,
|
||||
cashierName: result.transaction.employee?.name ?? employee.name,
|
||||
machineName: result.transaction.machine?.name ?? machine.name,
|
||||
paymentMethod: result.transaction.paymentMethod,
|
||||
relayOk: result.relayOk
|
||||
});
|
||||
|
||||
if (!result.relayOk && result.relayError) {
|
||||
setError(`Ticket creado, pero no se encendio relay: ${result.relayError}`);
|
||||
} else {
|
||||
setError(null);
|
||||
}
|
||||
|
||||
setActivateMachineId(null);
|
||||
await loadDashboard();
|
||||
};
|
||||
|
||||
const addTime = async (transactionId: string, extraMinutes: number, extraAmountCents: number) => {
|
||||
if (!employee) {
|
||||
return;
|
||||
}
|
||||
await apiFetch(`/api/transactions/${transactionId}/extend`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
employeeId: employee.id,
|
||||
extraMinutes,
|
||||
extraAmountCents
|
||||
})
|
||||
});
|
||||
setRunningMachineId(null);
|
||||
await loadDashboard();
|
||||
};
|
||||
|
||||
if (!employee) {
|
||||
return <LoginScreen pin={pin} error={error} onPinChange={setPin} onLogin={login} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto min-h-screen max-w-[1400px] px-4 py-4 lg:px-8">
|
||||
<header className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-white/90 px-5 py-4 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-teal-900">La Burbuja POS</h1>
|
||||
<p className="text-sm text-slate-600">Cajero: {employee.name}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<nav className="flex gap-2">
|
||||
{availableTabs.map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => {
|
||||
setTab(key);
|
||||
if (key === "reportes" && isAdmin) {
|
||||
loadReports().catch(() => undefined);
|
||||
}
|
||||
}}
|
||||
className={`rounded-xl px-4 py-2 text-sm font-semibold ${tab === key ? "bg-teal-700 text-white" : "bg-slate-100 text-slate-700"}`}
|
||||
>
|
||||
{tabLabels[key]}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<button onClick={() => setShowChangePin(true)} className="rounded-xl bg-slate-700 px-3 py-2 text-sm font-semibold text-white">
|
||||
Cambiar PIN
|
||||
</button>
|
||||
<button onClick={logout} className="rounded-xl bg-slate-200 px-3 py-2 text-sm font-semibold text-slate-800">
|
||||
Cerrar sesion
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{relayHealth && (
|
||||
<section
|
||||
className={`mb-4 rounded-xl px-4 py-3 text-sm font-semibold ${relayHealth.connected ? "bg-emerald-100 text-emerald-800" : "bg-red-100 text-red-800"}`}
|
||||
>
|
||||
Relay {relayHealth.mode === "mock" ? "SIMULADOR" : "SERIAL"}: {relayHealth.connected ? "Conectado" : "Desconectado"}
|
||||
{relayHealth.error ? ` (${relayHealth.error})` : ""}
|
||||
</section>
|
||||
)}
|
||||
{error && <p className="mb-4 rounded-xl bg-red-100 px-4 py-3 text-sm text-red-700">{error}</p>}
|
||||
{loading && <p className="mb-4 text-sm text-slate-500">Cargando datos...</p>}
|
||||
|
||||
{tab === "panel" && (
|
||||
<PanelTab
|
||||
machines={machines}
|
||||
ticker={ticker}
|
||||
onSelectAvailable={setActivateMachineId}
|
||||
onSelectRunning={setRunningMachineId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "corte" && (
|
||||
<ShiftTab employee={employee} activeShift={activeShift} onRefresh={loadDashboard} onError={setError} />
|
||||
)}
|
||||
|
||||
{tab === "reportes" && isAdmin && (
|
||||
<ReportsTab
|
||||
reportFrom={reportFrom}
|
||||
reportTo={reportTo}
|
||||
setReportFrom={setReportFrom}
|
||||
setReportTo={setReportTo}
|
||||
summary={reportSummary}
|
||||
utilization={utilization}
|
||||
onLoad={loadReports}
|
||||
onExport={exportReports}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "config" && isAdmin && sessionPin && (
|
||||
<SettingsTab
|
||||
employee={employee}
|
||||
adminPin={sessionPin}
|
||||
machines={machines}
|
||||
employees={employees}
|
||||
onRefresh={async () => {
|
||||
await Promise.all([loadDashboard(), loadEmployees()]);
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedAvailable && <ActivateModal machine={selectedAvailable} onCancel={() => setActivateMachineId(null)} onConfirm={activate} />}
|
||||
|
||||
{selectedRunning && selectedRunning.transaction && (
|
||||
<RunningModal machine={selectedRunning} onCancel={() => setRunningMachineId(null)} onAddTime={addTime} />
|
||||
)}
|
||||
|
||||
{showChangePin && (
|
||||
<ChangePinModal
|
||||
employee={employee}
|
||||
onClose={() => setShowChangePin(false)}
|
||||
onSuccess={(newPin) => {
|
||||
setSessionPin(newPin);
|
||||
setShowChangePin(false);
|
||||
setError(null);
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{ticketPreview && <TicketPreviewModal ticket={ticketPreview} onClose={() => setTicketPreview(null)} />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
31
src/components/pos/LoginScreen.tsx
Normal file
31
src/components/pos/LoginScreen.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
type LoginScreenProps = {
|
||||
pin: string;
|
||||
error: string | null;
|
||||
onPinChange: (value: string) => void;
|
||||
onLogin: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function LoginScreen({ pin, error, onPinChange, onLogin }: LoginScreenProps) {
|
||||
return (
|
||||
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center gap-4 px-6">
|
||||
<h1 className="text-3xl font-bold text-teal-900">La Burbuja POS</h1>
|
||||
<p className="text-sm text-slate-600">Ingrese PIN para iniciar turno.</p>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
data-lpignore="true"
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
value={pin}
|
||||
onChange={(event) => onPinChange(event.target.value.replace(/\D/g, "").slice(0, 4))}
|
||||
className="rounded-xl border border-slate-300 bg-white px-4 py-4 text-2xl tracking-[0.4em]"
|
||||
/>
|
||||
<button onClick={onLogin} className="rounded-xl bg-teal-700 px-4 py-4 text-lg font-semibold text-white">
|
||||
Entrar
|
||||
</button>
|
||||
{error && <p className="rounded-lg bg-red-100 px-3 py-2 text-sm text-red-700">{error}</p>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
103
src/components/pos/PanelTab.tsx
Normal file
103
src/components/pos/PanelTab.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import type { Machine } from "@/components/pos/types";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
|
||||
type PanelTabProps = {
|
||||
machines: Machine[];
|
||||
ticker: number;
|
||||
onSelectAvailable: (machineId: string) => void;
|
||||
onSelectRunning: (machineId: string) => void;
|
||||
};
|
||||
|
||||
export function PanelTab({ machines, ticker, onSelectAvailable, onSelectRunning }: PanelTabProps) {
|
||||
const washers = machines.filter((machine) => machine.type === "washer");
|
||||
const dryers = machines.filter((machine) => machine.type === "dryer");
|
||||
|
||||
const getSpecialLabel = (machine: Machine) => {
|
||||
const upper = machine.name.toUpperCase();
|
||||
if (upper.includes("(ENCARGO)")) {
|
||||
return "ENCARGO";
|
||||
}
|
||||
if (upper.includes("(XL)")) {
|
||||
return "XL";
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderMachineButton = (machine: Machine) => {
|
||||
const remainingMinutes = machine.transaction
|
||||
? Math.max(0, Math.ceil((new Date(machine.transaction.expectedEndAt).getTime() - ticker) / 60_000))
|
||||
: 0;
|
||||
const specialLabel = getSpecialLabel(machine);
|
||||
|
||||
const statusClass =
|
||||
machine.status === "out_of_service"
|
||||
? "border-2 border-slate-500 bg-slate-700 text-white"
|
||||
: machine.status === "running"
|
||||
? machine.type === "washer"
|
||||
? "border-2 border-indigo-300 bg-indigo-800 text-white shadow-lg"
|
||||
: "border-2 border-rose-300 bg-rose-800 text-white shadow-lg"
|
||||
: machine.type === "washer"
|
||||
? "border-2 border-cyan-300 bg-cyan-100 text-cyan-950"
|
||||
: "border-2 border-amber-300 bg-amber-100 text-amber-950";
|
||||
|
||||
return (
|
||||
<button
|
||||
key={machine.id}
|
||||
onClick={() => {
|
||||
if (machine.status === "available") {
|
||||
onSelectAvailable(machine.id);
|
||||
}
|
||||
if (machine.status === "running") {
|
||||
onSelectRunning(machine.id);
|
||||
}
|
||||
}}
|
||||
className={`${statusClass} min-h-28 rounded-xl p-3 text-left transition hover:scale-[1.01]`}
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-base font-bold leading-tight">{machine.name}</p>
|
||||
<p className="text-xs uppercase tracking-wide">{machine.type === "washer" ? "Lavadora" : "Secadora"}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{specialLabel && (
|
||||
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide">{specialLabel}</span>
|
||||
)}
|
||||
<span className="rounded-full bg-black/20 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide">
|
||||
{machine.status === "running" ? "En marcha" : machine.status === "available" ? "Lista" : "Fuera"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{machine.status === "running" && machine.transaction && (
|
||||
<>
|
||||
<p className="text-2xl font-bold">{remainingMinutes} min</p>
|
||||
<p className="text-xs">Ticket #{machine.transaction.ticketNumber}</p>
|
||||
<p className="text-xs">{machine.transaction.customerName}</p>
|
||||
<p className="text-xs">{formatCurrency(machine.transaction.amountCents)}</p>
|
||||
</>
|
||||
)}
|
||||
{machine.status === "available" && (
|
||||
<>
|
||||
<p className="text-lg font-bold">Disponible</p>
|
||||
<p className="text-xs">Tap para activar</p>
|
||||
</>
|
||||
)}
|
||||
{machine.status === "out_of_service" && <p className="text-lg font-bold">Fuera de servicio</p>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="grid gap-4">
|
||||
<article>
|
||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-cyan-900">Lavadoras</h2>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">{washers.map(renderMachineButton)}</div>
|
||||
</article>
|
||||
<article>
|
||||
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-amber-900">Secadoras</h2>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">{dryers.map(renderMachineButton)}</div>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
100
src/components/pos/ReportsTab.tsx
Normal file
100
src/components/pos/ReportsTab.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import type { ReportSummary, UtilizationRow } from "@/components/pos/types";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
|
||||
type ReportsTabProps = {
|
||||
reportFrom: string;
|
||||
reportTo: string;
|
||||
setReportFrom: (value: string) => void;
|
||||
setReportTo: (value: string) => void;
|
||||
summary: ReportSummary | null;
|
||||
utilization: UtilizationRow[];
|
||||
onLoad: () => Promise<void>;
|
||||
onExport: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function ReportsTab({
|
||||
reportFrom,
|
||||
reportTo,
|
||||
setReportFrom,
|
||||
setReportTo,
|
||||
summary,
|
||||
utilization,
|
||||
onLoad,
|
||||
onExport
|
||||
}: ReportsTabProps) {
|
||||
return (
|
||||
<section className="grid gap-4">
|
||||
<article className="rounded-2xl bg-white p-5 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-slate-900">Rango</h2>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={reportFrom}
|
||||
onChange={(event) => setReportFrom(event.target.value)}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={reportTo}
|
||||
onChange={(event) => setReportTo(event.target.value)}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
<button onClick={onLoad} className="rounded-xl bg-teal-700 px-4 py-2 font-semibold text-white">
|
||||
Actualizar
|
||||
</button>
|
||||
<button onClick={onExport} className="rounded-xl bg-slate-700 px-4 py-2 font-semibold text-white">
|
||||
Exportar CSV
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{summary && (
|
||||
<article className="rounded-2xl bg-white p-5 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-slate-900">Resumen</h2>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-3">
|
||||
<p>Total: {formatCurrency(summary.totals.totalRevenueCents)}</p>
|
||||
<p>Transacciones: {summary.totals.transactionCount}</p>
|
||||
<p>Ticket promedio: {formatCurrency(summary.totals.avgTicketCents)}</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="font-semibold">Por metodo de pago</h3>
|
||||
<ul className="mt-2 grid gap-1 text-sm">
|
||||
{summary.byPaymentMethod.map((row) => (
|
||||
<li key={row.paymentMethod}>
|
||||
{row.paymentMethod}: {formatCurrency(row.amountCents)} ({row.count})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Por maquina</h3>
|
||||
<ul className="mt-2 grid gap-1 text-sm">
|
||||
{summary.byMachine.slice(0, 8).map((row) => (
|
||||
<li key={row.machineName}>
|
||||
{row.machineName}: {formatCurrency(row.amountCents)} ({row.count})
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
|
||||
{utilization.length > 0 && (
|
||||
<article className="rounded-2xl bg-white p-5 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-slate-900">Utilizacion de maquinas</h2>
|
||||
<ul className="mt-3 grid gap-2 text-sm">
|
||||
{utilization.map((row) => (
|
||||
<li key={row.machineId} className="rounded-lg bg-slate-100 px-3 py-2">
|
||||
{row.machineName}: {row.utilizationPct}% ({Math.round(row.usedMinutes)} min)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
672
src/components/pos/SettingsTab.tsx
Normal file
672
src/components/pos/SettingsTab.tsx
Normal file
@@ -0,0 +1,672 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/components/pos/api";
|
||||
import type { CustomerRecord, Employee, Machine, PricingVariables } from "@/components/pos/types";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
|
||||
type SettingsTabProps = {
|
||||
employee: Employee;
|
||||
adminPin: string;
|
||||
machines: Machine[];
|
||||
employees: Employee[];
|
||||
onRefresh: () => Promise<void>;
|
||||
onError: (value: string) => void;
|
||||
};
|
||||
|
||||
export function SettingsTab({ employee, adminPin, machines, employees, onRefresh, onError }: SettingsTabProps) {
|
||||
const [newEmployeeName, setNewEmployeeName] = useState("");
|
||||
const [newEmployeePin, setNewEmployeePin] = useState("");
|
||||
const [serialPath, setSerialPath] = useState("COM3");
|
||||
const [serialBaudRate, setSerialBaudRate] = useState(9600);
|
||||
const [mockMode, setMockMode] = useState(true);
|
||||
const [bulkPrice, setBulkPrice] = useState("");
|
||||
const [bulkDuration, setBulkDuration] = useState("");
|
||||
const [machineDrafts, setMachineDrafts] = useState<Record<string, { price: number; duration: number }>>({});
|
||||
const [pricing, setPricing] = useState<PricingVariables | null>(null);
|
||||
const [customerQuery, setCustomerQuery] = useState("");
|
||||
const [customers, setCustomers] = useState<CustomerRecord[]>([]);
|
||||
const [customersLoading, setCustomersLoading] = useState(false);
|
||||
const [showMachineList, setShowMachineList] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (machines.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (!bulkPrice) {
|
||||
setBulkPrice((machines[0].defaultPriceCents / 100).toString());
|
||||
}
|
||||
if (!bulkDuration) {
|
||||
setBulkDuration(machines[0].defaultDurationMinutes.toString());
|
||||
}
|
||||
}, [bulkDuration, bulkPrice, machines]);
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ pricing: PricingVariables }>("/api/settings/pricing", {
|
||||
headers: {
|
||||
"x-admin-pin": adminPin
|
||||
}
|
||||
})
|
||||
.then((payload) => setPricing(payload.pricing))
|
||||
.catch(() => undefined);
|
||||
}, [adminPin]);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => {
|
||||
setCustomersLoading(true);
|
||||
apiFetch<{ customers: CustomerRecord[] }>(`/api/customers?limit=200&query=${encodeURIComponent(customerQuery.trim())}`)
|
||||
.then((payload) => setCustomers(payload.customers))
|
||||
.catch(() => undefined)
|
||||
.finally(() => setCustomersLoading(false));
|
||||
}, 250);
|
||||
|
||||
return () => window.clearTimeout(id);
|
||||
}, [customerQuery]);
|
||||
|
||||
const getMachineDraft = (machine: Machine) =>
|
||||
machineDrafts[machine.id] ?? {
|
||||
price: machine.defaultPriceCents / 100,
|
||||
duration: machine.defaultDurationMinutes
|
||||
};
|
||||
|
||||
const washers = machines.filter((machine) => machine.type === "washer");
|
||||
const dryers = machines.filter((machine) => machine.type === "dryer");
|
||||
|
||||
const renderMachineItem = (machine: Machine) => (
|
||||
<li key={machine.id} className="rounded-lg bg-slate-100 px-3 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-semibold">{machine.name}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiFetch(`/api/machines/${machine.id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"x-admin-pin": adminPin
|
||||
},
|
||||
body: JSON.stringify({ outOfService: machine.status !== "out_of_service" })
|
||||
});
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : "No fue posible actualizar maquina");
|
||||
}
|
||||
}}
|
||||
className={`rounded-lg px-3 py-1 text-xs font-semibold ${machine.status === "out_of_service" ? "bg-red-700 text-white" : "bg-emerald-700 text-white"}`}
|
||||
>
|
||||
{machine.status === "out_of_service" ? "Fuera de servicio" : "Activa"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-[1fr_1fr_auto]">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={getMachineDraft(machine).price}
|
||||
onChange={(event) =>
|
||||
setMachineDrafts((current) => ({
|
||||
...current,
|
||||
[machine.id]: {
|
||||
...(current[machine.id] ?? {
|
||||
price: machine.defaultPriceCents / 100,
|
||||
duration: machine.defaultDurationMinutes
|
||||
}),
|
||||
price: Number(event.target.value || 0)
|
||||
}
|
||||
}))
|
||||
}
|
||||
className="rounded-lg border border-slate-300 px-3 py-2"
|
||||
placeholder="Precio"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={getMachineDraft(machine).duration}
|
||||
onChange={(event) =>
|
||||
setMachineDrafts((current) => ({
|
||||
...current,
|
||||
[machine.id]: {
|
||||
...(current[machine.id] ?? {
|
||||
price: machine.defaultPriceCents / 100,
|
||||
duration: machine.defaultDurationMinutes
|
||||
}),
|
||||
duration: Number(event.target.value || 0)
|
||||
}
|
||||
}))
|
||||
}
|
||||
className="rounded-lg border border-slate-300 px-3 py-2"
|
||||
placeholder="Minutos"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const draft = getMachineDraft(machine);
|
||||
try {
|
||||
await apiFetch(`/api/machines/${machine.id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"x-admin-pin": adminPin
|
||||
},
|
||||
body: JSON.stringify({
|
||||
defaultPriceCents: Math.round(draft.price * 100),
|
||||
defaultDurationMinutes: Math.round(draft.duration)
|
||||
})
|
||||
});
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : "No fue posible guardar configuracion");
|
||||
}
|
||||
}}
|
||||
className="rounded-lg bg-teal-700 px-3 py-2 text-xs font-semibold text-white"
|
||||
>
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<article className="rounded-2xl bg-white p-5 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-slate-900">Maquinas</h2>
|
||||
<div className="mt-3 rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<p className="text-sm font-semibold text-slate-800">Aplicar valor global a todas</p>
|
||||
<p className="mt-1 text-xs text-slate-600">Despues puedes sobrescribir una maquina individualmente.</p>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-[1fr_1fr_auto]">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={bulkPrice}
|
||||
onChange={(event) => setBulkPrice(event.target.value)}
|
||||
className="rounded-lg border border-slate-300 px-3 py-2"
|
||||
placeholder="Precio global"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={bulkDuration}
|
||||
onChange={(event) => setBulkDuration(event.target.value)}
|
||||
className="rounded-lg border border-slate-300 px-3 py-2"
|
||||
placeholder="Minutos globales"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const payload: { defaultPriceCents?: number; defaultDurationMinutes?: number } = {};
|
||||
|
||||
if (bulkPrice.trim().length > 0) {
|
||||
const parsedPrice = Number(bulkPrice);
|
||||
if (!Number.isFinite(parsedPrice) || parsedPrice <= 0) {
|
||||
onError("Precio global invalido");
|
||||
return;
|
||||
}
|
||||
payload.defaultPriceCents = Math.round(parsedPrice * 100);
|
||||
}
|
||||
if (bulkDuration.trim().length > 0) {
|
||||
const parsedDuration = Number(bulkDuration);
|
||||
if (!Number.isFinite(parsedDuration) || parsedDuration <= 0) {
|
||||
onError("Duracion global invalida");
|
||||
return;
|
||||
}
|
||||
payload.defaultDurationMinutes = Math.round(parsedDuration);
|
||||
}
|
||||
if (!payload.defaultPriceCents && !payload.defaultDurationMinutes) {
|
||||
onError("Ingresa precio o duracion global");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiFetch("/api/machines/bulk", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"x-admin-pin": adminPin
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
setMachineDrafts({});
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : "No fue posible aplicar configuracion global");
|
||||
}
|
||||
}}
|
||||
className="rounded-lg bg-indigo-700 px-3 py-2 text-xs font-semibold text-white"
|
||||
>
|
||||
Aplicar a todas
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between rounded-xl border border-slate-200 bg-slate-50 px-3 py-2">
|
||||
<p className="text-xs text-slate-600">Lista individual de maquinas</p>
|
||||
<button
|
||||
onClick={() => setShowMachineList((current) => !current)}
|
||||
className="rounded-lg bg-slate-800 px-3 py-1.5 text-xs font-semibold text-white"
|
||||
>
|
||||
{showMachineList ? "Ocultar lista" : `Mostrar lista (${machines.length})`}
|
||||
</button>
|
||||
</div>
|
||||
{showMachineList && (
|
||||
<div className="mt-3 grid gap-3 text-sm">
|
||||
<details className="rounded-xl border border-slate-200 bg-white p-3" open>
|
||||
<summary className="cursor-pointer font-semibold text-slate-800">Lavadoras ({washers.length})</summary>
|
||||
<ul className="mt-3 grid gap-2">{washers.map(renderMachineItem)}</ul>
|
||||
</details>
|
||||
<details className="rounded-xl border border-slate-200 bg-white p-3">
|
||||
<summary className="cursor-pointer font-semibold text-slate-800">Secadoras ({dryers.length})</summary>
|
||||
<ul className="mt-3 grid gap-2">{dryers.map(renderMachineItem)}</ul>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="rounded-2xl bg-white p-5 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-slate-900">Empleados</h2>
|
||||
<ul className="mt-3 grid gap-2 text-sm">
|
||||
{employees.map((item) => (
|
||||
<li key={item.id} className="rounded-lg bg-slate-100 px-3 py-2">
|
||||
{item.name} {item.isAdmin ? "(admin)" : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{employee.isAdmin && (
|
||||
<div className="mt-4 grid gap-2">
|
||||
<input
|
||||
value={newEmployeeName}
|
||||
onChange={(event) => setNewEmployeeName(event.target.value)}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
placeholder="Nombre"
|
||||
/>
|
||||
<input
|
||||
value={newEmployeePin}
|
||||
onChange={(event) => setNewEmployeePin(event.target.value)}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
placeholder="PIN 4 digitos"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiFetch("/api/settings/employees", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-admin-pin": adminPin
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: newEmployeeName,
|
||||
pin: newEmployeePin,
|
||||
isAdmin: false
|
||||
})
|
||||
});
|
||||
setNewEmployeeName("");
|
||||
setNewEmployeePin("");
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : "No fue posible crear empleado");
|
||||
}
|
||||
}}
|
||||
className="rounded-xl bg-teal-700 px-4 py-2 font-semibold text-white"
|
||||
>
|
||||
Agregar empleado
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<article className="rounded-2xl bg-white p-5 shadow-sm lg:col-span-2">
|
||||
<h2 className="text-xl font-bold text-slate-900">Serial / Relay</h2>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={mockMode} onChange={(event) => setMockMode(event.target.checked)} />
|
||||
Modo simulador
|
||||
</label>
|
||||
<input
|
||||
value={serialPath}
|
||||
onChange={(event) => setSerialPath(event.target.value)}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
placeholder="Puerto"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={serialBaudRate}
|
||||
onChange={(event) => setSerialBaudRate(Number(event.target.value || 9600))}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
placeholder="BaudRate"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiFetch("/api/settings/serial", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"x-admin-pin": adminPin
|
||||
},
|
||||
body: JSON.stringify({
|
||||
relayMockMode: mockMode,
|
||||
serialPortPath: serialPath,
|
||||
serialBaudRate
|
||||
})
|
||||
});
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : "No fue posible actualizar serial");
|
||||
}
|
||||
}}
|
||||
className="rounded-xl bg-slate-800 px-4 py-2 font-semibold text-white"
|
||||
>
|
||||
Reconectar relay
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{pricing && (
|
||||
<article className="rounded-2xl bg-white p-5 shadow-sm lg:col-span-2">
|
||||
<h2 className="text-xl font-bold text-slate-900">Variables de precio</h2>
|
||||
<p className="mt-1 text-xs text-slate-600">Configuracion por categoria de servicio</p>
|
||||
<div className="mt-3 grid gap-3 lg:grid-cols-2">
|
||||
<article className="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Servicio 1: Autoservicio</h3>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-3">
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Precio lavado (MXN)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={pricing.selfServiceWashPriceCents / 100}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, selfServiceWashPriceCents: Math.round(Number(event.target.value || 0) * 100) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Precio secado (MXN)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={pricing.selfServiceDryPriceCents / 100}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, selfServiceDryPriceCents: Math.round(Number(event.target.value || 0) * 100) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Minutos por ciclo</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={pricing.selfServiceCycleMinutes}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, selfServiceCycleMinutes: Math.round(Number(event.target.value || 0)) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Servicio 2: Encargo</h3>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Precio por kg (MXN)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={pricing.encargoPricePerKgCents / 100}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, encargoPricePerKgCents: Math.round(Number(event.target.value || 0) * 100) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Cobro minimo (MXN)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={pricing.encargoMinimumChargeCents / 100}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, encargoMinimumChargeCents: Math.round(Number(event.target.value || 0) * 100) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Servicio 3: XL</h3>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Edredon individual (MXN)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={pricing.xlEdredonIndividualCents / 100}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, xlEdredonIndividualCents: Math.round(Number(event.target.value || 0) * 100) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Edredon matrimonial (MXN)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={pricing.xlEdredonMatrimonialCents / 100}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, xlEdredonMatrimonialCents: Math.round(Number(event.target.value || 0) * 100) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Edredon king (MXN)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={pricing.xlEdredonKingCents / 100}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, xlEdredonKingCents: Math.round(Number(event.target.value || 0) * 100) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Cobija gruesa (MXN)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={pricing.xlCobijaGruesaCents / 100}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, xlCobijaGruesaCents: Math.round(Number(event.target.value || 0) * 100) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Par de almohadas (MXN)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={pricing.xlAlmohadaParCents / 100}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, xlAlmohadaParCents: Math.round(Number(event.target.value || 0) * 100) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Tintoreria</h3>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Minimo tintoreria (MXN)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={pricing.dryCleaningMinimumCents / 100}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, dryCleaningMinimumCents: Math.round(Number(event.target.value || 0) * 100) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Recargo urgente (%)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={300}
|
||||
value={pricing.dryCleaningUrgentSurchargePct}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, dryCleaningUrgentSurchargePct: Math.round(Number(event.target.value || 0)) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Add-ons</h3>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-3">
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Detergente (MXN)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={pricing.detergentAddonCents / 100}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, detergentAddonCents: Math.round(Number(event.target.value || 0) * 100) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Suavizante (MXN)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={pricing.softenerAddonCents / 100}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, softenerAddonCents: Math.round(Number(event.target.value || 0) * 100) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Cloro (MXN)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={pricing.bleachAddonCents / 100}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, bleachAddonCents: Math.round(Number(event.target.value || 0) * 100) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Cada N transacciones</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={pricing.loyaltyEveryNTransactions}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, loyaltyEveryNTransactions: Math.round(Number(event.target.value || 0)) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1 text-xs">
|
||||
<span className="font-medium text-slate-700">Descuento de lealtad (%)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={pricing.loyaltyDiscountPct}
|
||||
onChange={(event) =>
|
||||
setPricing((current) => (current ? { ...current, loyaltyDiscountPct: Math.round(Number(event.target.value || 0)) } : current))
|
||||
}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiFetch("/api/settings/pricing", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"x-admin-pin": adminPin
|
||||
},
|
||||
body: JSON.stringify(pricing)
|
||||
});
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : "No fue posible actualizar variables de precio");
|
||||
}
|
||||
}}
|
||||
className="rounded-xl bg-teal-700 px-4 py-2 font-semibold text-white"
|
||||
>
|
||||
Guardar variables
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
|
||||
<article className="rounded-2xl bg-white p-5 shadow-sm lg:col-span-2">
|
||||
<h2 className="text-xl font-bold text-slate-900">Base de clientes</h2>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={customerQuery}
|
||||
onChange={(event) => setCustomerQuery(event.target.value)}
|
||||
className="w-full max-w-md rounded-xl border border-slate-300 px-3 py-2"
|
||||
placeholder="Buscar por nombre, telefono o email"
|
||||
/>
|
||||
{customersLoading && <span className="text-xs text-slate-500">Buscando...</span>}
|
||||
</div>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="bg-slate-100 text-slate-700">
|
||||
<tr>
|
||||
<th className="px-3 py-2">Cliente</th>
|
||||
<th className="px-3 py-2">Telefono</th>
|
||||
<th className="px-3 py-2">Email</th>
|
||||
<th className="px-3 py-2">Tx validas</th>
|
||||
<th className="px-3 py-2">Siguiente promo</th>
|
||||
<th className="px-3 py-2">Total gastado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customers.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-3 py-3 text-slate-500" colSpan={6}>
|
||||
Sin clientes para mostrar
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{customers.map((customer) => (
|
||||
<tr key={customer.id} className="border-b border-slate-100">
|
||||
<td className="px-3 py-2 font-medium">
|
||||
{customer.firstName} {customer.lastName}
|
||||
</td>
|
||||
<td className="px-3 py-2">{customer.phone}</td>
|
||||
<td className="px-3 py-2">{customer.email || "-"}</td>
|
||||
<td className="px-3 py-2">{customer.eligibleTransactionCount}</td>
|
||||
<td className="px-3 py-2">Tx #{customer.nextDiscountTransactionNumber}</td>
|
||||
<td className="px-3 py-2">{formatCurrency(customer.totalSpentCents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
161
src/components/pos/ShiftTab.tsx
Normal file
161
src/components/pos/ShiftTab.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/components/pos/api";
|
||||
import type { ActiveShiftPayload, Employee } from "@/components/pos/types";
|
||||
import { formatCurrency, formatDateTime } from "@/lib/format";
|
||||
|
||||
type ShiftTabProps = {
|
||||
employee: Employee;
|
||||
activeShift: ActiveShiftPayload;
|
||||
onRefresh: () => Promise<void>;
|
||||
onError: (value: string) => void;
|
||||
};
|
||||
|
||||
export function ShiftTab({ employee, activeShift, onRefresh, onError }: ShiftTabProps) {
|
||||
const [startingCash, setStartingCash] = useState(0);
|
||||
const [movementAmount, setMovementAmount] = useState(0);
|
||||
const [movementReason, setMovementReason] = useState("");
|
||||
const [movementType, setMovementType] = useState<"deposit" | "withdrawal">("deposit");
|
||||
const [actualCash, setActualCash] = useState(0);
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
if (!activeShift.shift || !activeShift.summary) {
|
||||
return (
|
||||
<section className="rounded-2xl bg-white p-5 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-slate-900">Corte de Caja</h2>
|
||||
<p className="mt-2 text-sm text-slate-600">No hay turno abierto.</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={startingCash}
|
||||
onChange={(event) => setStartingCash(Number(event.target.value || 0))}
|
||||
className="rounded-xl border border-slate-300 px-4 py-3 text-xl"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiFetch("/api/shifts/open", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
employeeId: employee.id,
|
||||
startingCashCents: startingCash * 100
|
||||
})
|
||||
});
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : "No fue posible abrir turno");
|
||||
}
|
||||
}}
|
||||
className="rounded-xl bg-teal-700 px-4 py-3 text-white"
|
||||
>
|
||||
Abrir Turno
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<article className="rounded-2xl bg-white p-5 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-slate-900">Turno Activo</h2>
|
||||
<p className="text-sm text-slate-600">Inicio: {formatDateTime(activeShift.shift.startTime)}</p>
|
||||
<div className="mt-3 grid gap-2 text-sm">
|
||||
<p>Ventas: {formatCurrency(activeShift.summary.totals.totalSalesCents)}</p>
|
||||
<p>Transacciones: {activeShift.summary.totals.transactionCount}</p>
|
||||
<p>Efectivo esperado: {formatCurrency(activeShift.summary.totals.expectedCashCents)}</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="rounded-2xl bg-white p-5 shadow-sm">
|
||||
<h2 className="text-xl font-bold text-slate-900">Movimientos de Caja</h2>
|
||||
<div className="mt-3 grid gap-2">
|
||||
<select
|
||||
value={movementType}
|
||||
onChange={(event) => setMovementType(event.target.value as "deposit" | "withdrawal")}
|
||||
className="rounded-xl border border-slate-300 px-4 py-3"
|
||||
>
|
||||
<option value="deposit">Deposito</option>
|
||||
<option value="withdrawal">Retiro</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={movementAmount}
|
||||
onChange={(event) => setMovementAmount(Number(event.target.value || 0))}
|
||||
className="rounded-xl border border-slate-300 px-4 py-3"
|
||||
placeholder="Monto"
|
||||
/>
|
||||
<input
|
||||
value={movementReason}
|
||||
onChange={(event) => setMovementReason(event.target.value)}
|
||||
className="rounded-xl border border-slate-300 px-4 py-3"
|
||||
placeholder="Motivo"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await apiFetch("/api/shifts/movements", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
shiftId: activeShift.shift!.id,
|
||||
employeeId: employee.id,
|
||||
type: movementType,
|
||||
amountCents: movementAmount * 100,
|
||||
reason: movementReason
|
||||
})
|
||||
});
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : "No fue posible registrar movimiento");
|
||||
}
|
||||
}}
|
||||
className="rounded-xl bg-amber-600 px-4 py-3 font-semibold text-white"
|
||||
>
|
||||
Registrar movimiento
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="rounded-2xl bg-white p-5 shadow-sm lg:col-span-2">
|
||||
<h2 className="text-xl font-bold text-slate-900">Cerrar Turno</h2>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={actualCash}
|
||||
onChange={(event) => setActualCash(Number(event.target.value || 0))}
|
||||
className="rounded-xl border border-slate-300 px-4 py-3 text-xl"
|
||||
placeholder="Efectivo contado"
|
||||
/>
|
||||
<button
|
||||
disabled={closing}
|
||||
onClick={async () => {
|
||||
setClosing(true);
|
||||
try {
|
||||
await apiFetch("/api/shifts/close", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
shiftId: activeShift.shift!.id,
|
||||
actualCashCents: actualCash * 100
|
||||
})
|
||||
});
|
||||
await onRefresh();
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : "No fue posible cerrar turno");
|
||||
} finally {
|
||||
setClosing(false);
|
||||
}
|
||||
}}
|
||||
className="rounded-xl bg-red-700 px-4 py-3 font-semibold text-white"
|
||||
>
|
||||
Cerrar Turno
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
34
src/components/pos/api.ts
Normal file
34
src/components/pos/api.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export async function apiFetch<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
}
|
||||
});
|
||||
|
||||
let data: unknown = null;
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
} else {
|
||||
const text = await response.text();
|
||||
data = text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (data && typeof data === "object" && "error" in data) {
|
||||
throw new Error(String((data as { error: unknown }).error));
|
||||
}
|
||||
if (typeof data === "string" && data.length > 0) {
|
||||
throw new Error(data);
|
||||
}
|
||||
throw new Error(`Error de API (${response.status})`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
461
src/components/pos/modals/ActivateModal.tsx
Normal file
461
src/components/pos/modals/ActivateModal.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/components/pos/api";
|
||||
import type { CustomerRecord, LoyaltyRule, Machine, PricingVariables, ServiceType } from "@/components/pos/types";
|
||||
|
||||
type ActivateModalProps = {
|
||||
machine: Machine;
|
||||
onCancel: () => void;
|
||||
onConfirm: (
|
||||
machine: Machine,
|
||||
form: {
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
baseAmountCents: number;
|
||||
durationMinutes: number;
|
||||
serviceType: ServiceType;
|
||||
paymentMethod: "cash" | "card" | "transfer";
|
||||
addons: {
|
||||
detergentQty: number;
|
||||
softenerQty: number;
|
||||
bleachQty: number;
|
||||
};
|
||||
}
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
type CustomersPayload = {
|
||||
customers: CustomerRecord[];
|
||||
loyalty: LoyaltyRule;
|
||||
};
|
||||
|
||||
const serviceLabels: Record<ServiceType, string> = {
|
||||
autoservicio: "Autoservicio",
|
||||
encargo: "Encargo",
|
||||
xl: "XL"
|
||||
};
|
||||
|
||||
function money(cents: number) {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function ActivateModal({ machine, onCancel, onConfirm }: ActivateModalProps) {
|
||||
const [baseAmountCents, setBaseAmountCents] = useState(machine.defaultPriceCents);
|
||||
const [durationMinutes] = useState(machine.defaultDurationMinutes);
|
||||
const [serviceType, setServiceType] = useState<ServiceType>("autoservicio");
|
||||
const [paymentMethod, setPaymentMethod] = useState<"cash" | "card" | "transfer">("cash");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pricing, setPricing] = useState<PricingVariables | null>(null);
|
||||
|
||||
const [customerQuery, setCustomerQuery] = useState("");
|
||||
const [customers, setCustomers] = useState<CustomerRecord[]>([]);
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<CustomerRecord | null>(null);
|
||||
const [customerLoading, setCustomerLoading] = useState(false);
|
||||
const [customerError, setCustomerError] = useState<string | null>(null);
|
||||
const [creatingCustomer, setCreatingCustomer] = useState(false);
|
||||
const [newCustomerFirstName, setNewCustomerFirstName] = useState("");
|
||||
const [newCustomerLastName, setNewCustomerLastName] = useState("");
|
||||
const [newCustomerPhone, setNewCustomerPhone] = useState("");
|
||||
const [newCustomerEmail, setNewCustomerEmail] = useState("");
|
||||
const [loyaltyRule, setLoyaltyRule] = useState<LoyaltyRule>({
|
||||
everyNTransactions: 10,
|
||||
discountPct: 50
|
||||
});
|
||||
|
||||
const [encargoWeightKg, setEncargoWeightKg] = useState(0);
|
||||
const [xlItems, setXlItems] = useState({
|
||||
individual: 0,
|
||||
matrimonial: 0,
|
||||
king: 0,
|
||||
cobija: 0,
|
||||
almohadaPar: 0
|
||||
});
|
||||
const [addons, setAddons] = useState({
|
||||
detergentQty: 0,
|
||||
softenerQty: 0,
|
||||
bleachQty: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<{ pricing: PricingVariables }>("/api/settings/pricing")
|
||||
.then((payload) => {
|
||||
setPricing(payload.pricing);
|
||||
setLoyaltyRule({
|
||||
everyNTransactions: payload.pricing.loyaltyEveryNTransactions,
|
||||
discountPct: payload.pricing.loyaltyDiscountPct
|
||||
});
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = window.setTimeout(() => {
|
||||
setCustomerLoading(true);
|
||||
setCustomerError(null);
|
||||
apiFetch<CustomersPayload>(`/api/customers?limit=20&query=${encodeURIComponent(customerQuery.trim())}`)
|
||||
.then((payload) => {
|
||||
setCustomers(payload.customers);
|
||||
setLoyaltyRule(payload.loyalty);
|
||||
})
|
||||
.catch((error) => {
|
||||
setCustomerError(error instanceof Error ? error.message : "No fue posible buscar clientes");
|
||||
})
|
||||
.finally(() => setCustomerLoading(false));
|
||||
}, 220);
|
||||
|
||||
return () => window.clearTimeout(id);
|
||||
}, [customerQuery]);
|
||||
|
||||
const xlTotal = useMemo(() => {
|
||||
if (!pricing) {
|
||||
return 0;
|
||||
}
|
||||
return (
|
||||
xlItems.individual * pricing.xlEdredonIndividualCents +
|
||||
xlItems.matrimonial * pricing.xlEdredonMatrimonialCents +
|
||||
xlItems.king * pricing.xlEdredonKingCents +
|
||||
xlItems.cobija * pricing.xlCobijaGruesaCents +
|
||||
xlItems.almohadaPar * pricing.xlAlmohadaParCents
|
||||
);
|
||||
}, [pricing, xlItems]);
|
||||
|
||||
const addonTotalCents = useMemo(() => {
|
||||
if (!pricing) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (
|
||||
addons.detergentQty * pricing.detergentAddonCents +
|
||||
addons.softenerQty * pricing.softenerAddonCents +
|
||||
addons.bleachQty * pricing.bleachAddonCents
|
||||
);
|
||||
}, [addons, pricing]);
|
||||
|
||||
const nextTransactionNumber = selectedCustomer ? selectedCustomer.eligibleTransactionCount + 1 : null;
|
||||
const loyaltyEvery = Math.max(1, loyaltyRule.everyNTransactions);
|
||||
const loyaltyDiscountPct = Math.max(0, Math.min(100, loyaltyRule.discountPct));
|
||||
const loyaltyApplies = nextTransactionNumber !== null && nextTransactionNumber % loyaltyEvery === 0;
|
||||
const loyaltyDiscountPreviewCents = loyaltyApplies ? Math.round((baseAmountCents * loyaltyDiscountPct) / 100) : 0;
|
||||
const finalTotalPreviewCents = Math.max(0, baseAmountCents - loyaltyDiscountPreviewCents + addonTotalCents);
|
||||
|
||||
const registerCustomer = async () => {
|
||||
setCreatingCustomer(true);
|
||||
setCustomerError(null);
|
||||
try {
|
||||
const payload = await apiFetch<{ customer: CustomerRecord | null; loyalty: LoyaltyRule }>("/api/customers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
firstName: newCustomerFirstName,
|
||||
lastName: newCustomerLastName,
|
||||
phone: newCustomerPhone,
|
||||
email: newCustomerEmail.trim().length > 0 ? newCustomerEmail : undefined
|
||||
})
|
||||
});
|
||||
|
||||
if (!payload.customer) {
|
||||
throw new Error("No fue posible recuperar cliente nuevo");
|
||||
}
|
||||
|
||||
setLoyaltyRule(payload.loyalty);
|
||||
setSelectedCustomer(payload.customer);
|
||||
setCustomerQuery(`${payload.customer.firstName} ${payload.customer.lastName}`.trim());
|
||||
setNewCustomerFirstName("");
|
||||
setNewCustomerLastName("");
|
||||
setNewCustomerPhone("");
|
||||
setNewCustomerEmail("");
|
||||
} catch (error) {
|
||||
setCustomerError(error instanceof Error ? error.message : "No fue posible registrar cliente");
|
||||
} finally {
|
||||
setCreatingCustomer(false);
|
||||
}
|
||||
};
|
||||
|
||||
const incrementAddon = (key: "detergentQty" | "softenerQty" | "bleachQty") => {
|
||||
setAddons((current) => ({
|
||||
...current,
|
||||
[key]: current[key] + 1
|
||||
}));
|
||||
};
|
||||
|
||||
const decrementAddon = (key: "detergentQty" | "softenerQty" | "bleachQty") => {
|
||||
setAddons((current) => ({
|
||||
...current,
|
||||
[key]: Math.max(0, current[key] - 1)
|
||||
}));
|
||||
};
|
||||
|
||||
const resetAddons = () => {
|
||||
setAddons({
|
||||
detergentQty: 0,
|
||||
softenerQty: 0,
|
||||
bleachQty: 0
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-3">
|
||||
<div className="max-h-[96vh] w-full max-w-6xl overflow-y-auto rounded-2xl bg-white p-4 sm:p-5">
|
||||
<h3 className="text-2xl font-bold text-slate-900">{machine.name}</h3>
|
||||
<p className="text-sm text-slate-500">Activacion con ticket y cliente</p>
|
||||
|
||||
<div className="mt-3 grid gap-3 lg:grid-cols-[1.15fr_1fr]">
|
||||
<div className="grid gap-3">
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-600">Cliente obligatorio</p>
|
||||
<input
|
||||
value={customerQuery}
|
||||
onChange={(event) => setCustomerQuery(event.target.value)}
|
||||
className="mt-2 w-full rounded-lg border border-slate-300 px-3 py-2"
|
||||
placeholder="Buscar por nombre, telefono o email"
|
||||
/>
|
||||
{customerLoading && <p className="mt-2 text-xs text-slate-500">Buscando...</p>}
|
||||
<div className="mt-2 max-h-44 overflow-y-auto rounded-lg border border-slate-200 bg-white">
|
||||
{customers.length === 0 && <p className="px-3 py-2 text-xs text-slate-500">Sin resultados</p>}
|
||||
{customers.map((customer) => (
|
||||
<button
|
||||
key={customer.id}
|
||||
onClick={() => setSelectedCustomer(customer)}
|
||||
className={`block w-full border-b border-slate-100 px-3 py-2 text-left text-sm last:border-b-0 ${selectedCustomer?.id === customer.id ? "bg-emerald-100" : "hover:bg-slate-50"}`}
|
||||
>
|
||||
<p className="font-semibold">{customer.firstName} {customer.lastName}</p>
|
||||
<p className="text-xs text-slate-500">{customer.phone} - Tx validas: {customer.eligibleTransactionCount}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{selectedCustomer && (
|
||||
<div className="mt-2 rounded-lg bg-emerald-50 px-3 py-2 text-xs text-emerald-800">
|
||||
Cliente seleccionado: {selectedCustomer.firstName} {selectedCustomer.lastName} ({selectedCustomer.phone})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<details className="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<summary className="cursor-pointer text-xs font-semibold uppercase tracking-wide text-slate-600">Registrar cliente nuevo</summary>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<input
|
||||
value={newCustomerFirstName}
|
||||
onChange={(event) => setNewCustomerFirstName(event.target.value)}
|
||||
className="rounded-lg border border-slate-300 px-3 py-2"
|
||||
placeholder="Nombre"
|
||||
/>
|
||||
<input
|
||||
value={newCustomerLastName}
|
||||
onChange={(event) => setNewCustomerLastName(event.target.value)}
|
||||
className="rounded-lg border border-slate-300 px-3 py-2"
|
||||
placeholder="Apellido"
|
||||
/>
|
||||
<input
|
||||
value={newCustomerPhone}
|
||||
onChange={(event) => setNewCustomerPhone(event.target.value)}
|
||||
className="rounded-lg border border-slate-300 px-3 py-2"
|
||||
placeholder="Telefono"
|
||||
/>
|
||||
<input
|
||||
value={newCustomerEmail}
|
||||
onChange={(event) => setNewCustomerEmail(event.target.value)}
|
||||
className="rounded-lg border border-slate-300 px-3 py-2"
|
||||
placeholder="Email (opcional)"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={registerCustomer}
|
||||
disabled={creatingCustomer}
|
||||
className="mt-2 rounded-lg bg-teal-700 px-3 py-2 text-xs font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{creatingCustomer ? "Registrando..." : "Registrar y seleccionar"}
|
||||
</button>
|
||||
</details>
|
||||
|
||||
{customerError && <p className="rounded-lg bg-red-100 px-3 py-2 text-xs text-red-700">{customerError}</p>}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-600">Tipo de servicio</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(["autoservicio", "encargo", "xl"] as ServiceType[]).map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setServiceType(option)}
|
||||
className={`rounded-lg px-3 py-2 text-xs font-semibold ${
|
||||
serviceType === option ? "bg-teal-700 text-white" : "bg-white text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{serviceLabels[option]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pricing && serviceType === "encargo" && (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-600">Calculadora encargo</p>
|
||||
<div className="grid gap-2 sm:grid-cols-[1fr_auto]">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={encargoWeightKg}
|
||||
onChange={(event) => setEncargoWeightKg(Number(event.target.value || 0))}
|
||||
className="rounded-lg border border-slate-300 px-3 py-2"
|
||||
placeholder="Peso (kg)"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const calculated = Math.max(
|
||||
Math.round(encargoWeightKg * pricing.encargoPricePerKgCents),
|
||||
pricing.encargoMinimumChargeCents
|
||||
);
|
||||
setBaseAmountCents(calculated);
|
||||
}}
|
||||
className="rounded-lg bg-emerald-700 px-3 py-2 text-xs font-semibold text-white"
|
||||
>
|
||||
Aplicar precio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pricing && serviceType === "xl" && (
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-600">Items XL</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<button onClick={() => setXlItems((c) => ({ ...c, individual: c.individual + 1 }))} className="rounded-lg bg-slate-700 px-3 py-2 text-xs font-semibold text-white">Edredon individual +1</button>
|
||||
<button onClick={() => setXlItems((c) => ({ ...c, matrimonial: c.matrimonial + 1 }))} className="rounded-lg bg-slate-700 px-3 py-2 text-xs font-semibold text-white">Edredon matrimonial +1</button>
|
||||
<button onClick={() => setXlItems((c) => ({ ...c, king: c.king + 1 }))} className="rounded-lg bg-slate-700 px-3 py-2 text-xs font-semibold text-white">Edredon king +1</button>
|
||||
<button onClick={() => setXlItems((c) => ({ ...c, cobija: c.cobija + 1 }))} className="rounded-lg bg-slate-700 px-3 py-2 text-xs font-semibold text-white">Cobija gruesa +1</button>
|
||||
<button onClick={() => setXlItems((c) => ({ ...c, almohadaPar: c.almohadaPar + 1 }))} className="rounded-lg bg-slate-700 px-3 py-2 text-xs font-semibold text-white">Almohada par +1</button>
|
||||
<button onClick={() => setXlItems({ individual: 0, matrimonial: 0, king: 0, cobija: 0, almohadaPar: 0 })} className="rounded-lg bg-slate-300 px-3 py-2 text-xs font-semibold text-slate-700">Limpiar</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (xlTotal > 0) {
|
||||
setBaseAmountCents(xlTotal);
|
||||
}
|
||||
}}
|
||||
className="mt-2 w-full rounded-lg bg-teal-700 px-3 py-2 text-xs font-semibold text-white"
|
||||
>
|
||||
Usar total XL ({money(xlTotal)})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-600">Add-ons</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetAddons}
|
||||
className="rounded-md bg-slate-200 px-2 py-1 text-[11px] font-semibold text-slate-700"
|
||||
>
|
||||
Limpiar add-ons
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-2">
|
||||
<button onClick={() => incrementAddon("detergentQty")} type="button" className="w-full rounded-md bg-sky-700 px-2 py-2 text-sm font-semibold text-white">Detergente +{money(pricing?.detergentAddonCents ?? 0)}</button>
|
||||
<div className="mt-2 flex items-center justify-between text-sm">
|
||||
<button type="button" onClick={() => decrementAddon("detergentQty")} className="rounded bg-slate-200 px-2 py-1 text-xs">-</button>
|
||||
<span>x{addons.detergentQty}</span>
|
||||
<span className="text-xs text-slate-500">{money(addons.detergentQty * (pricing?.detergentAddonCents ?? 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-2">
|
||||
<button onClick={() => incrementAddon("softenerQty")} type="button" className="w-full rounded-md bg-violet-700 px-2 py-2 text-sm font-semibold text-white">Suavizante +{money(pricing?.softenerAddonCents ?? 0)}</button>
|
||||
<div className="mt-2 flex items-center justify-between text-sm">
|
||||
<button type="button" onClick={() => decrementAddon("softenerQty")} className="rounded bg-slate-200 px-2 py-1 text-xs">-</button>
|
||||
<span>x{addons.softenerQty}</span>
|
||||
<span className="text-xs text-slate-500">{money(addons.softenerQty * (pricing?.softenerAddonCents ?? 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-2">
|
||||
<button onClick={() => incrementAddon("bleachQty")} type="button" className="w-full rounded-md bg-slate-700 px-2 py-2 text-sm font-semibold text-white">Cloro +{money(pricing?.bleachAddonCents ?? 0)}</button>
|
||||
<div className="mt-2 flex items-center justify-between text-sm">
|
||||
<button type="button" onClick={() => decrementAddon("bleachQty")} className="rounded bg-slate-200 px-2 py-1 text-xs">-</button>
|
||||
<span>x{addons.bleachQty}</span>
|
||||
<span className="text-xs text-slate-500">{money(addons.bleachQty * (pricing?.bleachAddonCents ?? 0))}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-600">Configuracion del ticket</p>
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-slate-300 bg-white px-3 py-2">
|
||||
<p className="text-[11px] uppercase tracking-wide text-slate-500">Servicio</p>
|
||||
<p className="text-lg font-semibold text-slate-900">{serviceLabels[serviceType]}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-300 bg-white px-3 py-2">
|
||||
<p className="text-[11px] uppercase tracking-wide text-slate-500">Precio base configurado</p>
|
||||
<p className="text-lg font-semibold text-slate-900">{money(baseAmountCents)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-slate-300 bg-white px-3 py-2">
|
||||
<p className="text-[11px] uppercase tracking-wide text-slate-500">Duracion configurada</p>
|
||||
<p className="text-lg font-semibold text-slate-900">{durationMinutes} min</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="mt-2 grid gap-1 text-sm">
|
||||
<span className="font-medium text-slate-600">Pago</span>
|
||||
<select
|
||||
value={paymentMethod}
|
||||
onChange={(event) => setPaymentMethod(event.target.value as "cash" | "card" | "transfer")}
|
||||
className="rounded-lg border border-slate-300 px-3 py-2"
|
||||
>
|
||||
<option value="cash">Efectivo</option>
|
||||
<option value="card">Tarjeta</option>
|
||||
<option value="transfer">Transferencia</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 mt-3 rounded-xl border border-slate-200 bg-white/95 p-3 shadow-sm backdrop-blur">
|
||||
<div className="mb-2 grid gap-1 text-sm sm:grid-cols-5">
|
||||
<p>Servicio: <strong>{serviceLabels[serviceType]}</strong></p>
|
||||
<p>Base: <strong>{money(baseAmountCents)}</strong></p>
|
||||
<p>Lealtad: <strong>-{money(loyaltyDiscountPreviewCents)}</strong></p>
|
||||
<p>Add-ons: <strong>+{money(addonTotalCents)}</strong></p>
|
||||
<p className="font-semibold text-slate-900">Total: {money(finalTotalPreviewCents)}</p>
|
||||
</div>
|
||||
{selectedCustomer && loyaltyApplies && (
|
||||
<p className="mb-2 text-xs text-emerald-700">Descuento de lealtad aplicado (Tx #{nextTransactionNumber}, {loyaltyDiscountPct}% off).</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button onClick={onCancel} className="rounded-xl bg-slate-200 px-4 py-2.5 font-semibold text-slate-700">Cancelar</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!selectedCustomer) {
|
||||
setCustomerError("Selecciona o registra un cliente antes de activar");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onConfirm(machine, {
|
||||
customerId: selectedCustomer.id,
|
||||
customerName: `${selectedCustomer.firstName} ${selectedCustomer.lastName}`.trim(),
|
||||
baseAmountCents,
|
||||
durationMinutes,
|
||||
serviceType,
|
||||
paymentMethod,
|
||||
addons
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
className="rounded-xl bg-teal-700 px-4 py-2.5 font-semibold text-white disabled:opacity-60"
|
||||
disabled={submitting || !selectedCustomer}
|
||||
>
|
||||
ACTIVAR
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/components/pos/modals/ChangePinModal.tsx
Normal file
103
src/components/pos/modals/ChangePinModal.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/components/pos/api";
|
||||
import type { Employee } from "@/components/pos/types";
|
||||
|
||||
type ChangePinModalProps = {
|
||||
employee: Employee;
|
||||
onClose: () => void;
|
||||
onSuccess: (newPin: string) => void;
|
||||
onError: (message: string) => void;
|
||||
};
|
||||
|
||||
function sanitizePin(value: string) {
|
||||
return value.replace(/\D/g, "").slice(0, 4);
|
||||
}
|
||||
|
||||
export function ChangePinModal({ employee, onClose, onSuccess, onError }: ChangePinModalProps) {
|
||||
const [currentPin, setCurrentPin] = useState("");
|
||||
const [newPin, setNewPin] = useState("");
|
||||
const [confirmPin, setConfirmPin] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-5">
|
||||
<h3 className="text-xl font-bold text-slate-900">Cambiar PIN</h3>
|
||||
<p className="text-sm text-slate-600">{employee.name}</p>
|
||||
<div className="mt-4 grid gap-3">
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
value={currentPin}
|
||||
onChange={(event) => setCurrentPin(sanitizePin(event.target.value))}
|
||||
className="rounded-xl border border-slate-300 px-4 py-3"
|
||||
placeholder="PIN actual"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
value={newPin}
|
||||
onChange={(event) => setNewPin(sanitizePin(event.target.value))}
|
||||
className="rounded-xl border border-slate-300 px-4 py-3"
|
||||
placeholder="PIN nuevo"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
value={confirmPin}
|
||||
onChange={(event) => setConfirmPin(sanitizePin(event.target.value))}
|
||||
className="rounded-xl border border-slate-300 px-4 py-3"
|
||||
placeholder="Confirmar PIN nuevo"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button onClick={onClose} className="rounded-xl bg-slate-200 px-4 py-3 font-semibold text-slate-700">
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
disabled={saving}
|
||||
onClick={async () => {
|
||||
if (currentPin.length !== 4 || newPin.length !== 4 || confirmPin.length !== 4) {
|
||||
onError("PIN debe tener 4 digitos");
|
||||
return;
|
||||
}
|
||||
if (newPin !== confirmPin) {
|
||||
onError("PIN nuevo y confirmacion no coinciden");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiFetch("/api/auth/change-pin", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
employeeId: employee.id,
|
||||
currentPin,
|
||||
newPin
|
||||
})
|
||||
});
|
||||
onSuccess(newPin);
|
||||
} catch (error) {
|
||||
onError(error instanceof Error ? error.message : "No fue posible cambiar PIN");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
className="rounded-xl bg-teal-700 px-4 py-3 font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
Guardar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
src/components/pos/modals/RunningModal.tsx
Normal file
78
src/components/pos/modals/RunningModal.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { formatCurrency, formatDateTime } from "@/lib/format";
|
||||
import type { Machine } from "@/components/pos/types";
|
||||
|
||||
type RunningModalProps = {
|
||||
machine: Machine;
|
||||
onCancel: () => void;
|
||||
onAddTime: (transactionId: string, extraMinutes: number, extraAmountCents: number) => Promise<void>;
|
||||
};
|
||||
|
||||
export function RunningModal({ machine, onCancel, onAddTime }: RunningModalProps) {
|
||||
const [extraMinutes, setExtraMinutes] = useState(10);
|
||||
const [extraAmount, setExtraAmount] = useState(20);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
if (!machine.transaction) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4">
|
||||
<div className="w-full max-w-lg rounded-2xl bg-white p-5">
|
||||
<h3 className="text-xl font-bold">{machine.name}</h3>
|
||||
<p className="text-sm text-slate-500">Ticket #{machine.transaction.ticketNumber}</p>
|
||||
<p className="text-sm text-slate-500">Cliente: {machine.transaction.customerName}</p>
|
||||
<p className="text-sm text-slate-500">Inicio: {formatDateTime(machine.transaction.startedAt)}</p>
|
||||
<p className="text-sm text-slate-500">Fin esperado: {formatDateTime(machine.transaction.expectedEndAt)}</p>
|
||||
<p className="mt-2 text-base font-semibold text-slate-900">Importe: {formatCurrency(machine.transaction.amountCents)}</p>
|
||||
{machine.transaction.loyaltyDiscountApplied && (
|
||||
<p className="text-xs text-emerald-700">Incluye descuento de lealtad</p>
|
||||
)}
|
||||
<div className="mt-4 grid gap-3">
|
||||
<label className="grid gap-1">
|
||||
<span className="text-sm text-slate-600">Agregar minutos</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={extraMinutes}
|
||||
onChange={(event) => setExtraMinutes(Number(event.target.value || 1))}
|
||||
className="rounded-xl border border-slate-300 px-4 py-3 text-xl"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1">
|
||||
<span className="text-sm text-slate-600">Cargo adicional (MXN)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={extraAmount}
|
||||
onChange={(event) => setExtraAmount(Number(event.target.value || 0))}
|
||||
className="rounded-xl border border-slate-300 px-4 py-3 text-xl"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
<button onClick={onCancel} className="rounded-xl bg-slate-200 px-4 py-3 font-semibold text-slate-700">
|
||||
Cerrar
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onAddTime(machine.transaction!.id, extraMinutes, extraAmount * 100);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
className="rounded-xl bg-blue-700 px-4 py-3 font-semibold text-white"
|
||||
disabled={submitting}
|
||||
>
|
||||
Agregar tiempo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
228
src/components/pos/modals/TicketPreviewModal.tsx
Normal file
228
src/components/pos/modals/TicketPreviewModal.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import type { TicketPreviewData } from "@/components/pos/types";
|
||||
import { APP_DEFAULTS } from "@/lib/config";
|
||||
import { formatCurrency } from "@/lib/format";
|
||||
|
||||
type TicketPreviewModalProps = {
|
||||
ticket: TicketPreviewData;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const serviceLabels: Record<TicketPreviewData["serviceType"], string> = {
|
||||
autoservicio: "Autoservicio",
|
||||
encargo: "Encargo",
|
||||
xl: "XL"
|
||||
};
|
||||
|
||||
const paymentLabels: Record<TicketPreviewData["paymentMethod"], string> = {
|
||||
cash: "Efectivo",
|
||||
card: "Tarjeta",
|
||||
transfer: "Transferencia"
|
||||
};
|
||||
|
||||
function escapeHtml(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export function TicketPreviewModal({ ticket, onClose }: TicketPreviewModalProps) {
|
||||
const [shareStatus, setShareStatus] = useState<string | null>(null);
|
||||
const ticketDate = useMemo(() => new Date(ticket.dateTimeIso), [ticket.dateTimeIso]);
|
||||
|
||||
const dateLabel = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(APP_DEFAULTS.locale, {
|
||||
dateStyle: "medium",
|
||||
timeZone: APP_DEFAULTS.timezone
|
||||
}).format(ticketDate),
|
||||
[ticketDate]
|
||||
);
|
||||
|
||||
const timeLabel = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(APP_DEFAULTS.locale, {
|
||||
timeStyle: "short",
|
||||
timeZone: APP_DEFAULTS.timezone
|
||||
}).format(ticketDate),
|
||||
[ticketDate]
|
||||
);
|
||||
|
||||
const addonLines = useMemo(
|
||||
() => [
|
||||
{ label: "Detergente", qty: ticket.addons.detergentQty },
|
||||
{ label: "Suavizante", qty: ticket.addons.softenerQty },
|
||||
{ label: "Cloro", qty: ticket.addons.bleachQty }
|
||||
].filter((line) => line.qty > 0),
|
||||
[ticket.addons]
|
||||
);
|
||||
|
||||
const ticketText = useMemo(() => {
|
||||
const lines: string[] = [];
|
||||
lines.push("LA BURBUJA POS");
|
||||
lines.push("------------------------------");
|
||||
lines.push(`TICKET #${ticket.ticketNumber}`);
|
||||
lines.push("------------------------------");
|
||||
lines.push(`Cliente: ${ticket.customerName}`);
|
||||
lines.push(`Servicio: ${serviceLabels[ticket.serviceType]}`);
|
||||
lines.push(`Pago: ${paymentLabels[ticket.paymentMethod]}`);
|
||||
lines.push(`Cajero: ${ticket.cashierName}`);
|
||||
lines.push(`Fecha: ${dateLabel}`);
|
||||
lines.push(`Hora: ${timeLabel}`);
|
||||
lines.push("------------------------------");
|
||||
lines.push("ADD-ONS");
|
||||
if (addonLines.length === 0) {
|
||||
lines.push("Sin add-ons");
|
||||
} else {
|
||||
for (const line of addonLines) {
|
||||
lines.push(`${line.label}: x${line.qty}`);
|
||||
}
|
||||
}
|
||||
lines.push("------------------------------");
|
||||
lines.push(`Lealtad: ${ticket.loyaltyApplied ? `Si (-${formatCurrency(ticket.discountCents)})` : "No"}`);
|
||||
lines.push(`Subtotal: ${formatCurrency(ticket.subtotalCents)}`);
|
||||
lines.push(`IVA 16%: ${formatCurrency(ticket.ivaCents)}`);
|
||||
lines.push(`TOTAL: ${formatCurrency(ticket.totalCents)}`);
|
||||
return lines.join("\n");
|
||||
}, [addonLines, dateLabel, timeLabel, ticket]);
|
||||
|
||||
const handlePrint = () => {
|
||||
const popup = window.open("", "_blank", "width=420,height=680");
|
||||
if (!popup) {
|
||||
setShareStatus("No se pudo abrir ventana de impresion.");
|
||||
return;
|
||||
}
|
||||
|
||||
const safeText = escapeHtml(ticketText).replaceAll("\n", "<br>");
|
||||
popup.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Ticket #${ticket.ticketNumber}</title>
|
||||
<style>
|
||||
body { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; margin: 16px; color: #111827; }
|
||||
.wrap { max-width: 320px; margin: 0 auto; }
|
||||
.num { text-align: center; font-size: 28px; font-weight: 700; margin-bottom: 12px; }
|
||||
.text { font-size: 13px; line-height: 1.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="num">#${ticket.ticketNumber}</div>
|
||||
<div class="text">${safeText}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
popup.document.close();
|
||||
popup.focus();
|
||||
popup.print();
|
||||
popup.close();
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
setShareStatus(null);
|
||||
|
||||
try {
|
||||
const nav = window.navigator;
|
||||
if (typeof nav.share === "function") {
|
||||
await nav.share({
|
||||
title: `Ticket #${ticket.ticketNumber}`,
|
||||
text: ticketText
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (nav.clipboard?.writeText) {
|
||||
await nav.clipboard.writeText(ticketText);
|
||||
} else {
|
||||
throw new Error("Clipboard no disponible");
|
||||
}
|
||||
setShareStatus("Ticket copiado al portapapeles.");
|
||||
} catch {
|
||||
setShareStatus("No se pudo compartir el ticket.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-3">
|
||||
<div className="w-full max-w-md rounded-2xl bg-white p-4 shadow-xl">
|
||||
<p className="text-center text-xs font-semibold uppercase tracking-[0.25em] text-slate-500">Vista previa ticket</p>
|
||||
<p className="mt-1 text-center text-4xl font-bold text-slate-900">#{ticket.ticketNumber}</p>
|
||||
|
||||
<div className="mt-3 space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm text-slate-700">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2">
|
||||
<span className="font-semibold">Cliente:</span>
|
||||
<span>{ticket.customerName}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2">
|
||||
<span className="font-semibold">Servicio:</span>
|
||||
<span>{serviceLabels[ticket.serviceType]}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2">
|
||||
<span className="font-semibold">Cajero:</span>
|
||||
<span>{ticket.cashierName}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-slate-200 pt-2">
|
||||
<p><span className="font-semibold">Fecha:</span> {dateLabel}</p>
|
||||
<p><span className="font-semibold">Hora:</span> {timeLabel}</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 pt-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Add-ons</p>
|
||||
{addonLines.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">Sin add-ons</p>
|
||||
) : (
|
||||
<div className="mt-1 space-y-1">
|
||||
{addonLines.map((line) => (
|
||||
<div key={line.label} className="flex items-center justify-between text-xs">
|
||||
<span>{line.label}</span>
|
||||
<span>x{line.qty}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-200 pt-2 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Lealtad aplicada</span>
|
||||
<span>{ticket.loyaltyApplied ? `Si (-${formatCurrency(ticket.discountCents)})` : "No"}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatCurrency(ticket.subtotalCents)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span>IVA (16%)</span>
|
||||
<span>{formatCurrency(ticket.ivaCents)}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between border-t border-slate-300 pt-1 text-sm font-bold text-slate-900">
|
||||
<span>Total</span>
|
||||
<span>{formatCurrency(ticket.totalCents)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!ticket.relayOk && (
|
||||
<p className="mt-2 rounded-lg bg-amber-100 px-2 py-1 text-xs text-amber-800">
|
||||
Activacion registrada pero relay no confirmado. Revisa estado de la maquina.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{shareStatus && <p className="mt-2 text-xs text-slate-600">{shareStatus}</p>}
|
||||
|
||||
<div className="mt-4 grid grid-cols-3 gap-2">
|
||||
<button onClick={handlePrint} className="rounded-lg bg-slate-800 px-3 py-2 text-sm font-semibold text-white">Imprimir</button>
|
||||
<button onClick={() => void handleShare()} className="rounded-lg bg-teal-700 px-3 py-2 text-sm font-semibold text-white">Compartir</button>
|
||||
<button onClick={onClose} className="rounded-lg bg-slate-200 px-3 py-2 text-sm font-semibold text-slate-700">Cerrar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
src/components/pos/types.ts
Normal file
136
src/components/pos/types.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
export type ServiceType = "autoservicio" | "encargo" | "xl";
|
||||
|
||||
export type Machine = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "washer" | "dryer";
|
||||
relayChannel: number;
|
||||
defaultPriceCents: number;
|
||||
defaultDurationMinutes: number;
|
||||
status: "available" | "running" | "out_of_service";
|
||||
transaction: {
|
||||
id: string;
|
||||
ticketNumber: number;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
baseAmountCents: number;
|
||||
discountCents: number;
|
||||
loyaltyDiscountApplied: boolean;
|
||||
addonDetergentQty: number;
|
||||
addonSoftenerQty: number;
|
||||
addonBleachQty: number;
|
||||
addonAmountCents: number;
|
||||
serviceType: ServiceType;
|
||||
amountCents: number;
|
||||
paymentMethod: "cash" | "card" | "transfer";
|
||||
startedAt: string;
|
||||
expectedEndAt: string;
|
||||
employeeId: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type CustomerRecord = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
email: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
eligibleTransactionCount: number;
|
||||
totalSpentCents: number;
|
||||
nextDiscountTransactionNumber: number;
|
||||
isNextTransactionDiscount: boolean;
|
||||
};
|
||||
|
||||
export type LoyaltyRule = {
|
||||
everyNTransactions: number;
|
||||
discountPct: number;
|
||||
};
|
||||
|
||||
export type RelayHealth = {
|
||||
connected: boolean;
|
||||
mode: "mock" | "serial";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type Employee = {
|
||||
id: string;
|
||||
name: string;
|
||||
isAdmin: boolean;
|
||||
};
|
||||
|
||||
export type ActiveShiftPayload = {
|
||||
shift: {
|
||||
id: string;
|
||||
startTime: string;
|
||||
startingCashCents: number;
|
||||
} | null;
|
||||
summary: {
|
||||
totals: {
|
||||
totalSalesCents: number;
|
||||
expectedCashCents: number;
|
||||
transactionCount: number;
|
||||
byPaymentMethod: Array<{ paymentMethod: string; amountCents: number; count: number }>;
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type ReportSummary = {
|
||||
totals: {
|
||||
totalRevenueCents: number;
|
||||
transactionCount: number;
|
||||
avgTicketCents: number;
|
||||
};
|
||||
byPaymentMethod: Array<{ paymentMethod: string; amountCents: number; count: number }>;
|
||||
byMachine: Array<{ machineName: string; amountCents: number; count: number }>;
|
||||
};
|
||||
|
||||
export type UtilizationRow = {
|
||||
machineId: string;
|
||||
machineName: string;
|
||||
usedMinutes: number;
|
||||
totalWindowMinutes: number;
|
||||
utilizationPct: number;
|
||||
};
|
||||
|
||||
export type PricingVariables = {
|
||||
selfServiceWashPriceCents: number;
|
||||
selfServiceDryPriceCents: number;
|
||||
selfServiceCycleMinutes: number;
|
||||
encargoPricePerKgCents: number;
|
||||
encargoMinimumChargeCents: number;
|
||||
xlEdredonIndividualCents: number;
|
||||
xlEdredonMatrimonialCents: number;
|
||||
xlEdredonKingCents: number;
|
||||
xlCobijaGruesaCents: number;
|
||||
xlAlmohadaParCents: number;
|
||||
dryCleaningMinimumCents: number;
|
||||
dryCleaningUrgentSurchargePct: number;
|
||||
detergentAddonCents: number;
|
||||
softenerAddonCents: number;
|
||||
bleachAddonCents: number;
|
||||
loyaltyEveryNTransactions: number;
|
||||
loyaltyDiscountPct: number;
|
||||
};
|
||||
|
||||
export type TicketPreviewData = {
|
||||
ticketNumber: number;
|
||||
customerName: string;
|
||||
serviceType: ServiceType;
|
||||
addons: {
|
||||
detergentQty: number;
|
||||
softenerQty: number;
|
||||
bleachQty: number;
|
||||
};
|
||||
loyaltyApplied: boolean;
|
||||
discountCents: number;
|
||||
subtotalCents: number;
|
||||
ivaCents: number;
|
||||
totalCents: number;
|
||||
dateTimeIso: string;
|
||||
cashierName: string;
|
||||
machineName: string;
|
||||
paymentMethod: "cash" | "card" | "transfer";
|
||||
relayOk: boolean;
|
||||
};
|
||||
21
src/lib/config.ts
Normal file
21
src/lib/config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export const APP_DEFAULTS = {
|
||||
timezone: "America/Monterrey",
|
||||
currency: "MXN",
|
||||
locale: "es-MX",
|
||||
serialBaudRate: 9600,
|
||||
serialPortPath: "COM3"
|
||||
} as const;
|
||||
|
||||
export function getNodeEnv(): "development" | "test" | "production" {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return "production";
|
||||
}
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
return "test";
|
||||
}
|
||||
return "development";
|
||||
}
|
||||
|
||||
export function nowDate() {
|
||||
return new Date();
|
||||
}
|
||||
16
src/lib/db.ts
Normal file
16
src/lib/db.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var prismaGlobal: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
global.prismaGlobal ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"]
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
global.prismaGlobal = prisma;
|
||||
}
|
||||
36
src/lib/format.ts
Normal file
36
src/lib/format.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { APP_DEFAULTS } from "@/lib/config";
|
||||
|
||||
export function formatCurrency(cents: number, currency = APP_DEFAULTS.currency) {
|
||||
return new Intl.NumberFormat(APP_DEFAULTS.locale, {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 2
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
export function formatDateTime(value: Date | string) {
|
||||
const date = typeof value === "string" ? new Date(value) : value;
|
||||
return new Intl.DateTimeFormat(APP_DEFAULTS.locale, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
timeZone: APP_DEFAULTS.timezone
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatMinutes(minutes: number) {
|
||||
const safe = Math.max(0, Math.floor(minutes));
|
||||
const hrs = Math.floor(safe / 60);
|
||||
const mins = safe % 60;
|
||||
if (hrs === 0) {
|
||||
return `${mins} min`;
|
||||
}
|
||||
return `${hrs} h ${mins} min`;
|
||||
}
|
||||
|
||||
export function parseMoneyToCents(input: string | number) {
|
||||
const value = typeof input === "number" ? input : Number.parseFloat(input);
|
||||
if (!Number.isFinite(value)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.round(value * 100);
|
||||
}
|
||||
15
src/lib/http.ts
Normal file
15
src/lib/http.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function ok<T>(data: T, status = 200) {
|
||||
return NextResponse.json(data, { status });
|
||||
}
|
||||
|
||||
export function fail(message: string, status = 400, detail?: unknown) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: message,
|
||||
detail
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
21
src/lib/logger.ts
Normal file
21
src/lib/logger.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
type LogPayload = Record<string, unknown>;
|
||||
|
||||
function out(level: "info" | "warn" | "error", message: string, payload?: LogPayload) {
|
||||
const stamp = new Date().toISOString();
|
||||
const line = payload ? `${stamp} [${level}] ${message} ${JSON.stringify(payload)}` : `${stamp} [${level}] ${message}`;
|
||||
if (level === "error") {
|
||||
console.error(line);
|
||||
return;
|
||||
}
|
||||
if (level === "warn") {
|
||||
console.warn(line);
|
||||
return;
|
||||
}
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
info: (message: string, payload?: LogPayload) => out("info", message, payload),
|
||||
warn: (message: string, payload?: LogPayload) => out("warn", message, payload),
|
||||
error: (message: string, payload?: LogPayload) => out("error", message, payload)
|
||||
};
|
||||
37
src/lib/relay/mockRelayController.ts
Normal file
37
src/lib/relay/mockRelayController.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import "server-only";
|
||||
|
||||
import type { RelayController } from "@/lib/relay/types";
|
||||
|
||||
export class MockRelayController implements RelayController {
|
||||
private connected = false;
|
||||
private states = new Map<number, boolean>();
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
async turnOn(channel: number): Promise<void> {
|
||||
this.assertConnected();
|
||||
this.states.set(channel, true);
|
||||
}
|
||||
|
||||
async turnOff(channel: number): Promise<void> {
|
||||
this.assertConnected();
|
||||
this.states.set(channel, false);
|
||||
}
|
||||
|
||||
async getStatus(channel: number): Promise<boolean> {
|
||||
this.assertConnected();
|
||||
return this.states.get(channel) ?? false;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
private assertConnected() {
|
||||
if (!this.connected) {
|
||||
throw new Error("Mock relay no conectado");
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/lib/relay/protocol.ts
Normal file
13
src/lib/relay/protocol.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type RelayProtocol = {
|
||||
onCommand: (channel: number) => Buffer;
|
||||
offCommand: (channel: number) => Buffer;
|
||||
};
|
||||
|
||||
export const asciiRelayProtocol: RelayProtocol = {
|
||||
onCommand(channel) {
|
||||
return Buffer.from(`relay on ${channel + 1}\n`, "utf8");
|
||||
},
|
||||
offCommand(channel) {
|
||||
return Buffer.from(`relay off ${channel + 1}\n`, "utf8");
|
||||
}
|
||||
};
|
||||
91
src/lib/relay/serialRelayController.ts
Normal file
91
src/lib/relay/serialRelayController.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import "server-only";
|
||||
|
||||
import { asciiRelayProtocol, type RelayProtocol } from "@/lib/relay/protocol";
|
||||
import type { RelayController } from "@/lib/relay/types";
|
||||
|
||||
export class SerialRelayController implements RelayController {
|
||||
private serialPort: import("serialport").SerialPort | null = null;
|
||||
|
||||
constructor(private readonly protocol: RelayProtocol = asciiRelayProtocol) {}
|
||||
|
||||
async connect(port: string, baudRate: number): Promise<void> {
|
||||
const SerialPort = await this.getSerialPortCtor();
|
||||
if (this.serialPort?.isOpen) {
|
||||
return;
|
||||
}
|
||||
this.serialPort = new SerialPort({
|
||||
path: port,
|
||||
baudRate,
|
||||
autoOpen: false
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.serialPort?.open((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async turnOn(channel: number): Promise<void> {
|
||||
await this.write(this.protocol.onCommand(channel));
|
||||
}
|
||||
|
||||
async turnOff(channel: number): Promise<void> {
|
||||
await this.write(this.protocol.offCommand(channel));
|
||||
}
|
||||
|
||||
async getStatus(): Promise<boolean> {
|
||||
return this.serialPort?.isOpen ?? false;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
if (!this.serialPort?.isOpen) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.serialPort?.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
this.serialPort = null;
|
||||
}
|
||||
|
||||
private async write(command: Buffer) {
|
||||
const port = this.serialPort;
|
||||
if (!port || !port.isOpen) {
|
||||
throw new Error("Puerto serial no conectado");
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
port.write(command, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
port.drain((drainError) => {
|
||||
if (drainError) {
|
||||
reject(drainError);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static async listPorts() {
|
||||
const { SerialPort } = await import("serialport");
|
||||
return SerialPort.list();
|
||||
}
|
||||
|
||||
private async getSerialPortCtor() {
|
||||
const { SerialPort } = await import("serialport");
|
||||
return SerialPort;
|
||||
}
|
||||
}
|
||||
14
src/lib/relay/types.ts
Normal file
14
src/lib/relay/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface RelayController {
|
||||
connect(port: string, baudRate: number): Promise<void>;
|
||||
turnOn(channel: number): Promise<void>;
|
||||
turnOff(channel: number): Promise<void>;
|
||||
getStatus(channel: number): Promise<boolean>;
|
||||
disconnect(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface RelayHealth {
|
||||
connected: boolean;
|
||||
mode: "mock" | "serial";
|
||||
port?: string;
|
||||
error?: string;
|
||||
}
|
||||
16
src/lib/time.ts
Normal file
16
src/lib/time.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function minutesBetween(start: Date, end: Date) {
|
||||
return Math.max(0, (end.getTime() - start.getTime()) / 60_000);
|
||||
}
|
||||
|
||||
export function addMinutes(date: Date, minutes: number) {
|
||||
return new Date(date.getTime() + minutes * 60_000);
|
||||
}
|
||||
|
||||
export function clampRange(start: Date, end: Date, rangeStart: Date, rangeEnd: Date) {
|
||||
const clampedStart = new Date(Math.max(start.getTime(), rangeStart.getTime()));
|
||||
const clampedEnd = new Date(Math.min(end.getTime(), rangeEnd.getTime()));
|
||||
if (clampedEnd <= clampedStart) {
|
||||
return null;
|
||||
}
|
||||
return { start: clampedStart, end: clampedEnd };
|
||||
}
|
||||
21
src/server/api/dateRange.ts
Normal file
21
src/server/api/dateRange.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export function parseDateRange(params: URLSearchParams) {
|
||||
const fromRaw = params.get("from");
|
||||
const toRaw = params.get("to");
|
||||
const now = new Date();
|
||||
|
||||
if (!fromRaw || !toRaw) {
|
||||
const start = new Date(now);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
return { from: start, to: now };
|
||||
}
|
||||
|
||||
const from = new Date(fromRaw);
|
||||
const to = new Date(toRaw);
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
|
||||
throw new Error("Rango de fechas invalido");
|
||||
}
|
||||
if (to < from) {
|
||||
throw new Error("Rango de fechas invalido");
|
||||
}
|
||||
return { from, to };
|
||||
}
|
||||
34
src/server/domain/constants.ts
Normal file
34
src/server/domain/constants.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export const MACHINE_TYPES = {
|
||||
washer: "washer",
|
||||
dryer: "dryer"
|
||||
} as const;
|
||||
|
||||
export const PAYMENT_METHODS = {
|
||||
cash: "cash",
|
||||
card: "card",
|
||||
transfer: "transfer"
|
||||
} as const;
|
||||
|
||||
export const SERVICE_TYPES = {
|
||||
autoservicio: "autoservicio",
|
||||
encargo: "encargo",
|
||||
xl: "xl"
|
||||
} as const;
|
||||
|
||||
export const TRANSACTION_STATUS = {
|
||||
pendingRelay: "pending_relay",
|
||||
running: "running",
|
||||
completed: "completed",
|
||||
relayFailed: "relay_failed",
|
||||
voided: "voided"
|
||||
} as const;
|
||||
|
||||
export const CASH_MOVEMENT_TYPE = {
|
||||
deposit: "deposit",
|
||||
withdrawal: "withdrawal"
|
||||
} as const;
|
||||
|
||||
export type PaymentMethodValue = (typeof PAYMENT_METHODS)[keyof typeof PAYMENT_METHODS];
|
||||
export type ServiceTypeValue = (typeof SERVICE_TYPES)[keyof typeof SERVICE_TYPES];
|
||||
export type TransactionStatusValue = (typeof TRANSACTION_STATUS)[keyof typeof TRANSACTION_STATUS];
|
||||
export type CashMovementTypeValue = (typeof CASH_MOVEMENT_TYPE)[keyof typeof CASH_MOVEMENT_TYPE];
|
||||
152
src/server/relay/relayManager.ts
Normal file
152
src/server/relay/relayManager.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import "server-only";
|
||||
|
||||
import { APP_DEFAULTS } from "@/lib/config";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { MockRelayController } from "@/lib/relay/mockRelayController";
|
||||
import { SerialRelayController } from "@/lib/relay/serialRelayController";
|
||||
import type { RelayController, RelayHealth } from "@/lib/relay/types";
|
||||
|
||||
class RelayManager {
|
||||
private controller: RelayController | null = null;
|
||||
private health: RelayHealth = {
|
||||
connected: false,
|
||||
mode: "mock"
|
||||
};
|
||||
private initialized = false;
|
||||
|
||||
async init() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
}
|
||||
await this.reloadFromConfig();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async reloadFromConfig() {
|
||||
const config = await prisma.appConfig.upsert({
|
||||
where: { id: 1 },
|
||||
update: {},
|
||||
create: {
|
||||
id: 1,
|
||||
businessName: "La Burbuja"
|
||||
}
|
||||
});
|
||||
await this.connectWithSettings(config.relayMockMode, config.serialPortPath, config.serialBaudRate);
|
||||
}
|
||||
|
||||
async connectWithSettings(mockMode: boolean, port: string, baudRate: number) {
|
||||
if (this.controller) {
|
||||
try {
|
||||
await this.controller.disconnect();
|
||||
} catch (error) {
|
||||
logger.warn("Error al cerrar relay previo", { error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
this.controller = mockMode ? new MockRelayController() : new SerialRelayController();
|
||||
this.health = {
|
||||
connected: false,
|
||||
mode: mockMode ? "mock" : "serial",
|
||||
port
|
||||
};
|
||||
|
||||
try {
|
||||
await this.controller.connect(port, baudRate || APP_DEFAULTS.serialBaudRate);
|
||||
this.health.connected = true;
|
||||
this.health.error = undefined;
|
||||
await prisma.appConfig.update({
|
||||
where: { id: 1 },
|
||||
data: {
|
||||
relayConnected: true,
|
||||
relayMockMode: mockMode,
|
||||
serialPortPath: port,
|
||||
serialBaudRate: baudRate || APP_DEFAULTS.serialBaudRate
|
||||
}
|
||||
});
|
||||
logger.info("Relay conectado", { mode: this.health.mode, port });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.health.connected = false;
|
||||
this.health.error = message;
|
||||
await prisma.appConfig.update({
|
||||
where: { id: 1 },
|
||||
data: {
|
||||
relayConnected: false,
|
||||
relayMockMode: mockMode,
|
||||
serialPortPath: port,
|
||||
serialBaudRate: baudRate || APP_DEFAULTS.serialBaudRate
|
||||
}
|
||||
});
|
||||
logger.error("Fallo conexion relay", { message, mode: this.health.mode, port });
|
||||
}
|
||||
}
|
||||
|
||||
async turnOn(channel: number) {
|
||||
await this.init();
|
||||
if (!this.controller) {
|
||||
throw new Error("Relay no inicializado");
|
||||
}
|
||||
try {
|
||||
await this.controller.turnOn(channel);
|
||||
} catch (error) {
|
||||
this.health.connected = false;
|
||||
this.health.error = error instanceof Error ? error.message : String(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async turnOff(channel: number) {
|
||||
await this.init();
|
||||
if (!this.controller) {
|
||||
throw new Error("Relay no inicializado");
|
||||
}
|
||||
try {
|
||||
await this.controller.turnOff(channel);
|
||||
} catch (error) {
|
||||
this.health.connected = false;
|
||||
this.health.error = error instanceof Error ? error.message : String(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getChannelStatus(channel: number) {
|
||||
await this.init();
|
||||
if (!this.controller) {
|
||||
return false;
|
||||
}
|
||||
return this.controller.getStatus(channel);
|
||||
}
|
||||
|
||||
async reconnect() {
|
||||
this.initialized = false;
|
||||
await this.init();
|
||||
}
|
||||
|
||||
async getHealth() {
|
||||
await this.init();
|
||||
return this.health;
|
||||
}
|
||||
|
||||
async listSerialPorts() {
|
||||
try {
|
||||
return await SerialRelayController.listPorts();
|
||||
} catch (error) {
|
||||
logger.warn("No se pudieron listar puertos seriales", {
|
||||
error: String(error)
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var relayManagerGlobal: RelayManager | undefined;
|
||||
}
|
||||
|
||||
export const relayManager = global.relayManagerGlobal ?? new RelayManager();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
global.relayManagerGlobal = relayManager;
|
||||
}
|
||||
307
src/server/services/activationService.ts
Normal file
307
src/server/services/activationService.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import "server-only";
|
||||
|
||||
import { addMinutes } from "@/lib/time";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { TRANSACTION_STATUS, type PaymentMethodValue, type ServiceTypeValue } from "@/server/domain/constants";
|
||||
import { relayManager } from "@/server/relay/relayManager";
|
||||
import { calculateAddonTotalCents, calculateLoyaltyDiscountCents } from "@/server/services/calculations";
|
||||
import { timerService } from "@/server/services/timerService";
|
||||
|
||||
type ActivateMachineAddonsInput = {
|
||||
detergentQty: number;
|
||||
softenerQty: number;
|
||||
bleachQty: number;
|
||||
};
|
||||
|
||||
export type ActivateMachineInput = {
|
||||
machineId: string;
|
||||
employeeId: string;
|
||||
customerId: string;
|
||||
baseAmountCents: number;
|
||||
durationMinutes: number;
|
||||
serviceType: ServiceTypeValue;
|
||||
paymentMethod: PaymentMethodValue;
|
||||
addons: ActivateMachineAddonsInput;
|
||||
};
|
||||
|
||||
export async function activateMachine(input: ActivateMachineInput) {
|
||||
const startedAt = new Date();
|
||||
const expectedEndAt = addMinutes(startedAt, input.durationMinutes);
|
||||
|
||||
const { machineRelayChannel, transaction } = await prisma.$transaction(async (tx) => {
|
||||
const machine = await tx.machine.findUnique({
|
||||
where: { id: input.machineId },
|
||||
include: {
|
||||
transactions: {
|
||||
where: {
|
||||
status: {
|
||||
in: [TRANSACTION_STATUS.running, TRANSACTION_STATUS.pendingRelay]
|
||||
}
|
||||
},
|
||||
take: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!machine || !machine.isActive) {
|
||||
throw new Error("Maquina no disponible");
|
||||
}
|
||||
|
||||
if (machine.outOfService) {
|
||||
throw new Error("Maquina fuera de servicio");
|
||||
}
|
||||
|
||||
if (machine.transactions.length > 0) {
|
||||
throw new Error("Maquina actualmente en uso");
|
||||
}
|
||||
|
||||
const customer = await tx.customer.findUnique({
|
||||
where: { id: input.customerId },
|
||||
select: { id: true, isActive: true }
|
||||
});
|
||||
|
||||
if (!customer || !customer.isActive) {
|
||||
throw new Error("Cliente no disponible");
|
||||
}
|
||||
|
||||
const config = await tx.appConfig.findUnique({
|
||||
where: { id: 1 },
|
||||
select: {
|
||||
loyaltyEveryNTransactions: true,
|
||||
loyaltyDiscountPct: true,
|
||||
detergentAddonCents: true,
|
||||
softenerAddonCents: true,
|
||||
bleachAddonCents: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!config) {
|
||||
throw new Error("Configuracion no disponible");
|
||||
}
|
||||
|
||||
const priorEligibleTransactions = await tx.transaction.count({
|
||||
where: {
|
||||
customerId: input.customerId,
|
||||
status: {
|
||||
in: [TRANSACTION_STATUS.pendingRelay, TRANSACTION_STATUS.running, TRANSACTION_STATUS.completed]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const customerTransactionNumber = priorEligibleTransactions + 1;
|
||||
const loyaltyEveryNTransactions = Math.max(1, config.loyaltyEveryNTransactions);
|
||||
const loyaltyDiscountPct = Math.max(0, Math.min(100, config.loyaltyDiscountPct));
|
||||
const loyaltyDiscountApplied =
|
||||
loyaltyDiscountPct > 0 && customerTransactionNumber % loyaltyEveryNTransactions === 0;
|
||||
|
||||
const discountCents = loyaltyDiscountApplied
|
||||
? calculateLoyaltyDiscountCents(input.baseAmountCents, loyaltyDiscountPct)
|
||||
: 0;
|
||||
const addonAmountCents = calculateAddonTotalCents({
|
||||
detergentQty: input.addons.detergentQty,
|
||||
softenerQty: input.addons.softenerQty,
|
||||
bleachQty: input.addons.bleachQty,
|
||||
detergentAddonCents: config.detergentAddonCents,
|
||||
softenerAddonCents: config.softenerAddonCents,
|
||||
bleachAddonCents: config.bleachAddonCents
|
||||
});
|
||||
|
||||
const amountCents = Math.max(0, input.baseAmountCents - discountCents + addonAmountCents);
|
||||
|
||||
const ticketAgg = await tx.transaction.aggregate({
|
||||
_max: { ticketNumber: true }
|
||||
});
|
||||
const ticketNumber = (ticketAgg._max.ticketNumber ?? 0) + 1;
|
||||
|
||||
const transaction = await tx.transaction.create({
|
||||
data: {
|
||||
ticketNumber,
|
||||
machineId: input.machineId,
|
||||
employeeId: input.employeeId,
|
||||
customerId: input.customerId,
|
||||
baseAmountCents: input.baseAmountCents,
|
||||
discountCents,
|
||||
loyaltyDiscountApplied,
|
||||
addonDetergentQty: input.addons.detergentQty,
|
||||
addonSoftenerQty: input.addons.softenerQty,
|
||||
addonBleachQty: input.addons.bleachQty,
|
||||
addonAmountCents,
|
||||
serviceType: input.serviceType,
|
||||
amountCents,
|
||||
paymentMethod: input.paymentMethod,
|
||||
startedAt,
|
||||
expectedEndAt,
|
||||
status: TRANSACTION_STATUS.pendingRelay
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
machineRelayChannel: machine.relayChannel,
|
||||
transaction
|
||||
};
|
||||
});
|
||||
|
||||
await prisma.transaction.update({
|
||||
where: { id: transaction.id },
|
||||
data: { relayOnAttemptedAt: new Date() }
|
||||
});
|
||||
|
||||
try {
|
||||
await relayManager.turnOn(machineRelayChannel);
|
||||
const updated = await prisma.transaction.update({
|
||||
where: { id: transaction.id },
|
||||
data: {
|
||||
status: TRANSACTION_STATUS.running,
|
||||
relayTurnedOnAt: new Date(),
|
||||
relayFailureReason: null
|
||||
},
|
||||
include: {
|
||||
customer: {
|
||||
select: { firstName: true, lastName: true, phone: true }
|
||||
},
|
||||
employee: {
|
||||
select: { name: true }
|
||||
},
|
||||
machine: {
|
||||
select: { name: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
timerService.scheduleExpiry(updated.id, updated.expectedEndAt);
|
||||
return {
|
||||
transaction: updated,
|
||||
relayOk: true
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const updated = await prisma.transaction.update({
|
||||
where: { id: transaction.id },
|
||||
data: {
|
||||
status: TRANSACTION_STATUS.relayFailed,
|
||||
relayFailureReason: message
|
||||
},
|
||||
include: {
|
||||
customer: {
|
||||
select: { firstName: true, lastName: true, phone: true }
|
||||
},
|
||||
employee: {
|
||||
select: { name: true }
|
||||
},
|
||||
machine: {
|
||||
select: { name: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
return {
|
||||
transaction: updated,
|
||||
relayOk: false,
|
||||
relayError: message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function retryRelayOn(transactionId: string) {
|
||||
const transaction = await prisma.transaction.findUnique({
|
||||
where: { id: transactionId },
|
||||
include: { machine: true }
|
||||
});
|
||||
|
||||
if (!transaction) {
|
||||
throw new Error("Transaccion no encontrada");
|
||||
}
|
||||
|
||||
const retryableStatuses = new Set<string>([TRANSACTION_STATUS.relayFailed, TRANSACTION_STATUS.pendingRelay]);
|
||||
if (!retryableStatuses.has(transaction.status)) {
|
||||
throw new Error("Transaccion no elegible para reintento");
|
||||
}
|
||||
|
||||
await prisma.transaction.update({
|
||||
where: { id: transaction.id },
|
||||
data: { relayOnAttemptedAt: new Date() }
|
||||
});
|
||||
|
||||
await relayManager.turnOn(transaction.machine.relayChannel);
|
||||
const updated = await prisma.transaction.update({
|
||||
where: { id: transaction.id },
|
||||
data: {
|
||||
status: TRANSACTION_STATUS.running,
|
||||
relayTurnedOnAt: new Date(),
|
||||
relayFailureReason: null
|
||||
}
|
||||
});
|
||||
timerService.scheduleExpiry(updated.id, updated.expectedEndAt);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function addTimeToTransaction(input: {
|
||||
transactionId: string;
|
||||
employeeId: string;
|
||||
extraMinutes: number;
|
||||
extraAmountCents: number;
|
||||
reason?: string;
|
||||
}) {
|
||||
const transaction = await prisma.transaction.findUnique({
|
||||
where: { id: input.transactionId }
|
||||
});
|
||||
|
||||
if (!transaction) {
|
||||
throw new Error("Transaccion no encontrada");
|
||||
}
|
||||
|
||||
if (transaction.status !== TRANSACTION_STATUS.running) {
|
||||
throw new Error("Solo se puede agregar tiempo a transacciones activas");
|
||||
}
|
||||
|
||||
const nextEnd = addMinutes(transaction.expectedEndAt, input.extraMinutes);
|
||||
const updated = await prisma.transaction.update({
|
||||
where: { id: transaction.id },
|
||||
data: {
|
||||
expectedEndAt: nextEnd,
|
||||
amountCents: transaction.amountCents + input.extraAmountCents,
|
||||
baseAmountCents: transaction.baseAmountCents + input.extraAmountCents
|
||||
}
|
||||
});
|
||||
|
||||
await prisma.transactionExtension.create({
|
||||
data: {
|
||||
transactionId: transaction.id,
|
||||
employeeId: input.employeeId,
|
||||
extraMinutes: input.extraMinutes,
|
||||
extraAmountCents: input.extraAmountCents,
|
||||
reason: input.reason
|
||||
}
|
||||
});
|
||||
|
||||
timerService.scheduleExpiry(updated.id, updated.expectedEndAt);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function voidTransaction(input: { transactionId: string; reason: string }) {
|
||||
const transaction = await prisma.transaction.findUnique({
|
||||
where: { id: input.transactionId },
|
||||
include: { machine: true }
|
||||
});
|
||||
|
||||
if (!transaction) {
|
||||
throw new Error("Transaccion no encontrada");
|
||||
}
|
||||
|
||||
if (transaction.status === TRANSACTION_STATUS.voided) {
|
||||
return transaction;
|
||||
}
|
||||
|
||||
if (transaction.status === TRANSACTION_STATUS.running) {
|
||||
await relayManager.turnOff(transaction.machine.relayChannel);
|
||||
}
|
||||
timerService.unschedule(transaction.id);
|
||||
|
||||
return prisma.transaction.update({
|
||||
where: { id: transaction.id },
|
||||
data: {
|
||||
status: TRANSACTION_STATUS.voided,
|
||||
voidReason: input.reason,
|
||||
endedAt: new Date(),
|
||||
relayTurnedOffAt: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
34
src/server/services/authService.ts
Normal file
34
src/server/services/authService.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function loginWithPin(pin: string) {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: {
|
||||
pin,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!employee) {
|
||||
throw new Error("PIN invalido");
|
||||
}
|
||||
|
||||
return employee;
|
||||
}
|
||||
|
||||
export function getAdminPinFromRequest(request: Request) {
|
||||
return request.headers.get("x-admin-pin")?.trim() ?? "";
|
||||
}
|
||||
|
||||
export async function requireAdminFromRequest(request: Request) {
|
||||
const pin = getAdminPinFromRequest(request);
|
||||
if (!pin || pin.length !== 4) {
|
||||
throw new Error("PIN de administrador requerido");
|
||||
}
|
||||
const employee = await loginWithPin(pin);
|
||||
if (!employee.isAdmin) {
|
||||
throw new Error("Permiso denegado: solo administrador");
|
||||
}
|
||||
return employee;
|
||||
}
|
||||
38
src/server/services/calculations.ts
Normal file
38
src/server/services/calculations.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export function calculateExpectedCash(input: {
|
||||
startingCashCents: number;
|
||||
cashSalesCents: number;
|
||||
depositsCents: number;
|
||||
withdrawalsCents: number;
|
||||
}) {
|
||||
return input.startingCashCents + input.cashSalesCents + input.depositsCents - input.withdrawalsCents;
|
||||
}
|
||||
|
||||
export function calculateUtilizationPct(usedMinutes: number, totalWindowMinutes: number) {
|
||||
if (totalWindowMinutes <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return Number(((usedMinutes / totalWindowMinutes) * 100).toFixed(2));
|
||||
}
|
||||
|
||||
export function calculateLoyaltyDiscountCents(baseAmountCents: number, discountPct: number) {
|
||||
if (baseAmountCents <= 0 || discountPct <= 0) {
|
||||
return 0;
|
||||
}
|
||||
const boundedPct = Math.min(100, Math.max(0, discountPct));
|
||||
return Math.round((baseAmountCents * boundedPct) / 100);
|
||||
}
|
||||
|
||||
export function calculateAddonTotalCents(input: {
|
||||
detergentQty: number;
|
||||
softenerQty: number;
|
||||
bleachQty: number;
|
||||
detergentAddonCents: number;
|
||||
softenerAddonCents: number;
|
||||
bleachAddonCents: number;
|
||||
}) {
|
||||
return (
|
||||
Math.max(0, input.detergentQty) * input.detergentAddonCents +
|
||||
Math.max(0, input.softenerQty) * input.softenerAddonCents +
|
||||
Math.max(0, input.bleachQty) * input.bleachAddonCents
|
||||
);
|
||||
}
|
||||
153
src/server/services/customerService.ts
Normal file
153
src/server/services/customerService.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import "server-only";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { TRANSACTION_STATUS } from "@/server/domain/constants";
|
||||
|
||||
const DEFAULT_LIMIT = 20;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
const LOYALTY_ELIGIBLE_STATUSES = [TRANSACTION_STATUS.pendingRelay, TRANSACTION_STATUS.running, TRANSACTION_STATUS.completed] as const;
|
||||
|
||||
export type CustomerListItem = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
email: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
eligibleTransactionCount: number;
|
||||
totalSpentCents: number;
|
||||
nextDiscountTransactionNumber: number;
|
||||
isNextTransactionDiscount: boolean;
|
||||
};
|
||||
|
||||
export function normalizePhone(raw: string) {
|
||||
return raw.replace(/[^\d+]/g, "");
|
||||
}
|
||||
|
||||
function sanitizeLimit(limit: number | undefined) {
|
||||
if (!Number.isFinite(limit) || !limit) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
return Math.max(1, Math.min(MAX_LIMIT, Math.floor(limit)));
|
||||
}
|
||||
|
||||
function nextDiscountTransactionNumber(transactionCount: number, everyN: number) {
|
||||
const normalizedEveryN = Math.max(1, everyN);
|
||||
return Math.ceil((transactionCount + 1) / normalizedEveryN) * normalizedEveryN;
|
||||
}
|
||||
|
||||
export async function listCustomers(input: { query?: string; limit?: number }) {
|
||||
const limit = sanitizeLimit(input.limit);
|
||||
const query = input.query?.trim();
|
||||
|
||||
const where: Prisma.CustomerWhereInput = query
|
||||
? {
|
||||
isActive: true,
|
||||
OR: [
|
||||
{ firstName: { contains: query } },
|
||||
{ lastName: { contains: query } },
|
||||
{ phone: { contains: query } },
|
||||
{ email: { contains: query } }
|
||||
]
|
||||
}
|
||||
: { isActive: true };
|
||||
|
||||
const [customers, config] = await Promise.all([
|
||||
prisma.customer.findMany({
|
||||
where,
|
||||
orderBy: [{ updatedAt: "desc" }, { createdAt: "desc" }],
|
||||
take: limit
|
||||
}),
|
||||
prisma.appConfig.findUnique({
|
||||
where: { id: 1 },
|
||||
select: {
|
||||
loyaltyEveryNTransactions: true,
|
||||
loyaltyDiscountPct: true
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
const customerIds = customers.map((customer) => customer.id);
|
||||
const stats =
|
||||
customerIds.length > 0
|
||||
? await prisma.transaction.groupBy({
|
||||
by: ["customerId"],
|
||||
where: {
|
||||
customerId: { in: customerIds },
|
||||
status: { in: [...LOYALTY_ELIGIBLE_STATUSES] }
|
||||
},
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true }
|
||||
})
|
||||
: [];
|
||||
|
||||
const statsMap = new Map<string, { count: number; total: number }>();
|
||||
for (const row of stats) {
|
||||
statsMap.set(row.customerId, {
|
||||
count: row._count._all,
|
||||
total: row._sum.amountCents ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
const loyaltyEveryNTransactions = Math.max(1, config?.loyaltyEveryNTransactions ?? 10);
|
||||
const loyaltyDiscountPct = Math.max(0, Math.min(100, config?.loyaltyDiscountPct ?? 50));
|
||||
|
||||
const rows: CustomerListItem[] = customers.map((customer) => {
|
||||
const txStats = statsMap.get(customer.id) ?? { count: 0, total: 0 };
|
||||
const nextDiscount = nextDiscountTransactionNumber(txStats.count, loyaltyEveryNTransactions);
|
||||
|
||||
return {
|
||||
id: customer.id,
|
||||
firstName: customer.firstName,
|
||||
lastName: customer.lastName,
|
||||
phone: customer.phone,
|
||||
email: customer.email,
|
||||
createdAt: customer.createdAt.toISOString(),
|
||||
updatedAt: customer.updatedAt.toISOString(),
|
||||
eligibleTransactionCount: txStats.count,
|
||||
totalSpentCents: txStats.total,
|
||||
nextDiscountTransactionNumber: nextDiscount,
|
||||
isNextTransactionDiscount: txStats.count + 1 === nextDiscount
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
customers: rows,
|
||||
loyalty: {
|
||||
everyNTransactions: loyaltyEveryNTransactions,
|
||||
discountPct: loyaltyDiscountPct
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function createCustomer(input: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
email?: string | null;
|
||||
}) {
|
||||
const normalizedPhone = normalizePhone(input.phone);
|
||||
if (normalizedPhone.length < 8) {
|
||||
throw new Error("Telefono invalido");
|
||||
}
|
||||
|
||||
try {
|
||||
return await prisma.customer.create({
|
||||
data: {
|
||||
firstName: input.firstName.trim(),
|
||||
lastName: input.lastName.trim(),
|
||||
phone: normalizedPhone,
|
||||
email: input.email?.trim() || null
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
throw new Error("Ya existe un cliente con ese telefono");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
248
src/server/services/machineService.ts
Normal file
248
src/server/services/machineService.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { TRANSACTION_STATUS } from "@/server/domain/constants";
|
||||
|
||||
export type DashboardMachine = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "washer" | "dryer";
|
||||
relayChannel: number;
|
||||
defaultPriceCents: number;
|
||||
defaultDurationMinutes: number;
|
||||
status: "available" | "running" | "out_of_service";
|
||||
transaction: {
|
||||
id: string;
|
||||
ticketNumber: number;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
baseAmountCents: number;
|
||||
discountCents: number;
|
||||
loyaltyDiscountApplied: boolean;
|
||||
addonDetergentQty: number;
|
||||
addonSoftenerQty: number;
|
||||
addonBleachQty: number;
|
||||
addonAmountCents: number;
|
||||
serviceType: "autoservicio" | "encargo" | "xl";
|
||||
amountCents: number;
|
||||
paymentMethod: "cash" | "card" | "transfer";
|
||||
startedAt: string;
|
||||
expectedEndAt: string;
|
||||
employeeId: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type ComboLabel = "ENCARGO" | "XL";
|
||||
|
||||
type ComboDescriptor = {
|
||||
number: number;
|
||||
label?: ComboLabel;
|
||||
};
|
||||
|
||||
const DEFAULT_COMBOS: ComboDescriptor[] = [
|
||||
...Array.from({ length: 12 }, (_, index) => ({
|
||||
number: index + 1
|
||||
})),
|
||||
{ number: 13, label: "ENCARGO" },
|
||||
{ number: 14, label: "ENCARGO" },
|
||||
{ number: 15, label: "ENCARGO" },
|
||||
{ number: 16, label: "XL" }
|
||||
];
|
||||
|
||||
function toComboLabelText(label?: ComboLabel) {
|
||||
if (label === "ENCARGO") {
|
||||
return "Encargo";
|
||||
}
|
||||
if (label === "XL") {
|
||||
return "XL";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildDefaultMachineName(type: "washer" | "dryer", combo: ComboDescriptor) {
|
||||
const base = type === "washer" ? `Lavadora ${combo.number}` : `Secadora ${combo.number}`;
|
||||
const label = toComboLabelText(combo.label);
|
||||
return label ? `${base} (${label})` : base;
|
||||
}
|
||||
|
||||
export async function ensureDefaultMachineCombos() {
|
||||
const existingMachines = await prisma.machine.findMany({
|
||||
select: {
|
||||
name: true,
|
||||
relayChannel: true
|
||||
},
|
||||
orderBy: { relayChannel: "asc" }
|
||||
});
|
||||
const usedNames = new Set(existingMachines.map((machine) => machine.name));
|
||||
|
||||
const requiredNames = new Set<string>();
|
||||
for (const combo of DEFAULT_COMBOS) {
|
||||
requiredNames.add(buildDefaultMachineName("washer", combo));
|
||||
requiredNames.add(buildDefaultMachineName("dryer", combo));
|
||||
}
|
||||
const hasAllDefaultCombos = Array.from(requiredNames).every((name) => usedNames.has(name));
|
||||
if (hasAllDefaultCombos) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usedRelayChannels = new Set(existingMachines.map((machine) => machine.relayChannel));
|
||||
let nextRelayChannel = 0;
|
||||
|
||||
function reserveRelayChannel() {
|
||||
while (usedRelayChannels.has(nextRelayChannel)) {
|
||||
nextRelayChannel += 1;
|
||||
}
|
||||
const value = nextRelayChannel;
|
||||
usedRelayChannels.add(value);
|
||||
nextRelayChannel += 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
const toCreate: Array<{
|
||||
name: string;
|
||||
type: "washer" | "dryer";
|
||||
relayChannel: number;
|
||||
defaultPriceCents: number;
|
||||
defaultDurationMinutes: number;
|
||||
}> = [];
|
||||
|
||||
for (const combo of DEFAULT_COMBOS) {
|
||||
const washerName = buildDefaultMachineName("washer", combo);
|
||||
if (!usedNames.has(washerName)) {
|
||||
usedNames.add(washerName);
|
||||
toCreate.push({
|
||||
name: washerName,
|
||||
type: "washer",
|
||||
relayChannel: reserveRelayChannel(),
|
||||
defaultPriceCents: 8000,
|
||||
defaultDurationMinutes: 35
|
||||
});
|
||||
}
|
||||
|
||||
const dryerName = buildDefaultMachineName("dryer", combo);
|
||||
if (!usedNames.has(dryerName)) {
|
||||
usedNames.add(dryerName);
|
||||
toCreate.push({
|
||||
name: dryerName,
|
||||
type: "dryer",
|
||||
relayChannel: reserveRelayChannel(),
|
||||
defaultPriceCents: 6000,
|
||||
defaultDurationMinutes: 45
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
await prisma.machine.createMany({ data: toCreate });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDashboardMachines(): Promise<DashboardMachine[]> {
|
||||
await ensureDefaultMachineCombos();
|
||||
|
||||
const machines = await prisma.machine.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { relayChannel: "asc" },
|
||||
include: {
|
||||
transactions: {
|
||||
where: {
|
||||
status: {
|
||||
in: [TRANSACTION_STATUS.running, TRANSACTION_STATUS.pendingRelay]
|
||||
}
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 1,
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return machines.map((machine) => {
|
||||
const runningTransaction = machine.transactions.at(0);
|
||||
const machineType = machine.type === "dryer" ? "dryer" : "washer";
|
||||
const status = machine.outOfService
|
||||
? "out_of_service"
|
||||
: runningTransaction
|
||||
? "running"
|
||||
: "available";
|
||||
|
||||
return {
|
||||
id: machine.id,
|
||||
name: machine.name,
|
||||
type: machineType,
|
||||
relayChannel: machine.relayChannel,
|
||||
defaultPriceCents: machine.defaultPriceCents,
|
||||
defaultDurationMinutes: machine.defaultDurationMinutes,
|
||||
status,
|
||||
transaction: runningTransaction
|
||||
? {
|
||||
id: runningTransaction.id,
|
||||
ticketNumber: runningTransaction.ticketNumber,
|
||||
customerId: runningTransaction.customerId,
|
||||
customerName: `${runningTransaction.customer.firstName} ${runningTransaction.customer.lastName}`.trim(),
|
||||
baseAmountCents: runningTransaction.baseAmountCents,
|
||||
discountCents: runningTransaction.discountCents,
|
||||
loyaltyDiscountApplied: runningTransaction.loyaltyDiscountApplied,
|
||||
addonDetergentQty: runningTransaction.addonDetergentQty,
|
||||
addonSoftenerQty: runningTransaction.addonSoftenerQty,
|
||||
addonBleachQty: runningTransaction.addonBleachQty,
|
||||
addonAmountCents: runningTransaction.addonAmountCents,
|
||||
serviceType:
|
||||
runningTransaction.serviceType === "encargo"
|
||||
? "encargo"
|
||||
: runningTransaction.serviceType === "xl"
|
||||
? "xl"
|
||||
: "autoservicio",
|
||||
amountCents: runningTransaction.amountCents,
|
||||
paymentMethod:
|
||||
runningTransaction.paymentMethod === "card"
|
||||
? "card"
|
||||
: runningTransaction.paymentMethod === "transfer"
|
||||
? "transfer"
|
||||
: "cash",
|
||||
startedAt: runningTransaction.startedAt.toISOString(),
|
||||
expectedEndAt: runningTransaction.expectedEndAt.toISOString(),
|
||||
employeeId: runningTransaction.employeeId
|
||||
}
|
||||
: null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateMachineConfig(
|
||||
machineId: string,
|
||||
input: Partial<{
|
||||
name: string;
|
||||
relayChannel: number;
|
||||
defaultPriceCents: number;
|
||||
defaultDurationMinutes: number;
|
||||
outOfService: boolean;
|
||||
isActive: boolean;
|
||||
}>
|
||||
) {
|
||||
return prisma.machine.update({
|
||||
where: { id: machineId },
|
||||
data: input
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateAllMachineDefaults(input: {
|
||||
defaultPriceCents?: number;
|
||||
defaultDurationMinutes?: number;
|
||||
}) {
|
||||
if (input.defaultPriceCents === undefined && input.defaultDurationMinutes === undefined) {
|
||||
return { count: 0 };
|
||||
}
|
||||
return prisma.machine.updateMany({
|
||||
where: { isActive: true },
|
||||
data: input
|
||||
});
|
||||
}
|
||||
58
src/server/services/recoveryService.ts
Normal file
58
src/server/services/recoveryService.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { TRANSACTION_STATUS } from "@/server/domain/constants";
|
||||
import { relayManager } from "@/server/relay/relayManager";
|
||||
import { ensureDefaultMachineCombos } from "@/server/services/machineService";
|
||||
import { timerService } from "@/server/services/timerService";
|
||||
|
||||
class RecoveryService {
|
||||
private restored = false;
|
||||
|
||||
async restoreOnBoot() {
|
||||
if (this.restored) {
|
||||
return;
|
||||
}
|
||||
this.restored = true;
|
||||
|
||||
await relayManager.init();
|
||||
await ensureDefaultMachineCombos();
|
||||
await timerService.bootstrap();
|
||||
|
||||
const now = new Date();
|
||||
const running = await prisma.transaction.findMany({
|
||||
where: {
|
||||
status: TRANSACTION_STATUS.running
|
||||
},
|
||||
include: { machine: true }
|
||||
});
|
||||
|
||||
for (const transaction of running) {
|
||||
if (transaction.expectedEndAt <= now) {
|
||||
await timerService.expireTransaction(transaction.id, "recovery");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await relayManager.turnOn(transaction.machine.relayChannel);
|
||||
} catch (error) {
|
||||
logger.warn("No se pudo reactivar relay en recuperacion", {
|
||||
transactionId: transaction.id,
|
||||
error: String(error)
|
||||
});
|
||||
}
|
||||
timerService.scheduleExpiry(transaction.id, transaction.expectedEndAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var recoveryServiceGlobal: RecoveryService | undefined;
|
||||
}
|
||||
|
||||
export const recoveryService = global.recoveryServiceGlobal ?? new RecoveryService();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
global.recoveryServiceGlobal = recoveryService;
|
||||
}
|
||||
142
src/server/services/reportService.ts
Normal file
142
src/server/services/reportService.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { clampRange, minutesBetween } from "@/lib/time";
|
||||
import { TRANSACTION_STATUS } from "@/server/domain/constants";
|
||||
import { calculateUtilizationPct } from "@/server/services/calculations";
|
||||
|
||||
export type ReportRange = {
|
||||
from: Date;
|
||||
to: Date;
|
||||
};
|
||||
|
||||
export async function getReportSummary(range: ReportRange) {
|
||||
const transactions = await prisma.transaction.findMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: range.from,
|
||||
lte: range.to
|
||||
},
|
||||
status: {
|
||||
not: TRANSACTION_STATUS.voided
|
||||
}
|
||||
},
|
||||
include: {
|
||||
machine: {
|
||||
select: { id: true, name: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const totalRevenueCents = transactions.reduce((sum, tx) => sum + tx.amountCents, 0);
|
||||
const transactionCount = transactions.length;
|
||||
const avgTicketCents = transactionCount > 0 ? Math.round(totalRevenueCents / transactionCount) : 0;
|
||||
|
||||
const paymentMap = new Map<string, { amountCents: number; count: number }>();
|
||||
const machineMap = new Map<string, { machineId: string; machineName: string; amountCents: number; count: number }>();
|
||||
|
||||
for (const tx of transactions) {
|
||||
const paymentEntry = paymentMap.get(tx.paymentMethod) ?? { amountCents: 0, count: 0 };
|
||||
paymentEntry.amountCents += tx.amountCents;
|
||||
paymentEntry.count += 1;
|
||||
paymentMap.set(tx.paymentMethod, paymentEntry);
|
||||
|
||||
const machineEntry = machineMap.get(tx.machineId) ?? {
|
||||
machineId: tx.machineId,
|
||||
machineName: tx.machine.name,
|
||||
amountCents: 0,
|
||||
count: 0
|
||||
};
|
||||
machineEntry.amountCents += tx.amountCents;
|
||||
machineEntry.count += 1;
|
||||
machineMap.set(tx.machineId, machineEntry);
|
||||
}
|
||||
|
||||
return {
|
||||
range,
|
||||
totals: {
|
||||
totalRevenueCents,
|
||||
transactionCount,
|
||||
avgTicketCents
|
||||
},
|
||||
byPaymentMethod: Array.from(paymentMap.entries()).map(([paymentMethod, value]) => ({
|
||||
paymentMethod,
|
||||
...value
|
||||
})),
|
||||
byMachine: Array.from(machineMap.values()).sort((a, b) => b.amountCents - a.amountCents)
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUtilizationReport(range: ReportRange) {
|
||||
const machines = await prisma.machine.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: { relayChannel: "asc" }
|
||||
});
|
||||
const transactions = await prisma.transaction.findMany({
|
||||
where: {
|
||||
status: {
|
||||
in: [TRANSACTION_STATUS.running, TRANSACTION_STATUS.completed]
|
||||
},
|
||||
startedAt: {
|
||||
lte: range.to
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
endedAt: {
|
||||
gte: range.from
|
||||
}
|
||||
},
|
||||
{
|
||||
endedAt: null,
|
||||
expectedEndAt: {
|
||||
gte: range.from
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const totalWindowMinutes = minutesBetween(range.from, range.to);
|
||||
const usageByMachine = new Map<string, number>();
|
||||
|
||||
for (const tx of transactions) {
|
||||
const txEnd = tx.endedAt ?? tx.expectedEndAt;
|
||||
const overlap = clampRange(tx.startedAt, txEnd, range.from, range.to);
|
||||
if (!overlap) {
|
||||
continue;
|
||||
}
|
||||
const minutes = minutesBetween(overlap.start, overlap.end);
|
||||
usageByMachine.set(tx.machineId, (usageByMachine.get(tx.machineId) ?? 0) + minutes);
|
||||
}
|
||||
|
||||
return machines.map((machine) => {
|
||||
const usedMinutes = usageByMachine.get(machine.id) ?? 0;
|
||||
const utilizationPct = calculateUtilizationPct(usedMinutes, totalWindowMinutes);
|
||||
return {
|
||||
machineId: machine.id,
|
||||
machineName: machine.name,
|
||||
usedMinutes: Number(usedMinutes.toFixed(2)),
|
||||
totalWindowMinutes: Number(totalWindowMinutes.toFixed(2)),
|
||||
utilizationPct
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getReportCsv(range: ReportRange) {
|
||||
const summary = await getReportSummary(range);
|
||||
const lines = [
|
||||
"Tipo,Clave,Valor1,Valor2",
|
||||
`totales,totalRevenueCents,${summary.totals.totalRevenueCents},`,
|
||||
`totales,transactionCount,${summary.totals.transactionCount},`,
|
||||
`totales,avgTicketCents,${summary.totals.avgTicketCents},`
|
||||
];
|
||||
|
||||
for (const row of summary.byPaymentMethod) {
|
||||
lines.push(`payment,${row.paymentMethod},${row.amountCents},${row.count}`);
|
||||
}
|
||||
for (const row of summary.byMachine) {
|
||||
lines.push(`machine,${row.machineName},${row.amountCents},${row.count}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
171
src/server/services/shiftService.ts
Normal file
171
src/server/services/shiftService.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { CASH_MOVEMENT_TYPE, PAYMENT_METHODS, TRANSACTION_STATUS, type CashMovementTypeValue } from "@/server/domain/constants";
|
||||
import { calculateExpectedCash } from "@/server/services/calculations";
|
||||
|
||||
export async function getActiveShift() {
|
||||
return prisma.shift.findFirst({
|
||||
where: { endTime: null },
|
||||
include: {
|
||||
employee: true,
|
||||
cashMovements: {
|
||||
orderBy: { createdAt: "desc" }
|
||||
}
|
||||
},
|
||||
orderBy: { startTime: "desc" }
|
||||
});
|
||||
}
|
||||
|
||||
export async function openShift(input: { employeeId: string; startingCashCents: number }) {
|
||||
const existing = await getActiveShift();
|
||||
if (existing) {
|
||||
throw new Error("Ya hay un turno abierto");
|
||||
}
|
||||
return prisma.shift.create({
|
||||
data: {
|
||||
employeeId: input.employeeId,
|
||||
startingCashCents: input.startingCashCents
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function addCashMovement(input: {
|
||||
employeeId: string;
|
||||
shiftId: string;
|
||||
type: CashMovementTypeValue;
|
||||
amountCents: number;
|
||||
reason: string;
|
||||
}) {
|
||||
return prisma.cashMovement.create({
|
||||
data: input
|
||||
});
|
||||
}
|
||||
|
||||
export async function calculateExpectedCashCents(shiftId: string) {
|
||||
const shift = await prisma.shift.findUnique({
|
||||
where: { id: shiftId }
|
||||
});
|
||||
if (!shift) {
|
||||
throw new Error("Turno no encontrado");
|
||||
}
|
||||
|
||||
const rangeEnd = shift.endTime ?? new Date();
|
||||
const rangeStart = shift.startTime;
|
||||
|
||||
const cashSales = await prisma.transaction.aggregate({
|
||||
_sum: { amountCents: true },
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: rangeStart,
|
||||
lte: rangeEnd
|
||||
},
|
||||
paymentMethod: PAYMENT_METHODS.cash,
|
||||
status: {
|
||||
not: TRANSACTION_STATUS.voided
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const movements = await prisma.cashMovement.groupBy({
|
||||
by: ["type"],
|
||||
_sum: { amountCents: true },
|
||||
where: {
|
||||
shiftId
|
||||
}
|
||||
});
|
||||
|
||||
const deposits = movements.find((item) => item.type === CASH_MOVEMENT_TYPE.deposit)?._sum.amountCents ?? 0;
|
||||
const withdrawals = movements.find((item) => item.type === CASH_MOVEMENT_TYPE.withdrawal)?._sum.amountCents ?? 0;
|
||||
const sales = cashSales._sum.amountCents ?? 0;
|
||||
|
||||
return calculateExpectedCash({
|
||||
startingCashCents: shift.startingCashCents,
|
||||
cashSalesCents: sales,
|
||||
depositsCents: deposits,
|
||||
withdrawalsCents: withdrawals
|
||||
});
|
||||
}
|
||||
|
||||
export async function closeShift(input: { shiftId: string; actualCashCents: number; notes?: string }) {
|
||||
const shift = await prisma.shift.findUnique({
|
||||
where: { id: input.shiftId }
|
||||
});
|
||||
if (!shift || shift.endTime) {
|
||||
throw new Error("Turno no valido para cierre");
|
||||
}
|
||||
const expected = await calculateExpectedCashCents(input.shiftId);
|
||||
const difference = input.actualCashCents - expected;
|
||||
|
||||
return prisma.shift.update({
|
||||
where: { id: input.shiftId },
|
||||
data: {
|
||||
endTime: new Date(),
|
||||
expectedCashCents: expected,
|
||||
actualCashCents: input.actualCashCents,
|
||||
differenceCashCents: difference,
|
||||
notes: input.notes
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function getShiftSummary(shiftId: string) {
|
||||
const shift = await prisma.shift.findUnique({
|
||||
where: { id: shiftId },
|
||||
include: {
|
||||
cashMovements: true,
|
||||
employee: true
|
||||
}
|
||||
});
|
||||
if (!shift) {
|
||||
throw new Error("Turno no encontrado");
|
||||
}
|
||||
|
||||
const end = shift.endTime ?? new Date();
|
||||
const txStats = await prisma.transaction.groupBy({
|
||||
by: ["paymentMethod"],
|
||||
where: {
|
||||
createdAt: {
|
||||
gte: shift.startTime,
|
||||
lte: end
|
||||
},
|
||||
status: {
|
||||
not: TRANSACTION_STATUS.voided
|
||||
}
|
||||
},
|
||||
_sum: { amountCents: true },
|
||||
_count: { _all: true }
|
||||
});
|
||||
|
||||
const totalSalesCents = txStats.reduce((sum, row) => sum + (row._sum.amountCents ?? 0), 0);
|
||||
const expectedCashCents = await calculateExpectedCashCents(shift.id);
|
||||
|
||||
return {
|
||||
shift,
|
||||
totals: {
|
||||
totalSalesCents,
|
||||
expectedCashCents,
|
||||
transactionCount: txStats.reduce((sum, row) => sum + row._count._all, 0),
|
||||
byPaymentMethod: txStats.map((row) => ({
|
||||
paymentMethod: row.paymentMethod,
|
||||
amountCents: row._sum.amountCents ?? 0,
|
||||
count: row._count._all
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function getShiftHistory(range: { from: Date; to: Date }) {
|
||||
return prisma.shift.findMany({
|
||||
where: {
|
||||
startTime: {
|
||||
gte: range.from,
|
||||
lte: range.to
|
||||
}
|
||||
},
|
||||
include: {
|
||||
employee: true
|
||||
},
|
||||
orderBy: { startTime: "desc" }
|
||||
});
|
||||
}
|
||||
150
src/server/services/timerService.ts
Normal file
150
src/server/services/timerService.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { TRANSACTION_STATUS } from "@/server/domain/constants";
|
||||
import { relayManager } from "@/server/relay/relayManager";
|
||||
|
||||
type JobMap = Map<string, NodeJS.Timeout>;
|
||||
|
||||
class TimerService {
|
||||
private jobs: JobMap = new Map();
|
||||
private lock = new Set<string>();
|
||||
private bootstrapped = false;
|
||||
private sweepInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
async bootstrap() {
|
||||
if (this.bootstrapped) {
|
||||
return;
|
||||
}
|
||||
this.bootstrapped = true;
|
||||
|
||||
const running = await prisma.transaction.findMany({
|
||||
where: {
|
||||
status: TRANSACTION_STATUS.running
|
||||
}
|
||||
});
|
||||
|
||||
for (const transaction of running) {
|
||||
this.scheduleExpiry(transaction.id, transaction.expectedEndAt);
|
||||
}
|
||||
|
||||
this.sweepInterval = setInterval(() => {
|
||||
this.sweepDueTransactions().catch((error) => {
|
||||
logger.error("Error en barrido de timers", { error: String(error) });
|
||||
});
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
scheduleExpiry(transactionId: string, expectedEndAt: Date) {
|
||||
const existing = this.jobs.get(transactionId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
}
|
||||
const delayMs = Math.max(0, expectedEndAt.getTime() - Date.now());
|
||||
const timeout = setTimeout(() => {
|
||||
this.expireTransaction(transactionId, "scheduled").catch((error) =>
|
||||
logger.error("Error en expiracion programada", { error: String(error), transactionId })
|
||||
);
|
||||
}, delayMs);
|
||||
this.jobs.set(transactionId, timeout);
|
||||
}
|
||||
|
||||
unschedule(transactionId: string) {
|
||||
const existing = this.jobs.get(transactionId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
this.jobs.delete(transactionId);
|
||||
}
|
||||
}
|
||||
|
||||
async sweepDueTransactions() {
|
||||
const now = new Date();
|
||||
const due = await prisma.transaction.findMany({
|
||||
where: {
|
||||
status: TRANSACTION_STATUS.running,
|
||||
expectedEndAt: {
|
||||
lte: now
|
||||
}
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
for (const row of due) {
|
||||
await this.expireTransaction(row.id, "sweep");
|
||||
}
|
||||
}
|
||||
|
||||
async expireTransaction(transactionId: string, source: "scheduled" | "sweep" | "recovery") {
|
||||
if (this.lock.has(transactionId)) {
|
||||
return;
|
||||
}
|
||||
this.lock.add(transactionId);
|
||||
|
||||
try {
|
||||
const tx = await prisma.transaction.findUnique({
|
||||
where: { id: transactionId },
|
||||
include: { machine: true }
|
||||
});
|
||||
|
||||
if (!tx) {
|
||||
this.unschedule(transactionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tx.status !== TRANSACTION_STATUS.running) {
|
||||
this.unschedule(transactionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (tx.expectedEndAt > new Date() && source !== "recovery") {
|
||||
this.scheduleExpiry(tx.id, tx.expectedEndAt);
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.transaction.update({
|
||||
where: { id: tx.id },
|
||||
data: {
|
||||
relayOffAttemptedAt: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
await relayManager.turnOff(tx.machine.relayChannel);
|
||||
|
||||
await prisma.transaction.update({
|
||||
where: { id: tx.id },
|
||||
data: {
|
||||
status: TRANSACTION_STATUS.completed,
|
||||
endedAt: new Date(),
|
||||
relayTurnedOffAt: new Date(),
|
||||
relayFailureReason: null
|
||||
}
|
||||
});
|
||||
this.unschedule(tx.id);
|
||||
logger.info("Transaccion completada por expiracion", { transactionId: tx.id });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await prisma.transaction.update({
|
||||
where: { id: transactionId },
|
||||
data: {
|
||||
relayFailureReason: `OFF_FAIL: ${message}`
|
||||
}
|
||||
});
|
||||
this.scheduleExpiry(transactionId, new Date(Date.now() + 30_000));
|
||||
logger.error("Fallo apagado de relay en expiracion", { transactionId, message });
|
||||
} finally {
|
||||
this.lock.delete(transactionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var timerServiceGlobal: TimerService | undefined;
|
||||
}
|
||||
|
||||
export const timerService = global.timerServiceGlobal ?? new TimerService();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
global.timerServiceGlobal = timerService;
|
||||
}
|
||||
15
src/server/system/bootstrap.ts
Normal file
15
src/server/system/bootstrap.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import "server-only";
|
||||
|
||||
import { recoveryService } from "@/server/services/recoveryService";
|
||||
|
||||
let bootPromise: Promise<void> | null = null;
|
||||
|
||||
export function ensureSystemBootstrapped() {
|
||||
if (!bootPromise) {
|
||||
bootPromise = recoveryService.restoreOnBoot().catch((error) => {
|
||||
bootPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return bootPromise;
|
||||
}
|
||||
25
tailwind.config.ts
Normal file
25
tailwind.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./src/app/**/*.{ts,tsx}",
|
||||
"./src/components/**/*.{ts,tsx}",
|
||||
"./src/lib/**/*.{ts,tsx}"
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
surface: "#f8f6ee",
|
||||
panel: "#ffffff",
|
||||
accent: "#0f766e",
|
||||
available: "#166534",
|
||||
running: "#1d4ed8",
|
||||
danger: "#b91c1c",
|
||||
warning: "#a16207"
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
|
||||
export default config;
|
||||
7
tests/e2e/smoke.spec.ts
Normal file
7
tests/e2e/smoke.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("login screen is visible", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("La Burbuja POS")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Entrar" })).toBeVisible();
|
||||
});
|
||||
51
tests/unit/calculations.test.ts
Normal file
51
tests/unit/calculations.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { calculateAddonTotalCents, calculateExpectedCash, calculateLoyaltyDiscountCents, calculateUtilizationPct } from "@/server/services/calculations";
|
||||
|
||||
describe("calculateExpectedCash", () => {
|
||||
it("computes shift expected cash with deposits and withdrawals", () => {
|
||||
const value = calculateExpectedCash({
|
||||
startingCashCents: 10_000,
|
||||
cashSalesCents: 24_500,
|
||||
depositsCents: 2_000,
|
||||
withdrawalsCents: 1_500
|
||||
});
|
||||
expect(value).toBe(35_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateUtilizationPct", () => {
|
||||
it("returns rounded percentage", () => {
|
||||
expect(calculateUtilizationPct(43, 120)).toBe(35.83);
|
||||
});
|
||||
|
||||
it("guards zero window", () => {
|
||||
expect(calculateUtilizationPct(10, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateLoyaltyDiscountCents", () => {
|
||||
it("computes rounded discount by percentage", () => {
|
||||
expect(calculateLoyaltyDiscountCents(4_500, 50)).toBe(2_250);
|
||||
});
|
||||
|
||||
it("caps percentage and guards invalid values", () => {
|
||||
expect(calculateLoyaltyDiscountCents(3_000, 150)).toBe(3_000);
|
||||
expect(calculateLoyaltyDiscountCents(3_000, -20)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateAddonTotalCents", () => {
|
||||
it("sums addon quantities by configured prices", () => {
|
||||
expect(
|
||||
calculateAddonTotalCents({
|
||||
detergentQty: 2,
|
||||
softenerQty: 1,
|
||||
bleachQty: 3,
|
||||
detergentAddonCents: 500,
|
||||
softenerAddonCents: 500,
|
||||
bleachAddonCents: 500
|
||||
})
|
||||
).toBe(3_000);
|
||||
});
|
||||
});
|
||||
23
tests/unit/dateRange.test.ts
Normal file
23
tests/unit/dateRange.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseDateRange } from "@/server/api/dateRange";
|
||||
|
||||
describe("parseDateRange", () => {
|
||||
it("parses explicit from/to", () => {
|
||||
const params = new URLSearchParams({
|
||||
from: "2026-04-05T10:00:00.000Z",
|
||||
to: "2026-04-05T18:00:00.000Z"
|
||||
});
|
||||
const range = parseDateRange(params);
|
||||
expect(range.from.toISOString()).toBe("2026-04-05T10:00:00.000Z");
|
||||
expect(range.to.toISOString()).toBe("2026-04-05T18:00:00.000Z");
|
||||
});
|
||||
|
||||
it("throws on inverted range", () => {
|
||||
const params = new URLSearchParams({
|
||||
from: "2026-04-05T18:00:00.000Z",
|
||||
to: "2026-04-05T10:00:00.000Z"
|
||||
});
|
||||
expect(() => parseDateRange(params)).toThrow("Rango de fechas invalido");
|
||||
});
|
||||
});
|
||||
23
tests/unit/time.test.ts
Normal file
23
tests/unit/time.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { addMinutes, clampRange, minutesBetween } from "@/lib/time";
|
||||
|
||||
describe("time helpers", () => {
|
||||
it("adds minutes correctly", () => {
|
||||
const base = new Date("2026-04-06T10:00:00.000Z");
|
||||
const result = addMinutes(base, 35);
|
||||
expect(result.toISOString()).toBe("2026-04-06T10:35:00.000Z");
|
||||
});
|
||||
|
||||
it("returns overlap range", () => {
|
||||
const overlap = clampRange(
|
||||
new Date("2026-04-06T10:00:00.000Z"),
|
||||
new Date("2026-04-06T11:00:00.000Z"),
|
||||
new Date("2026-04-06T10:30:00.000Z"),
|
||||
new Date("2026-04-06T12:00:00.000Z")
|
||||
);
|
||||
expect(overlap?.start.toISOString()).toBe("2026-04-06T10:30:00.000Z");
|
||||
expect(overlap?.end.toISOString()).toBe("2026-04-06T11:00:00.000Z");
|
||||
expect(minutesBetween(overlap!.start, overlap!.end)).toBe(30);
|
||||
});
|
||||
});
|
||||
44
tsconfig.json
Normal file
44
tsconfig.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"es2022"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"types": [
|
||||
"node",
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
14
vitest.config.ts
Normal file
14
vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src")
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"]
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user