Files
AgendaPro/server/routes/appointments.ts
T
AgendaPro Dev d796538fd9 feat: rebrand AgendaPro -> AgendaMax + calendar fixes + current-week demo seed
- Rebrand all user-facing text to AgendaMax
- Fix sidebar scrolling: card container no longer overflows html (flex h-full min-h-0)
- Fix calendar toolbar hover: active buttons keep readable contrast
- Sticky calendar toolbar (month/week/day always visible on scroll)
- Seed guarantees 2-4 appointments per day for current week (Mon-Sat)
2026-07-27 10:11:16 -06:00

322 lines
13 KiB
TypeScript

import { Router } from "express";
import { db } from "../db.ts";
import type { AuthedRequest } from "../lib/auth.ts";
import { err } from "../lib/auth.ts";
import { autoAssign, isAvailable, runInTransaction, parseWorkingHours } from "../lib/scheduling.ts";
export const appointmentsRouter = Router();
/** Compute commission for a ticket: service rate, fallback employee rate. */
function computeCommission(businessId: number, serviceId: number, employeeId: number, amount: number): number {
const svc = db.prepare(`SELECT commission_pct p FROM services WHERE id = ? AND business_id = ?`).get(serviceId, businessId) as { p: number } | undefined;
let pct = svc?.p ?? 0;
if (!pct) {
const emp = db.prepare(`SELECT commission_pct p FROM employees WHERE id = ? AND business_id = ?`).get(employeeId, businessId) as { p: number } | undefined;
pct = emp?.p ?? 0;
}
return Math.round(((amount * pct) / 100) * 100) / 100;
}
function joinAppointment(a: any) {
if (!a) return a;
a.service = db
.prepare(`SELECT id, name, category, color, duration_min, price FROM services WHERE id = ?`)
.get(a.service_id);
a.employee = db
.prepare(`SELECT id, name, color, role FROM employees WHERE id = ?`)
.get(a.employee_id);
a.client = db
.prepare(
`SELECT c.id, c.name, c.phone, c.email, c.notes, c.tags,
(SELECT COUNT(*) FROM appointments a2 WHERE a2.client_id = c.id AND a2.status = 'no_show') AS no_show_count
FROM clients c WHERE c.id = ?`
)
.get(a.client_id);
return a;
}
appointmentsRouter.get("/", (req: AuthedRequest, res) => {
const { from, to, employee_id, client_id, status, limit } = req.query;
const conds: string[] = ["business_id = ?"];
const args: any[] = [req.user!.business_id];
if (from) {
conds.push("start_at >= ?");
args.push(String(from));
}
if (to) {
conds.push("start_at <= ?");
args.push(String(to));
}
if (employee_id) {
conds.push("employee_id = ?");
args.push(Number(employee_id));
}
if (client_id) {
conds.push("client_id = ?");
args.push(Number(client_id));
}
if (status) {
conds.push("status = ?");
args.push(String(status));
}
// Employees only see their own appointments (still can see all to plan, but filter by default)
// We let them see all so they can coordinate; UI filters by default.
let sql = `SELECT * FROM appointments WHERE ${conds.join(" AND ")} ORDER BY start_at`;
if (limit) sql += ` LIMIT ${Number(limit)}`;
const rows = db.prepare(sql).all(...args) as any[];
rows.forEach(joinAppointment);
res.json({ appointments: rows });
});
appointmentsRouter.get("/:id", (req: AuthedRequest, res) => {
const a = db
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
.get(Number(req.params.id), req.user!.business_id) as any;
if (!a) return err(res, 404, "Cita no encontrada");
joinAppointment(a);
res.json({ appointment: a });
});
appointmentsRouter.post("/", (req: AuthedRequest, res) => {
const {
service_id,
employee_id,
client_id,
new_client,
start_at,
duration_min,
price_override,
notes,
status,
} = req.body ?? {};
if (!service_id) return err(res, 400, "Selecciona un servicio");
if (!start_at) return err(res, 400, "Selecciona una fecha y hora");
const service = db
.prepare(`SELECT * FROM services WHERE id = ? AND business_id = ?`)
.get(Number(service_id), req.user!.business_id) as any;
if (!service) return err(res, 400, "Servicio no válido");
// Resolve client: existing id, or create a new one from new_client payload
let clientId = client_id ? Number(client_id) : null;
if (!clientId && new_client) {
if (!new_client.name) return err(res, 400, "El nombre del cliente es obligatorio");
const created = db
.prepare(
`INSERT INTO clients (business_id, name, email, phone, notes, tags) VALUES (?, ?, ?, ?, ?, ?) RETURNING id`
)
.get(
req.user!.business_id,
new_client.name,
new_client.email || null,
new_client.phone || null,
new_client.notes || null,
new_client.tags || null
) as { id: number };
clientId = created.id;
}
if (!clientId) return err(res, 400, "Selecciona o crea un cliente");
// Resolve employee: auto-assign to self for employees; default to first eligible for owner
const start = new Date(start_at);
if (isNaN(start.getTime())) return err(res, 400, "Fecha de inicio inválida");
const dur = Number(duration_min) || service.duration_min;
const end = new Date(start.getTime() + dur * 60000);
const endIso = end.toISOString().replace(/\.\d{3}Z$/, "Z");
const startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z");
const startMs = start.getTime();
const endMs = end.getTime();
const finalStatus = status || "scheduled";
const price = price_override !== undefined ? Number(price_override) : service.price;
try {
const r = runInTransaction(db, () => {
// Resolver empleado (igual que antes, pero usando autoAssign si ninguno)
let empId = employee_id ? Number(employee_id) : null;
if (!empId && req.user!.role === "employee" && req.user!.employee_id) {
empId = req.user!.employee_id;
}
if (!empId) {
const bizRow = db.prepare(`SELECT working_hours wh FROM businesses WHERE id = ?`).get(req.user!.business_id) as any;
const aa = autoAssign(db, {
businessId: Number(req.user!.business_id), serviceId: service.id, serviceName: service.name, serviceCategory: service.category,
startMs, endMs, bizWh: parseWorkingHours(bizRow?.wh) as any,
});
empId = aa?.employeeId ?? null;
}
if (!empId) throw { status: 400, error: "No hay empleado asignado a este servicio" };
// Guard anti doble reserva
if (!isAvailable(db, empId, startMs, endMs)) throw { status: 409, error: "Ese horario ya está ocupado para el especialista" };
const inserted = db
.prepare(
`INSERT INTO appointments
(business_id, service_id, employee_id, client_id, start_at, end_at, status, price, notes, created_by_user_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`
)
.get(req.user!.business_id, Number(service_id), empId, clientId, startIso, endIso, finalStatus, price, notes || null, req.user!.id) as any;
if (finalStatus === "completed") {
db.prepare(
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method)
VALUES (?, ?, ?, ?, ?, ?, 0, 'card')`
).run(req.user!.business_id, inserted.id, clientId, empId, Number(service_id), price);
}
joinAppointment(inserted);
return inserted;
});
res.status(201).json({ appointment: r });
} catch (e: any) {
if (e && typeof e === "object" && e.status) return err(res, e.status, e.error);
throw e;
}
});
appointmentsRouter.patch("/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id) as any;
if (!existing) return err(res, 404, "Cita no encontrada");
if (req.user!.role === "employee" && existing.employee_id !== req.user!.employee_id) {
return err(res, 403, "No puedes modificar una cita que no es tuya");
}
const {
service_id,
employee_id,
client_id,
start_at,
end_at,
duration_min,
status,
price,
notes,
} = req.body ?? {};
let startIso = existing.start_at;
let endIso = existing.end_at;
if (start_at) {
const start = new Date(start_at);
if (isNaN(start.getTime())) return err(res, 400, "Fecha inválida");
startIso = start.toISOString().replace(/\.\d{3}Z$/, "Z");
const svc = service_id
? db.prepare(`SELECT duration_min FROM services WHERE id = ?`).get(Number(service_id)) as any
: { duration_min: null };
const dur = Number(duration_min) || svc?.duration_min || null;
if (end_at) {
endIso = new Date(end_at).toISOString().replace(/\.\d{3}Z$/, "Z");
} else if (dur) {
endIso = new Date(start.getTime() + dur * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
}
} else if (duration_min) {
const start = new Date(existing.start_at);
endIso = new Date(start.getTime() + Number(duration_min) * 60000).toISOString().replace(/\.\d{3}Z$/, "Z");
}
const finalStatus = status ?? existing.status;
const finalPrice = price !== undefined ? Number(price) : existing.price;
db.prepare(
`UPDATE appointments
SET service_id = ?, employee_id = ?, client_id = ?, start_at = ?, end_at = ?, status = ?, price = ?, notes = ?, updated_at = datetime('now')
WHERE id = ?`
).run(
service_id !== undefined ? Number(service_id) : existing.service_id,
employee_id !== undefined ? Number(employee_id) : existing.employee_id,
client_id !== undefined ? Number(client_id) : existing.client_id,
startIso,
endIso,
finalStatus,
finalPrice,
notes !== undefined ? notes : existing.notes,
id
);
// If transitioned to completed and no ticket yet, create one
if (finalStatus === "completed" && existing.status !== "completed") {
const hasTicket = db
.prepare(`SELECT id FROM tickets WHERE appointment_id = ?`)
.get(id);
if (!hasTicket) {
const appt = db.prepare(`SELECT * FROM appointments WHERE id = ?`).get(id) as any;
const commission = computeCommission(appt.business_id, appt.service_id, appt.employee_id, appt.price);
db.prepare(
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method, commission)
VALUES (?, ?, ?, ?, ?, ?, 0, 'card', ?)`
).run(appt.business_id, id, appt.client_id, appt.employee_id, appt.service_id, appt.price, commission);
}
}
const updated = db.prepare(`SELECT * FROM appointments WHERE id = ?`).get(id) as any;
joinAppointment(updated);
res.json({ appointment: updated });
});
appointmentsRouter.post("/:id/complete", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const appt = db
.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id) as any;
if (!appt) return err(res, 404, "Cita no encontrada");
if (req.user!.role === "employee" && appt.employee_id !== req.user!.employee_id) {
return err(res, 403, "No puedes modificar una cita que no es tuya");
}
const { tip = 0, payment_method = "card" } = req.body ?? {};
db.prepare(`UPDATE appointments SET status = 'completed', updated_at = datetime('now') WHERE id = ?`).run(id);
const hasTicket = db.prepare(`SELECT id FROM tickets WHERE appointment_id = ?`).get(id);
if (!hasTicket) {
const commission = computeCommission(appt.business_id, appt.service_id, appt.employee_id, appt.price);
db.prepare(
`INSERT INTO tickets (business_id, appointment_id, client_id, employee_id, service_id, amount, tip, payment_method, commission)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(appt.business_id, id, appt.client_id, appt.employee_id, appt.service_id, appt.price, Number(tip), payment_method, commission);
} else {
// ticket already exists (e.g. completed via PATCH) — backfill commission + tip if provided
db.prepare(`UPDATE tickets SET commission = COALESCE(NULLIF(commission,0), ?) WHERE appointment_id = ? AND commission = 0`).run(
computeCommission(appt.business_id, appt.service_id, appt.employee_id, appt.price),
id
);
}
const updated = db.prepare(`SELECT * FROM appointments WHERE id = ?`).get(id) as any;
joinAppointment(updated);
res.json({ appointment: updated });
});
appointmentsRouter.delete("/:id", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const existing = db
.prepare(`SELECT id, employee_id FROM appointments WHERE id = ? AND business_id = ?`)
.get(id, req.user!.business_id) as any;
if (!existing) return err(res, 404, "Cita no encontrada");
if (req.user!.role === "employee" && existing.employee_id !== req.user!.employee_id) {
return err(res, 403, "No puedes modificar una cita que no es tuya");
}
db.prepare(`DELETE FROM appointments WHERE id = ?`).run(id);
res.json({ ok: true });
});
/** Preview the cancellation policy outcome for an appointment (penalty based on hours left). */
appointmentsRouter.get("/:id/cancellation-policy", (req: AuthedRequest, res) => {
const id = Number(req.params.id);
const appt = db.prepare(`SELECT * FROM appointments WHERE id = ? AND business_id = ?`).get(id, req.user!.business_id) as any;
if (!appt) return err(res, 404, "Cita no encontrada");
const biz = db
.prepare(`SELECT cancel_window_hours wh, cancel_penalty_pct pen, require_deposit rd, deposit_pct dp FROM businesses WHERE id = ?`)
.get(req.user!.business_id) as any;
const hoursLeft = (new Date(appt.start_at).getTime() - Date.now()) / 3600000;
const withinWindow = hoursLeft < (biz?.wh ?? 24);
const penalty = withinWindow ? Math.round(((appt.price * (biz?.pen ?? 0)) / 100) * 100) / 100 : 0;
res.json({
hours_left: Math.round(hoursLeft),
within_window: withinWindow,
window_hours: biz?.wh ?? 24,
penalty_pct: withinWindow ? biz?.pen ?? 0 : 0,
penalty_amount: penalty,
policy_active: (biz?.pen ?? 0) > 0,
});
});