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

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