first commit

This commit is contained in:
mdares
2026-04-07 08:54:41 -06:00
commit 3d1a8ba07e
92 changed files with 15392 additions and 0 deletions

1
.env.example Normal file
View File

@@ -0,0 +1 @@
DATABASE_URL="file:./dev.db"

3
.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

11
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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 112 | LG WM22VV2S6R combo (washer + dryer stacked) | Self-service | Client-facing, coin/app activated |
| Lavadora 1315 | 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 112 |
| Dry | $45 | 50 min | Lavadora 112 |
- **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 1315) 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 110 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: 4872 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 112 |
| Drop-off (ropa) | Per-kg ($33/kg, min $120) | Staff | Back room, Lavadoras 1315 |
| 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 $1525 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
View 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
View 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

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View 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
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View 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");

View File

@@ -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;

View File

@@ -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");

View File

@@ -0,0 +1 @@
ALTER TABLE "Transaction" ADD COLUMN "serviceType" TEXT NOT NULL DEFAULT 'autoservicio';

View 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
View 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
View 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
View 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

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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));
}
}

View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
import { POSDashboard } from "@/components/POSDashboard";
export default function HomePage() {
return <POSDashboard />;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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
View 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
View 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
View 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
View 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
View 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
View 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)
};

View 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
View 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");
}
};

View 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
View 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
View 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 };
}

View 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 };
}

View 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];

View 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;
}

View 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()
}
});
}

View 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;
}

View 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
);
}

View 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;
}
}

View 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
});
}

View 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;
}

View 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");
}

View 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" }
});
}

View 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;
}

View 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
View 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
View 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();
});

View 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);
});
});

View 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
View 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
View 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

File diff suppressed because one or more lines are too long

14
vitest.config.ts Normal file
View 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"]
}
});