diff --git a/artifacts/api-server/src/middlewares/auth.ts b/artifacts/api-server/src/middlewares/auth.ts index cbae1daa..5449ac33 100644 --- a/artifacts/api-server/src/middlewares/auth.ts +++ b/artifacts/api-server/src/middlewares/auth.ts @@ -253,3 +253,52 @@ export async function requireExecutiveAccess( next(); } +const PROTOCOL_READ_ROLES: ReadonlyArray = [ + "admin", + "protocol_super_admin", + "protocol_pr_manager", + "protocol_officer", + "protocol_requester", + "protocol_viewer", +]; + +/** + * Gate any read access to the Public Relations & Protocol module. Allows + * admin and any of the protocol_* roles. Finer-grained mutate / approve + * sub-roles are layered on top via local helpers in the routes file. + */ +export async function requireProtocolAccess( + req: Request, + res: Response, + next: NextFunction, +): Promise { + if (!req.session.userId) { + res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); + return; + } + const [user] = await db + .select({ isActive: usersTable.isActive }) + .from(usersTable) + .where(eq(usersTable.id, req.session.userId)); + if (!user || !user.isActive) { + res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); + return; + } + const ids = await getEffectiveRoleIds(req.session.userId); + if (ids.length === 0) { + res.status(403).json({ error: "Forbidden", code: "forbidden" }); + return; + } + const rows = await db + .select({ name: rolesTable.name }) + .from(rolesTable) + .where(inArray(rolesTable.id, ids)); + const names = new Set(rows.map((r) => r.name)); + const ok = PROTOCOL_READ_ROLES.some((r) => names.has(r)); + if (!ok) { + res.status(403).json({ error: "Forbidden", code: "forbidden" }); + return; + } + next(); +} + diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index da09bce4..271eb383 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -16,6 +16,7 @@ import groupsRouter from "./groups"; import rolesRouter from "./roles"; import auditRouter from "./audit"; import executiveMeetingsRouter from "./executive-meetings"; +import protocolRouter from "./protocol"; import pushRouter from "./push"; import systemRouter from "./system"; @@ -38,6 +39,7 @@ router.use(groupsRouter); router.use(rolesRouter); router.use(auditRouter); router.use(executiveMeetingsRouter); +router.use(protocolRouter); router.use(pushRouter); router.use(systemRouter); diff --git a/artifacts/api-server/src/routes/protocol.ts b/artifacts/api-server/src/routes/protocol.ts new file mode 100644 index 00000000..d727ac4a --- /dev/null +++ b/artifacts/api-server/src/routes/protocol.ts @@ -0,0 +1,1473 @@ +import { Router, type IRouter } from "express"; +import { and, asc, desc, eq, gte, inArray, lte, ne, sql } from "drizzle-orm"; +import { z, type ZodType } from "zod"; +import { db } from "@workspace/db"; +import { + protocolRoomsTable, + protocolRoomBookingsTable, + protocolExternalMeetingsTable, + protocolGiftsTable, + protocolGiftIssuesTable, + protocolAuditLogsTable, + rolesTable, + usersTable, + PROTOCOL_GIFT_KINDS, +} from "@workspace/db"; +import { requireProtocolAccess, getEffectiveRoleIds } from "../middlewares/auth"; +import type { Request, Response, NextFunction } from "express"; + +const router: IRouter = Router(); + +router.param("id", (req, res, next, value) => { + if (!/^\d+$/.test(String(value))) { + next("route"); + return; + } + next(); +}); + +// --------------------------------------------------------------------------- +// Role helpers +// --------------------------------------------------------------------------- +// Roles that may approve/reject bookings and gift issuances. +const APPROVE_ROLES = [ + "admin", + "protocol_super_admin", + "protocol_pr_manager", +] as const; +// Roles that may create/edit records directly (no approval needed to author). +const MUTATE_ROLES = [ + "admin", + "protocol_super_admin", + "protocol_pr_manager", + "protocol_officer", +] as const; +// Roles that may submit booking / issuance requests (created as "pending"). +const REQUEST_ROLES = [...MUTATE_ROLES, "protocol_requester"] as const; +// Roles that may manage the rooms registry. +const MANAGE_ROOMS_ROLES = ["admin", "protocol_super_admin"] as const; +// Roles that may view the audit log. +const AUDIT_ROLES = [ + "admin", + "protocol_super_admin", + "protocol_pr_manager", +] as const; + +async function getRoleNamesForUser(userId: number): Promise> { + const ids = await getEffectiveRoleIds(userId); + if (ids.length === 0) return new Set(); + const rows = await db + .select({ name: rolesTable.name }) + .from(rolesTable) + .where(inArray(rolesTable.id, ids)); + return new Set(rows.map((r) => r.name)); +} + +function makeRequireRoles(allowed: ReadonlyArray) { + return async function ( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + if (!req.session.userId) { + res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); + return; + } + // Mirror requireProtocolAccess: a deactivated user with a still-valid + // session must not be able to reach mutating/approval endpoints. + const [user] = await db + .select({ isActive: usersTable.isActive }) + .from(usersTable) + .where(eq(usersTable.id, req.session.userId)); + if (!user || !user.isActive) { + res.status(401).json({ error: "Unauthorized", code: "unauthorized" }); + return; + } + const names = await getRoleNamesForUser(req.session.userId); + if (!allowed.some((r) => names.has(r))) { + res.status(403).json({ error: "Forbidden", code: "forbidden" }); + return; + } + next(); + }; +} + +const requireRequest = makeRequireRoles(REQUEST_ROLES); +const requireMutate = makeRequireRoles(MUTATE_ROLES); +const requireApprove = makeRequireRoles(APPROVE_ROLES); +const requireManageRooms = makeRequireRoles(MANAGE_ROOMS_ROLES); +const requireAudit = makeRequireRoles(AUDIT_ROLES); + +// --------------------------------------------------------------------------- +// Zod schemas +// --------------------------------------------------------------------------- +const isoDateTime = z.string().datetime({ offset: true }); + +function parseBody( + res: Response, + schema: ZodType, + body: unknown, +): T | null { + const r = schema.safeParse(body ?? {}); + if (!r.success) { + const issue = r.error.issues[0]; + const msg = issue + ? `${issue.path.join(".") || "body"}: ${issue.message}` + : "validation"; + res.status(400).json({ error: msg, code: "validation" }); + return null; + } + return r.data; +} + +const roomCreateSchema = z.object({ + nameAr: z.string().trim().min(1).max(200), + nameEn: z.string().trim().max(200).optional().default(""), + capacity: z.number().int().positive().max(100000).nullable().optional(), + location: z.string().trim().max(300).nullable().optional(), + isActive: z.boolean().optional(), + sortOrder: z.number().int().min(0).max(100000).optional(), +}); +const roomPatchSchema = roomCreateSchema.partial().refine( + (v) => Object.keys(v).length > 0, + { message: "no fields to update" }, +); + +const bookingCreateSchema = z + .object({ + roomId: z.number().int().positive(), + title: z.string().trim().min(1).max(500), + requesterName: z.string().trim().max(300).nullable().optional(), + startsAt: isoDateTime, + endsAt: isoDateTime, + notes: z.string().trim().max(5000).nullable().optional(), + }) + .refine((v) => new Date(v.startsAt) < new Date(v.endsAt), { + message: "startsAt must be before endsAt", + path: ["endsAt"], + }); + +const bookingPatchSchema = z + .object({ + roomId: z.number().int().positive().optional(), + title: z.string().trim().min(1).max(500).optional(), + requesterName: z.string().trim().max(300).nullable().optional(), + startsAt: isoDateTime.optional(), + endsAt: isoDateTime.optional(), + notes: z.string().trim().max(5000).nullable().optional(), + }) + .refine((v) => Object.keys(v).length > 0, { message: "no fields to update" }); + +const rejectSchema = z.object({ + reason: z.string().trim().max(1000).nullable().optional(), +}); + +const externalCreateSchema = z + .object({ + title: z.string().trim().min(1).max(500), + partyName: z.string().trim().max(300).nullable().optional(), + location: z.string().trim().max(300).nullable().optional(), + startsAt: isoDateTime, + endsAt: isoDateTime.nullable().optional(), + status: z.enum(["scheduled", "completed", "cancelled"]).optional(), + notes: z.string().trim().max(5000).nullable().optional(), + }) + .refine( + (v) => !v.endsAt || new Date(v.startsAt) < new Date(v.endsAt), + { message: "startsAt must be before endsAt", path: ["endsAt"] }, + ); + +const externalPatchSchema = z + .object({ + title: z.string().trim().min(1).max(500).optional(), + partyName: z.string().trim().max(300).nullable().optional(), + location: z.string().trim().max(300).nullable().optional(), + startsAt: isoDateTime.optional(), + endsAt: isoDateTime.nullable().optional(), + status: z.enum(["scheduled", "completed", "cancelled"]).optional(), + notes: z.string().trim().max(5000).nullable().optional(), + }) + .refine((v) => Object.keys(v).length > 0, { message: "no fields to update" }); + +const giftCreateSchema = z.object({ + nameAr: z.string().trim().min(1).max(300), + nameEn: z.string().trim().max(300).optional().default(""), + kind: z.enum(PROTOCOL_GIFT_KINDS).optional(), + descriptionAr: z.string().trim().max(5000).nullable().optional(), + descriptionEn: z.string().trim().max(5000).nullable().optional(), + imageUrl: z.string().trim().max(500).nullable().optional(), + quantityInStock: z.number().int().min(0).max(1000000).optional(), + isActive: z.boolean().optional(), +}); +const giftPatchSchema = giftCreateSchema.partial().refine( + (v) => Object.keys(v).length > 0, + { message: "no fields to update" }, +); + +const issueCreateSchema = z.object({ + giftId: z.number().int().positive(), + recipientName: z.string().trim().min(1).max(300), + occasion: z.string().trim().max(300).nullable().optional(), + quantity: z.number().int().positive().max(100000).optional(), + notes: z.string().trim().max(5000).nullable().optional(), +}); + +// --------------------------------------------------------------------------- +// Audit helper +// --------------------------------------------------------------------------- +type DbExecutor = typeof db | Parameters[0]>[0]; + +async function logAudit( + executor: DbExecutor, + opts: { + action: string; + entityType: string; + entityId: number | null; + oldValue?: unknown; + newValue?: unknown; + performedBy: number | null; + }, +): Promise { + await executor.insert(protocolAuditLogsTable).values({ + action: opts.action, + entityType: opts.entityType, + entityId: opts.entityId ?? null, + oldValue: (opts.oldValue ?? null) as never, + newValue: (opts.newValue ?? null) as never, + performedBy: opts.performedBy, + }); +} + +// Overlap / conflict rule: a booking occupies [startsAt, endsAt). Two +// bookings for the same room conflict when +// newStart < existingEnd AND newEnd > existingStart +// Only "pending" and "approved" rows count as occupying the room. +const OCCUPYING_STATUSES = ["pending", "approved"] as const; +const CONFLICT_MESSAGE_AR = + "لا يمكن إتمام الحجز، توجد حجز آخر لنفس القاعة في نفس الوقت."; + +// Serialize all booking mutations for a given room by taking a row-level +// lock on the room inside the transaction. Any concurrent create / edit / +// approve for the same room blocks until this transaction commits, which +// makes the check-then-write conflict rule atomic without needing a +// btree_gist exclusion constraint (additive-only migration requirement). +async function lockRoomRow( + tx: Parameters[0]>[0], + roomId: number, +): Promise { + const rows = await tx + .select({ id: protocolRoomsTable.id }) + .from(protocolRoomsTable) + .where(eq(protocolRoomsTable.id, roomId)) + .for("update"); + return rows.length > 0; +} + +async function hasBookingConflict( + executor: DbExecutor, + opts: { + roomId: number; + startsAt: Date; + endsAt: Date; + excludeId?: number; + statuses?: ReadonlyArray; + }, +): Promise { + const statuses = opts.statuses ?? OCCUPYING_STATUSES; + const conds = [ + eq(protocolRoomBookingsTable.roomId, opts.roomId), + inArray(protocolRoomBookingsTable.status, statuses as string[]), + sql`${protocolRoomBookingsTable.startsAt} < ${opts.endsAt.toISOString()}`, + sql`${protocolRoomBookingsTable.endsAt} > ${opts.startsAt.toISOString()}`, + ]; + if (opts.excludeId !== undefined) { + conds.push(ne(protocolRoomBookingsTable.id, opts.excludeId)); + } + const rows = await executor + .select({ id: protocolRoomBookingsTable.id }) + .from(protocolRoomBookingsTable) + .where(and(...conds)) + .limit(1); + return rows.length > 0; +} + +// --------------------------------------------------------------------------- +// Capabilities +// --------------------------------------------------------------------------- +router.get( + "/protocol/me", + requireProtocolAccess, + async (req: Request, res: Response) => { + const userId = req.session.userId!; + const names = await getRoleNamesForUser(userId); + const has = (list: ReadonlyArray) => list.some((r) => names.has(r)); + res.json({ + userId, + roles: Array.from(names), + canRead: true, + canRequest: has(REQUEST_ROLES), + canMutate: has(MUTATE_ROLES), + canApprove: has(APPROVE_ROLES), + canManageRooms: has(MANAGE_ROOMS_ROLES), + canViewAudit: has(AUDIT_ROLES), + }); + }, +); + +// --------------------------------------------------------------------------- +// Rooms +// --------------------------------------------------------------------------- +router.get( + "/protocol/rooms", + requireProtocolAccess, + async (_req: Request, res: Response) => { + const rows = await db + .select() + .from(protocolRoomsTable) + .orderBy(asc(protocolRoomsTable.sortOrder), asc(protocolRoomsTable.id)); + res.json(rows); + }, +); + +router.post( + "/protocol/rooms", + requireManageRooms, + async (req: Request, res: Response) => { + const body = parseBody(res, roomCreateSchema, req.body); + if (!body) return; + const [row] = await db + .insert(protocolRoomsTable) + .values({ + nameAr: body.nameAr, + nameEn: body.nameEn ?? "", + capacity: body.capacity ?? null, + location: body.location ?? null, + isActive: body.isActive ?? true, + sortOrder: body.sortOrder ?? 0, + createdBy: req.session.userId!, + }) + .returning(); + await logAudit(db, { + action: "room.create", + entityType: "room", + entityId: row.id, + newValue: row, + performedBy: req.session.userId!, + }); + res.status(201).json(row); + }, +); + +router.patch( + "/protocol/rooms/:id", + requireManageRooms, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + const body = parseBody(res, roomPatchSchema, req.body); + if (!body) return; + const [existing] = await db + .select() + .from(protocolRoomsTable) + .where(eq(protocolRoomsTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const [row] = await db + .update(protocolRoomsTable) + .set({ + ...(body.nameAr !== undefined ? { nameAr: body.nameAr } : {}), + ...(body.nameEn !== undefined ? { nameEn: body.nameEn } : {}), + ...(body.capacity !== undefined ? { capacity: body.capacity } : {}), + ...(body.location !== undefined ? { location: body.location } : {}), + ...(body.isActive !== undefined ? { isActive: body.isActive } : {}), + ...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}), + }) + .where(eq(protocolRoomsTable.id, id)) + .returning(); + await logAudit(db, { + action: "room.update", + entityType: "room", + entityId: id, + oldValue: existing, + newValue: row, + performedBy: req.session.userId!, + }); + res.json(row); + }, +); + +router.delete( + "/protocol/rooms/:id", + requireManageRooms, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + const [existing] = await db + .select() + .from(protocolRoomsTable) + .where(eq(protocolRoomsTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + await db.delete(protocolRoomsTable).where(eq(protocolRoomsTable.id, id)); + await logAudit(db, { + action: "room.delete", + entityType: "room", + entityId: id, + oldValue: existing, + performedBy: req.session.userId!, + }); + res.status(204).end(); + }, +); + +// --------------------------------------------------------------------------- +// Bookings +// --------------------------------------------------------------------------- +router.get( + "/protocol/bookings", + requireProtocolAccess, + async (req: Request, res: Response) => { + const roomIdRaw = req.query.roomId; + const statusRaw = req.query.status; + const fromRaw = req.query.from; + const toRaw = req.query.to; + const conds = []; + if (typeof roomIdRaw === "string" && /^\d+$/.test(roomIdRaw)) { + conds.push(eq(protocolRoomBookingsTable.roomId, Number(roomIdRaw))); + } + if ( + typeof statusRaw === "string" && + ["pending", "approved", "rejected", "cancelled"].includes(statusRaw) + ) { + conds.push(eq(protocolRoomBookingsTable.status, statusRaw)); + } + if (typeof fromRaw === "string" && !Number.isNaN(Date.parse(fromRaw))) { + conds.push( + gte(protocolRoomBookingsTable.startsAt, new Date(fromRaw)), + ); + } + if (typeof toRaw === "string" && !Number.isNaN(Date.parse(toRaw))) { + conds.push(lte(protocolRoomBookingsTable.startsAt, new Date(toRaw))); + } + const rows = await db + .select() + .from(protocolRoomBookingsTable) + .where(conds.length > 0 ? and(...conds) : undefined) + .orderBy(desc(protocolRoomBookingsTable.startsAt)); + res.json(rows); + }, +); + +router.post( + "/protocol/bookings", + requireRequest, + async (req: Request, res: Response) => { + const body = parseBody(res, bookingCreateSchema, req.body); + if (!body) return; + const startsAt = new Date(body.startsAt); + const endsAt = new Date(body.endsAt); + + const [room] = await db + .select({ id: protocolRoomsTable.id, isActive: protocolRoomsTable.isActive }) + .from(protocolRoomsTable) + .where(eq(protocolRoomsTable.id, body.roomId)); + if (!room) { + res.status(400).json({ error: "room not found", code: "invalid_room" }); + return; + } + + // Non-approvers submit requests as "pending"; approver roles may create + // an already-approved booking directly. Either way the slot is checked. + const names = await getRoleNamesForUser(req.session.userId!); + const canApprove = APPROVE_ROLES.some((r) => names.has(r)); + + try { + const created = await db.transaction(async (tx) => { + // Lock the room to serialize concurrent bookings, then check. + if (!(await lockRoomRow(tx, body.roomId))) { + throw new Error("__room_missing__"); + } + if ( + await hasBookingConflict(tx, { + roomId: body.roomId, + startsAt, + endsAt, + }) + ) { + throw new Error("__conflict__"); + } + const [row] = await tx + .insert(protocolRoomBookingsTable) + .values({ + roomId: body.roomId, + title: body.title, + requesterName: body.requesterName ?? null, + startsAt, + endsAt, + status: canApprove ? "approved" : "pending", + notes: body.notes ?? null, + createdBy: req.session.userId!, + approvedBy: canApprove ? req.session.userId! : null, + approvedAt: canApprove ? new Date() : null, + }) + .returning(); + await logAudit(tx, { + action: "booking.create", + entityType: "booking", + entityId: row.id, + newValue: row, + performedBy: req.session.userId!, + }); + return row; + }); + res.status(201).json(created); + } catch (e) { + if (e instanceof Error && e.message === "__conflict__") { + res + .status(409) + .json({ error: CONFLICT_MESSAGE_AR, code: "booking_conflict" }); + return; + } + if (e instanceof Error && e.message === "__room_missing__") { + res.status(400).json({ error: "room not found", code: "invalid_room" }); + return; + } + throw e; + } + }, +); + +router.patch( + "/protocol/bookings/:id", + requireMutate, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + const body = parseBody(res, bookingPatchSchema, req.body); + if (!body) return; + const [existing] = await db + .select() + .from(protocolRoomBookingsTable) + .where(eq(protocolRoomBookingsTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const roomId = body.roomId ?? existing.roomId; + const startsAt = body.startsAt ? new Date(body.startsAt) : existing.startsAt; + const endsAt = body.endsAt ? new Date(body.endsAt) : existing.endsAt; + if (startsAt >= endsAt) { + res + .status(400) + .json({ error: "startsAt must be before endsAt", code: "validation" }); + return; + } + try { + const updated = await db.transaction(async (tx) => { + // Lock the (possibly new) room to serialize against concurrent + // bookings before the overlap re-check. + if (!(await lockRoomRow(tx, roomId))) { + throw new Error("__room_missing__"); + } + // Only re-check the slot if the booking still occupies the room. + if ( + (existing.status === "pending" || existing.status === "approved") && + (await hasBookingConflict(tx, { + roomId, + startsAt, + endsAt, + excludeId: id, + })) + ) { + throw new Error("__conflict__"); + } + const [row] = await tx + .update(protocolRoomBookingsTable) + .set({ + roomId, + ...(body.title !== undefined ? { title: body.title } : {}), + ...(body.requesterName !== undefined + ? { requesterName: body.requesterName } + : {}), + startsAt, + endsAt, + ...(body.notes !== undefined ? { notes: body.notes } : {}), + }) + .where(eq(protocolRoomBookingsTable.id, id)) + .returning(); + await logAudit(tx, { + action: "booking.update", + entityType: "booking", + entityId: id, + oldValue: existing, + newValue: row, + performedBy: req.session.userId!, + }); + return row; + }); + res.json(updated); + } catch (e) { + if (e instanceof Error && e.message === "__conflict__") { + res + .status(409) + .json({ error: CONFLICT_MESSAGE_AR, code: "booking_conflict" }); + return; + } + if (e instanceof Error && e.message === "__room_missing__") { + res.status(400).json({ error: "room not found", code: "invalid_room" }); + return; + } + throw e; + } + }, +); + +router.post( + "/protocol/bookings/:id/approve", + requireApprove, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + try { + const updated = await db.transaction(async (tx) => { + // Re-read the booking inside the transaction so status checks and + // the conflict guard see a consistent snapshot. + const [existing] = await tx + .select() + .from(protocolRoomBookingsTable) + .where(eq(protocolRoomBookingsTable.id, id)); + if (!existing) throw new Error("__not_found__"); + // Lock the room to serialize against concurrent approvals/bookings. + await lockRoomRow(tx, existing.roomId); + if (existing.status !== "pending") { + throw new Error("__invalid_status__"); + } + // Guard against approving two overlapping pending requests: only + // other APPROVED bookings block the approval here. + if ( + await hasBookingConflict(tx, { + roomId: existing.roomId, + startsAt: existing.startsAt, + endsAt: existing.endsAt, + excludeId: id, + statuses: ["approved"], + }) + ) { + throw new Error("__conflict__"); + } + // State-conditional update: only flip a still-pending row. + const [row] = await tx + .update(protocolRoomBookingsTable) + .set({ + status: "approved", + approvedBy: req.session.userId!, + approvedAt: new Date(), + rejectionReason: null, + }) + .where( + and( + eq(protocolRoomBookingsTable.id, id), + eq(protocolRoomBookingsTable.status, "pending"), + ), + ) + .returning(); + if (!row) throw new Error("__invalid_status__"); + await logAudit(tx, { + action: "booking.approve", + entityType: "booking", + entityId: id, + oldValue: existing, + newValue: row, + performedBy: req.session.userId!, + }); + return row; + }); + res.json(updated); + } catch (e) { + if (e instanceof Error) { + if (e.message === "__conflict__") { + res + .status(409) + .json({ error: CONFLICT_MESSAGE_AR, code: "booking_conflict" }); + return; + } + if (e.message === "__not_found__") { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + if (e.message === "__invalid_status__") { + res + .status(409) + .json({ error: "booking is not pending", code: "invalid_status" }); + return; + } + } + throw e; + } + }, +); + +router.post( + "/protocol/bookings/:id/reject", + requireApprove, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + const body = parseBody(res, rejectSchema, req.body); + if (!body) return; + const [existing] = await db + .select() + .from(protocolRoomBookingsTable) + .where(eq(protocolRoomBookingsTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const [row] = await db + .update(protocolRoomBookingsTable) + .set({ + status: "rejected", + approvedBy: req.session.userId!, + approvedAt: new Date(), + rejectionReason: body.reason ?? null, + }) + .where(eq(protocolRoomBookingsTable.id, id)) + .returning(); + await logAudit(db, { + action: "booking.reject", + entityType: "booking", + entityId: id, + oldValue: existing, + newValue: row, + performedBy: req.session.userId!, + }); + res.json(row); + }, +); + +router.post( + "/protocol/bookings/:id/cancel", + requireMutate, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + const [existing] = await db + .select() + .from(protocolRoomBookingsTable) + .where(eq(protocolRoomBookingsTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const [row] = await db + .update(protocolRoomBookingsTable) + .set({ status: "cancelled" }) + .where(eq(protocolRoomBookingsTable.id, id)) + .returning(); + await logAudit(db, { + action: "booking.cancel", + entityType: "booking", + entityId: id, + oldValue: existing, + newValue: row, + performedBy: req.session.userId!, + }); + res.json(row); + }, +); + +router.delete( + "/protocol/bookings/:id", + requireMutate, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + const [existing] = await db + .select() + .from(protocolRoomBookingsTable) + .where(eq(protocolRoomBookingsTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + await db + .delete(protocolRoomBookingsTable) + .where(eq(protocolRoomBookingsTable.id, id)); + await logAudit(db, { + action: "booking.delete", + entityType: "booking", + entityId: id, + oldValue: existing, + performedBy: req.session.userId!, + }); + res.status(204).end(); + }, +); + +// --------------------------------------------------------------------------- +// External meetings +// --------------------------------------------------------------------------- +router.get( + "/protocol/external-meetings", + requireProtocolAccess, + async (_req: Request, res: Response) => { + const rows = await db + .select() + .from(protocolExternalMeetingsTable) + .orderBy(desc(protocolExternalMeetingsTable.startsAt)); + res.json(rows); + }, +); + +router.post( + "/protocol/external-meetings", + requireMutate, + async (req: Request, res: Response) => { + const body = parseBody(res, externalCreateSchema, req.body); + if (!body) return; + const [row] = await db + .insert(protocolExternalMeetingsTable) + .values({ + title: body.title, + partyName: body.partyName ?? null, + location: body.location ?? null, + startsAt: new Date(body.startsAt), + endsAt: body.endsAt ? new Date(body.endsAt) : null, + status: body.status ?? "scheduled", + notes: body.notes ?? null, + createdBy: req.session.userId!, + }) + .returning(); + await logAudit(db, { + action: "external_meeting.create", + entityType: "external_meeting", + entityId: row.id, + newValue: row, + performedBy: req.session.userId!, + }); + res.status(201).json(row); + }, +); + +router.patch( + "/protocol/external-meetings/:id", + requireMutate, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + const body = parseBody(res, externalPatchSchema, req.body); + if (!body) return; + const [existing] = await db + .select() + .from(protocolExternalMeetingsTable) + .where(eq(protocolExternalMeetingsTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const [row] = await db + .update(protocolExternalMeetingsTable) + .set({ + ...(body.title !== undefined ? { title: body.title } : {}), + ...(body.partyName !== undefined ? { partyName: body.partyName } : {}), + ...(body.location !== undefined ? { location: body.location } : {}), + ...(body.startsAt !== undefined + ? { startsAt: new Date(body.startsAt) } + : {}), + ...(body.endsAt !== undefined + ? { endsAt: body.endsAt ? new Date(body.endsAt) : null } + : {}), + ...(body.status !== undefined ? { status: body.status } : {}), + ...(body.notes !== undefined ? { notes: body.notes } : {}), + }) + .where(eq(protocolExternalMeetingsTable.id, id)) + .returning(); + await logAudit(db, { + action: "external_meeting.update", + entityType: "external_meeting", + entityId: id, + oldValue: existing, + newValue: row, + performedBy: req.session.userId!, + }); + res.json(row); + }, +); + +router.delete( + "/protocol/external-meetings/:id", + requireMutate, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + const [existing] = await db + .select() + .from(protocolExternalMeetingsTable) + .where(eq(protocolExternalMeetingsTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + await db + .delete(protocolExternalMeetingsTable) + .where(eq(protocolExternalMeetingsTable.id, id)); + await logAudit(db, { + action: "external_meeting.delete", + entityType: "external_meeting", + entityId: id, + oldValue: existing, + performedBy: req.session.userId!, + }); + res.status(204).end(); + }, +); + +// --------------------------------------------------------------------------- +// Gifts & shields catalogue +// --------------------------------------------------------------------------- +router.get( + "/protocol/gifts", + requireProtocolAccess, + async (req: Request, res: Response) => { + const kindRaw = req.query.kind; + const conds = []; + if ( + typeof kindRaw === "string" && + (PROTOCOL_GIFT_KINDS as readonly string[]).includes(kindRaw) + ) { + conds.push(eq(protocolGiftsTable.kind, kindRaw)); + } + const rows = await db + .select() + .from(protocolGiftsTable) + .where(conds.length > 0 ? and(...conds) : undefined) + .orderBy(asc(protocolGiftsTable.nameAr)); + res.json(rows); + }, +); + +router.post( + "/protocol/gifts", + requireMutate, + async (req: Request, res: Response) => { + const body = parseBody(res, giftCreateSchema, req.body); + if (!body) return; + const [row] = await db + .insert(protocolGiftsTable) + .values({ + nameAr: body.nameAr, + nameEn: body.nameEn ?? "", + kind: body.kind ?? "gift", + descriptionAr: body.descriptionAr ?? null, + descriptionEn: body.descriptionEn ?? null, + imageUrl: body.imageUrl ?? null, + quantityInStock: body.quantityInStock ?? 0, + isActive: body.isActive ?? true, + createdBy: req.session.userId!, + }) + .returning(); + await logAudit(db, { + action: "gift.create", + entityType: "gift", + entityId: row.id, + newValue: row, + performedBy: req.session.userId!, + }); + res.status(201).json(row); + }, +); + +router.patch( + "/protocol/gifts/:id", + requireMutate, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + const body = parseBody(res, giftPatchSchema, req.body); + if (!body) return; + const [existing] = await db + .select() + .from(protocolGiftsTable) + .where(eq(protocolGiftsTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const [row] = await db + .update(protocolGiftsTable) + .set({ + ...(body.nameAr !== undefined ? { nameAr: body.nameAr } : {}), + ...(body.nameEn !== undefined ? { nameEn: body.nameEn } : {}), + ...(body.kind !== undefined ? { kind: body.kind } : {}), + ...(body.descriptionAr !== undefined + ? { descriptionAr: body.descriptionAr } + : {}), + ...(body.descriptionEn !== undefined + ? { descriptionEn: body.descriptionEn } + : {}), + ...(body.imageUrl !== undefined ? { imageUrl: body.imageUrl } : {}), + ...(body.quantityInStock !== undefined + ? { quantityInStock: body.quantityInStock } + : {}), + ...(body.isActive !== undefined ? { isActive: body.isActive } : {}), + }) + .where(eq(protocolGiftsTable.id, id)) + .returning(); + await logAudit(db, { + action: "gift.update", + entityType: "gift", + entityId: id, + oldValue: existing, + newValue: row, + performedBy: req.session.userId!, + }); + res.json(row); + }, +); + +router.delete( + "/protocol/gifts/:id", + requireMutate, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + const [existing] = await db + .select() + .from(protocolGiftsTable) + .where(eq(protocolGiftsTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + // Refuse to delete a gift that has issuance history — the FK is + // `on delete restrict`, so return a clean 409 instead of a raw 500. + const [used] = await db + .select({ id: protocolGiftIssuesTable.id }) + .from(protocolGiftIssuesTable) + .where(eq(protocolGiftIssuesTable.giftId, id)) + .limit(1); + if (used) { + res.status(409).json({ + error: "gift has issuance history", + code: "gift_in_use", + }); + return; + } + await db.delete(protocolGiftsTable).where(eq(protocolGiftsTable.id, id)); + await logAudit(db, { + action: "gift.delete", + entityType: "gift", + entityId: id, + oldValue: existing, + performedBy: req.session.userId!, + }); + res.status(204).end(); + }, +); + +// --------------------------------------------------------------------------- +// Gift issuances +// --------------------------------------------------------------------------- +router.get( + "/protocol/gift-issues", + requireProtocolAccess, + async (req: Request, res: Response) => { + const statusRaw = req.query.status; + const giftIdRaw = req.query.giftId; + const conds = []; + if ( + typeof statusRaw === "string" && + ["pending", "approved", "rejected", "issued"].includes(statusRaw) + ) { + conds.push(eq(protocolGiftIssuesTable.status, statusRaw)); + } + if (typeof giftIdRaw === "string" && /^\d+$/.test(giftIdRaw)) { + conds.push(eq(protocolGiftIssuesTable.giftId, Number(giftIdRaw))); + } + const rows = await db + .select() + .from(protocolGiftIssuesTable) + .where(conds.length > 0 ? and(...conds) : undefined) + .orderBy(desc(protocolGiftIssuesTable.createdAt)); + res.json(rows); + }, +); + +router.post( + "/protocol/gift-issues", + requireRequest, + async (req: Request, res: Response) => { + const body = parseBody(res, issueCreateSchema, req.body); + if (!body) return; + const [gift] = await db + .select() + .from(protocolGiftsTable) + .where(eq(protocolGiftsTable.id, body.giftId)); + if (!gift) { + res.status(400).json({ error: "gift not found", code: "invalid_gift" }); + return; + } + const [row] = await db + .insert(protocolGiftIssuesTable) + .values({ + giftId: body.giftId, + recipientName: body.recipientName, + occasion: body.occasion ?? null, + quantity: body.quantity ?? 1, + status: "pending", + notes: body.notes ?? null, + createdBy: req.session.userId!, + }) + .returning(); + await logAudit(db, { + action: "gift_issue.create", + entityType: "gift_issue", + entityId: row.id, + newValue: row, + performedBy: req.session.userId!, + }); + res.status(201).json(row); + }, +); + +router.post( + "/protocol/gift-issues/:id/approve", + requireApprove, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + try { + const updated = await db.transaction(async (tx) => { + const [existing] = await tx + .select() + .from(protocolGiftIssuesTable) + .where(eq(protocolGiftIssuesTable.id, id)); + if (!existing) throw new Error("__not_found__"); + // Authoritatively claim the pending issue exactly once with a + // state-conditional update. Two concurrent approvals of the same + // issue race here: only one flips pending -> issued and gets a row + // back; the loser fails with invalid_status and never decrements. + const [row] = await tx + .update(protocolGiftIssuesTable) + .set({ + status: "issued", + approvedBy: req.session.userId!, + approvedAt: new Date(), + issuedAt: new Date(), + rejectionReason: null, + }) + .where( + and( + eq(protocolGiftIssuesTable.id, id), + eq(protocolGiftIssuesTable.status, "pending"), + ), + ) + .returning(); + if (!row) throw new Error("__invalid_status__"); + const [gift] = await tx + .select() + .from(protocolGiftsTable) + .where(eq(protocolGiftsTable.id, existing.giftId)); + if (!gift) throw new Error("__invalid_gift__"); + // Conditional decrement: matches only when enough stock remains. + // A failure here throws and rolls back the status claim above, so + // an issue never ends up "issued" without stock being consumed. + const decremented = await tx + .update(protocolGiftsTable) + .set({ + quantityInStock: sql`${protocolGiftsTable.quantityInStock} - ${existing.quantity}`, + }) + .where( + and( + eq(protocolGiftsTable.id, gift.id), + gte(protocolGiftsTable.quantityInStock, existing.quantity), + ), + ) + .returning({ id: protocolGiftsTable.id }); + if (decremented.length === 0) { + throw new Error("__insufficient_stock__"); + } + await logAudit(tx, { + action: "gift_issue.approve", + entityType: "gift_issue", + entityId: id, + oldValue: existing, + newValue: row, + performedBy: req.session.userId!, + }); + return row; + }); + res.json(updated); + } catch (e) { + if (e instanceof Error) { + if (e.message === "__not_found__") { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + if (e.message === "__invalid_status__") { + res + .status(409) + .json({ error: "issue is not pending", code: "invalid_status" }); + return; + } + if (e.message === "__invalid_gift__") { + res.status(400).json({ error: "gift not found", code: "invalid_gift" }); + return; + } + if (e.message === "__insufficient_stock__") { + res.status(409).json({ + error: "الكمية المتوفرة غير كافية", + code: "insufficient_stock", + }); + return; + } + } + throw e; + } + }, +); + +router.post( + "/protocol/gift-issues/:id/reject", + requireApprove, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + const body = parseBody(res, rejectSchema, req.body); + if (!body) return; + const [existing] = await db + .select() + .from(protocolGiftIssuesTable) + .where(eq(protocolGiftIssuesTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + const [row] = await db + .update(protocolGiftIssuesTable) + .set({ + status: "rejected", + approvedBy: req.session.userId!, + approvedAt: new Date(), + rejectionReason: body.reason ?? null, + }) + .where(eq(protocolGiftIssuesTable.id, id)) + .returning(); + await logAudit(db, { + action: "gift_issue.reject", + entityType: "gift_issue", + entityId: id, + oldValue: existing, + newValue: row, + performedBy: req.session.userId!, + }); + res.json(row); + }, +); + +router.delete( + "/protocol/gift-issues/:id", + requireMutate, + async (req: Request, res: Response) => { + const id = Number(req.params.id); + const [existing] = await db + .select() + .from(protocolGiftIssuesTable) + .where(eq(protocolGiftIssuesTable.id, id)); + if (!existing) { + res.status(404).json({ error: "Not found", code: "not_found" }); + return; + } + await db + .delete(protocolGiftIssuesTable) + .where(eq(protocolGiftIssuesTable.id, id)); + await logAudit(db, { + action: "gift_issue.delete", + entityType: "gift_issue", + entityId: id, + oldValue: existing, + performedBy: req.session.userId!, + }); + res.status(204).end(); + }, +); + +// --------------------------------------------------------------------------- +// Dashboard +// --------------------------------------------------------------------------- +router.get( + "/protocol/dashboard", + requireProtocolAccess, + async (_req: Request, res: Response) => { + const now = new Date(); + const startOfDay = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ); + const endOfDay = new Date(startOfDay.getTime() + 24 * 60 * 60 * 1000); + + const [roomCount] = await db + .select({ c: sql`count(*)::int` }) + .from(protocolRoomsTable) + .where(eq(protocolRoomsTable.isActive, true)); + + const [pendingBookings] = await db + .select({ c: sql`count(*)::int` }) + .from(protocolRoomBookingsTable) + .where(eq(protocolRoomBookingsTable.status, "pending")); + + const [todayBookings] = await db + .select({ c: sql`count(*)::int` }) + .from(protocolRoomBookingsTable) + .where( + and( + inArray(protocolRoomBookingsTable.status, ["pending", "approved"]), + gte(protocolRoomBookingsTable.startsAt, startOfDay), + lte(protocolRoomBookingsTable.startsAt, endOfDay), + ), + ); + + const [upcomingExternal] = await db + .select({ c: sql`count(*)::int` }) + .from(protocolExternalMeetingsTable) + .where( + and( + eq(protocolExternalMeetingsTable.status, "scheduled"), + gte(protocolExternalMeetingsTable.startsAt, now), + ), + ); + + const [giftStock] = await db + .select({ + kinds: sql`count(*)::int`, + totalStock: sql`coalesce(sum(${protocolGiftsTable.quantityInStock}), 0)::int`, + }) + .from(protocolGiftsTable) + .where(eq(protocolGiftsTable.isActive, true)); + + const [pendingIssues] = await db + .select({ c: sql`count(*)::int` }) + .from(protocolGiftIssuesTable) + .where(eq(protocolGiftIssuesTable.status, "pending")); + + res.json({ + activeRooms: roomCount?.c ?? 0, + pendingBookings: pendingBookings?.c ?? 0, + todayBookings: todayBookings?.c ?? 0, + upcomingExternalMeetings: upcomingExternal?.c ?? 0, + giftCatalogCount: giftStock?.kinds ?? 0, + totalGiftStock: giftStock?.totalStock ?? 0, + pendingGiftIssues: pendingIssues?.c ?? 0, + }); + }, +); + +// --------------------------------------------------------------------------- +// Reports +// --------------------------------------------------------------------------- +router.get( + "/protocol/reports", + requireProtocolAccess, + async (req: Request, res: Response) => { + const fromRaw = req.query.from; + const toRaw = req.query.to; + const from = + typeof fromRaw === "string" && !Number.isNaN(Date.parse(fromRaw)) + ? new Date(fromRaw) + : null; + const to = + typeof toRaw === "string" && !Number.isNaN(Date.parse(toRaw)) + ? new Date(toRaw) + : null; + const bookingRange = []; + if (from) bookingRange.push(gte(protocolRoomBookingsTable.startsAt, from)); + if (to) bookingRange.push(lte(protocolRoomBookingsTable.startsAt, to)); + + const bookingsByRoom = await db + .select({ + roomId: protocolRoomBookingsTable.roomId, + roomNameAr: protocolRoomsTable.nameAr, + roomNameEn: protocolRoomsTable.nameEn, + total: sql`count(*)::int`, + approved: sql`count(*) filter (where ${protocolRoomBookingsTable.status} = 'approved')::int`, + pending: sql`count(*) filter (where ${protocolRoomBookingsTable.status} = 'pending')::int`, + rejected: sql`count(*) filter (where ${protocolRoomBookingsTable.status} = 'rejected')::int`, + cancelled: sql`count(*) filter (where ${protocolRoomBookingsTable.status} = 'cancelled')::int`, + }) + .from(protocolRoomBookingsTable) + .innerJoin( + protocolRoomsTable, + eq(protocolRoomsTable.id, protocolRoomBookingsTable.roomId), + ) + .where(bookingRange.length > 0 ? and(...bookingRange) : undefined) + .groupBy( + protocolRoomBookingsTable.roomId, + protocolRoomsTable.nameAr, + protocolRoomsTable.nameEn, + ) + .orderBy(desc(sql`count(*)`)); + + const issueRange = []; + if (from) issueRange.push(gte(protocolGiftIssuesTable.createdAt, from)); + if (to) issueRange.push(lte(protocolGiftIssuesTable.createdAt, to)); + + const issuesByGift = await db + .select({ + giftId: protocolGiftIssuesTable.giftId, + giftNameAr: protocolGiftsTable.nameAr, + giftNameEn: protocolGiftsTable.nameEn, + kind: protocolGiftsTable.kind, + totalRequests: sql`count(*)::int`, + issued: sql`count(*) filter (where ${protocolGiftIssuesTable.status} = 'issued')::int`, + issuedQty: sql`coalesce(sum(${protocolGiftIssuesTable.quantity}) filter (where ${protocolGiftIssuesTable.status} = 'issued'), 0)::int`, + pending: sql`count(*) filter (where ${protocolGiftIssuesTable.status} = 'pending')::int`, + rejected: sql`count(*) filter (where ${protocolGiftIssuesTable.status} = 'rejected')::int`, + }) + .from(protocolGiftIssuesTable) + .innerJoin( + protocolGiftsTable, + eq(protocolGiftsTable.id, protocolGiftIssuesTable.giftId), + ) + .where(issueRange.length > 0 ? and(...issueRange) : undefined) + .groupBy( + protocolGiftIssuesTable.giftId, + protocolGiftsTable.nameAr, + protocolGiftsTable.nameEn, + protocolGiftsTable.kind, + ) + .orderBy(desc(sql`count(*)`)); + + res.json({ bookingsByRoom, issuesByGift }); + }, +); + +// --------------------------------------------------------------------------- +// Audit log +// --------------------------------------------------------------------------- +router.get( + "/protocol/audit", + requireAudit, + async (req: Request, res: Response) => { + const limitRaw = req.query.limit; + const limit = + typeof limitRaw === "string" && /^\d+$/.test(limitRaw) + ? Math.min(Number(limitRaw), 500) + : 200; + const rows = await db + .select({ + id: protocolAuditLogsTable.id, + action: protocolAuditLogsTable.action, + entityType: protocolAuditLogsTable.entityType, + entityId: protocolAuditLogsTable.entityId, + oldValue: protocolAuditLogsTable.oldValue, + newValue: protocolAuditLogsTable.newValue, + performedBy: protocolAuditLogsTable.performedBy, + performedAt: protocolAuditLogsTable.performedAt, + actorUsername: usersTable.username, + actorNameAr: usersTable.displayNameAr, + actorNameEn: usersTable.displayNameEn, + }) + .from(protocolAuditLogsTable) + .leftJoin( + usersTable, + eq(usersTable.id, protocolAuditLogsTable.performedBy), + ) + .orderBy(desc(protocolAuditLogsTable.performedAt)) + .limit(limit); + res.json(rows); + }, +); + +export default router; diff --git a/artifacts/tx-os/src/App.tsx b/artifacts/tx-os/src/App.tsx index d5dff006..ecc2eced 100644 --- a/artifacts/tx-os/src/App.tsx +++ b/artifacts/tx-os/src/App.tsx @@ -27,6 +27,7 @@ import NotesPage from "@/pages/notes"; import MyOrdersPage from "@/pages/my-orders"; import OrdersIncomingPage from "@/pages/orders-incoming"; import ExecutiveMeetingsPage from "@/pages/executive-meetings"; +import ProtocolPage from "@/pages/protocol"; import EmbeddedAppPage from "@/pages/embedded-app"; const queryClient = new QueryClient({ @@ -73,6 +74,7 @@ function Router() { + {/* Back-compat redirect: the page used to live at /executive-meetings. Stored PDFs, email links, and bookmarks still point there, so catch the old path (and any deep diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 14057ab7..a9a25b23 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -971,7 +971,9 @@ "appName": "نظام Tx", "previous": "السابق", "next": "التالي", - "empty": "(فارغ)" + "empty": "(فارغ)", + "back": "رجوع", + "confirmDelete": "تأكيد الحذف" }, "embeddedFrame": { "title": "تطبيق مدمج", @@ -1656,5 +1658,111 @@ "saved": "تم الحفظ", "reset": "إعادة الافتراضي" } + }, + "protocol": { + "title": "العلاقات العامة والمراسم", + "notes": "ملاحظات", + "statusLabel": "الحالة", + "confirmReject": "هل أنت متأكد من رفض هذا الطلب؟", + "confirmDelete": "لا يمكن التراجع عن هذا الإجراء.", + "tabs": { + "dashboard": "لوحة المعلومات", + "bookings": "حجوزات القاعات", + "external": "اللقاءات الخارجية", + "gifts": "الهدايا والدروع", + "issues": "طلبات الصرف", + "reports": "التقارير", + "audit": "سجل النشاط" + }, + "status": { + "pending": "قيد الانتظار", + "approved": "معتمد", + "rejected": "مرفوض", + "cancelled": "ملغى", + "issued": "تم الصرف", + "scheduled": "مجدول", + "completed": "منجز" + }, + "giftKind": { + "gift": "هدية", + "shield": "درع" + }, + "actions": { + "approve": "اعتماد", + "reject": "رفض", + "cancel": "إلغاء", + "approveIssue": "اعتماد وصرف" + }, + "errors": { + "generic": "تعذّر إتمام العملية." + }, + "dashboard": { + "activeRooms": "القاعات المتاحة", + "todayBookings": "حجوزات اليوم", + "pendingBookings": "حجوزات بانتظار الاعتماد", + "upcomingExternal": "لقاءات خارجية قادمة", + "giftStock": "إجمالي مخزون الهدايا", + "pendingIssues": "طلبات صرف معلّقة" + }, + "bookings": { + "new": "حجز جديد", + "edit": "تعديل الحجز", + "empty": "لا توجد حجوزات.", + "requester": "مقدّم الطلب", + "roomLabel": "القاعة", + "titleLabel": "عنوان الحجز", + "startsAt": "من", + "endsAt": "إلى" + }, + "external": { + "new": "لقاء جديد", + "edit": "تعديل اللقاء", + "empty": "لا توجد لقاءات خارجية.", + "titleLabel": "عنوان اللقاء", + "party": "الجهة", + "location": "المكان", + "startsAt": "التاريخ والوقت" + }, + "gifts": { + "new": "صنف جديد", + "edit": "تعديل الصنف", + "empty": "لا توجد أصناف.", + "stock": "المخزون", + "nameAr": "الاسم (عربي)", + "nameEn": "الاسم (إنجليزي)", + "kind": "النوع" + }, + "rooms": { + "new": "قاعة جديدة", + "edit": "تعديل القاعة", + "title": "القاعات", + "inactive": "غير مفعّلة", + "nameAr": "الاسم (عربي)", + "nameEn": "الاسم (إنجليزي)", + "capacity": "السعة", + "location": "الموقع" + }, + "issues": { + "new": "طلب صرف", + "empty": "لا توجد طلبات صرف.", + "recipient": "المستفيد", + "gift": "الصنف", + "occasion": "المناسبة", + "quantity": "الكمية" + }, + "reports": { + "byRoom": "الحجوزات حسب القاعة", + "byGift": "الصرف حسب الصنف", + "room": "القاعة", + "gift": "الصنف", + "total": "الإجمالي", + "requests": "الطلبات", + "issued": "المصروفة", + "issuedQty": "الكمية المصروفة", + "empty": "لا توجد بيانات." + }, + "audit": { + "empty": "لا يوجد نشاط." + } } } diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index d80430a0..8b3b7400 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -883,7 +883,9 @@ "appName": "Tx OS", "previous": "Previous", "next": "Next", - "empty": "(empty)" + "empty": "(empty)", + "back": "Back", + "confirmDelete": "Confirm delete" }, "embeddedFrame": { "title": "Embedded app", @@ -1529,5 +1531,111 @@ "saved": "Saved", "reset": "Reset to default" } + }, + "protocol": { + "title": "Public Relations & Protocol", + "notes": "Notes", + "statusLabel": "Status", + "confirmReject": "Are you sure you want to reject this request?", + "confirmDelete": "This action cannot be undone.", + "tabs": { + "dashboard": "Dashboard", + "bookings": "Room Bookings", + "external": "External Meetings", + "gifts": "Gifts & Shields", + "issues": "Issuance Requests", + "reports": "Reports", + "audit": "Activity Log" + }, + "status": { + "pending": "Pending", + "approved": "Approved", + "rejected": "Rejected", + "cancelled": "Cancelled", + "issued": "Issued", + "scheduled": "Scheduled", + "completed": "Completed" + }, + "giftKind": { + "gift": "Gift", + "shield": "Shield" + }, + "actions": { + "approve": "Approve", + "reject": "Reject", + "cancel": "Cancel", + "approveIssue": "Approve & issue" + }, + "errors": { + "generic": "The operation could not be completed." + }, + "dashboard": { + "activeRooms": "Active rooms", + "todayBookings": "Today's bookings", + "pendingBookings": "Bookings awaiting approval", + "upcomingExternal": "Upcoming external meetings", + "giftStock": "Total gift stock", + "pendingIssues": "Pending issuance requests" + }, + "bookings": { + "new": "New booking", + "edit": "Edit booking", + "empty": "No bookings yet.", + "requester": "Requester", + "roomLabel": "Room", + "titleLabel": "Booking title", + "startsAt": "From", + "endsAt": "To" + }, + "external": { + "new": "New meeting", + "edit": "Edit meeting", + "empty": "No external meetings yet.", + "titleLabel": "Meeting title", + "party": "Party", + "location": "Location", + "startsAt": "Date & time" + }, + "gifts": { + "new": "New item", + "edit": "Edit item", + "empty": "No items yet.", + "stock": "Stock", + "nameAr": "Name (Arabic)", + "nameEn": "Name (English)", + "kind": "Kind" + }, + "rooms": { + "new": "New room", + "edit": "Edit room", + "title": "Rooms", + "inactive": "inactive", + "nameAr": "Name (Arabic)", + "nameEn": "Name (English)", + "capacity": "Capacity", + "location": "Location" + }, + "issues": { + "new": "Issuance request", + "empty": "No issuance requests yet.", + "recipient": "Recipient", + "gift": "Item", + "occasion": "Occasion", + "quantity": "Quantity" + }, + "reports": { + "byRoom": "Bookings by room", + "byGift": "Issuance by item", + "room": "Room", + "gift": "Item", + "total": "Total", + "requests": "Requests", + "issued": "Issued", + "issuedQty": "Issued qty", + "empty": "No data." + }, + "audit": { + "empty": "No activity yet." + } } } diff --git a/artifacts/tx-os/src/pages/protocol.tsx b/artifacts/tx-os/src/pages/protocol.tsx new file mode 100644 index 00000000..3daa52aa --- /dev/null +++ b/artifacts/tx-os/src/pages/protocol.tsx @@ -0,0 +1,1599 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation } from "wouter"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ArrowLeft, + ArrowRight, + LayoutDashboard, + DoorOpen, + CalendarClock, + Handshake, + Gift, + BarChart3, + ScrollText, + Plus, + Pencil, + Trash2, + Check, + X, + PackageCheck, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { AppDock } from "@/components/app-dock"; +import { useToast } from "@/hooks/use-toast"; +import { apiJson, ApiError } from "@/lib/api-json"; +import { cn } from "@/lib/utils"; + +const API = `${import.meta.env.BASE_URL}api`; + +// --------------------------------------------------------------------------- +// Types (mirror of the /api/protocol/* response shapes) +// --------------------------------------------------------------------------- +type Capabilities = { + canRead: boolean; + canRequest: boolean; + canMutate: boolean; + canApprove: boolean; + canManageRooms: boolean; + canViewAudit: boolean; +}; + +type Room = { + id: number; + nameAr: string; + nameEn: string; + capacity: number | null; + location: string | null; + isActive: boolean; + sortOrder: number; +}; + +type BookingStatus = "pending" | "approved" | "rejected" | "cancelled"; +type Booking = { + id: number; + roomId: number; + title: string; + requesterName: string | null; + startsAt: string; + endsAt: string; + status: BookingStatus; + notes: string | null; + rejectionReason: string | null; +}; + +type ExternalMeeting = { + id: number; + title: string; + partyName: string | null; + location: string | null; + startsAt: string; + endsAt: string | null; + status: "scheduled" | "completed" | "cancelled"; + notes: string | null; +}; + +type GiftKind = "gift" | "shield"; +type Gift = { + id: number; + nameAr: string; + nameEn: string; + kind: GiftKind; + descriptionAr: string | null; + descriptionEn: string | null; + quantityInStock: number; + isActive: boolean; +}; + +type IssueStatus = "pending" | "approved" | "rejected" | "issued"; +type GiftIssue = { + id: number; + giftId: number; + recipientName: string; + occasion: string | null; + quantity: number; + status: IssueStatus; + notes: string | null; + rejectionReason: string | null; +}; + +type Dashboard = { + activeRooms: number; + pendingBookings: number; + todayBookings: number; + upcomingExternalMeetings: number; + giftCatalogCount: number; + totalGiftStock: number; + pendingGiftIssues: number; +}; + +type ReportRow = { + bookingsByRoom: Array<{ + roomId: number; + roomNameAr: string; + roomNameEn: string; + total: number; + approved: number; + pending: number; + rejected: number; + cancelled: number; + }>; + issuesByGift: Array<{ + giftId: number; + giftNameAr: string; + giftNameEn: string; + kind: GiftKind; + totalRequests: number; + issued: number; + issuedQty: number; + pending: number; + rejected: number; + }>; +}; + +type TabKey = + | "dashboard" + | "bookings" + | "external" + | "gifts" + | "issues" + | "reports" + | "audit"; + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- +function toLocalInput(iso: string): string { + // Convert an ISO string into a value for . + const d = new Date(iso); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad( + d.getHours(), + )}:${pad(d.getMinutes())}`; +} +function fromLocalInput(v: string): string { + return new Date(v).toISOString(); +} + +const STATUS_PILL: Record = { + pending: "bg-amber-100 text-amber-800", + approved: "bg-emerald-100 text-emerald-700", + issued: "bg-emerald-100 text-emerald-700", + rejected: "bg-rose-100 text-rose-700", + cancelled: "bg-slate-200 text-slate-600", + scheduled: "bg-sky-100 text-sky-700", + completed: "bg-emerald-100 text-emerald-700", +}; + +export default function ProtocolPage() { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const isAr = lang === "ar"; + const [, setLocation] = useLocation(); + const { toast } = useToast(); + const qc = useQueryClient(); + const [tab, setTab] = useState("dashboard"); + + const nameOf = (ar: string, en: string) => (isAr ? ar : en || ar); + + const caps = useQuery({ + queryKey: ["protocol", "me"], + queryFn: () => apiJson(`${API}/protocol/me`), + }); + + const rooms = useQuery({ + queryKey: ["protocol", "rooms"], + queryFn: () => apiJson(`${API}/protocol/rooms`), + }); + const bookings = useQuery({ + queryKey: ["protocol", "bookings"], + queryFn: () => apiJson(`${API}/protocol/bookings`), + }); + const external = useQuery({ + queryKey: ["protocol", "external"], + queryFn: () => + apiJson(`${API}/protocol/external-meetings`), + }); + const gifts = useQuery({ + queryKey: ["protocol", "gifts"], + queryFn: () => apiJson(`${API}/protocol/gifts`), + }); + const issues = useQuery({ + queryKey: ["protocol", "issues"], + queryFn: () => apiJson(`${API}/protocol/gift-issues`), + }); + const dashboard = useQuery({ + queryKey: ["protocol", "dashboard"], + queryFn: () => apiJson(`${API}/protocol/dashboard`), + }); + const reports = useQuery({ + queryKey: ["protocol", "reports"], + queryFn: () => apiJson(`${API}/protocol/reports`), + enabled: tab === "reports", + }); + const audit = useQuery< + Array<{ + id: number; + action: string; + entityType: string; + entityId: number | null; + performedAt: string; + actorNameAr: string | null; + actorNameEn: string | null; + actorUsername: string | null; + }> + >({ + queryKey: ["protocol", "audit"], + queryFn: () => apiJson(`${API}/protocol/audit`), + enabled: tab === "audit", + }); + + const c = caps.data; + const roomName = (id: number) => { + const r = rooms.data?.find((x) => x.id === id); + return r ? nameOf(r.nameAr, r.nameEn) : `#${id}`; + }; + const giftName = (id: number) => { + const g = gifts.data?.find((x) => x.id === id); + return g ? nameOf(g.nameAr, g.nameEn) : `#${id}`; + }; + + const invalidate = (...keys: string[]) => { + for (const k of keys) qc.invalidateQueries({ queryKey: ["protocol", k] }); + qc.invalidateQueries({ queryKey: ["protocol", "dashboard"] }); + }; + + const fmtDateTime = (iso: string | null) => { + if (!iso) return "—"; + return new Date(iso).toLocaleString(isAr ? "ar" : "en", { + dateStyle: "medium", + timeStyle: "short", + }); + }; + + const onApiError = (e: unknown) => { + const msg = + e instanceof ApiError ? e.message : t("protocol.errors.generic"); + toast({ title: msg, variant: "destructive" }); + }; + + // ---- Booking dialog state ---- + const [bookingDialog, setBookingDialog] = useState<{ + open: boolean; + edit?: Booking; + }>({ open: false }); + const [rejectTarget, setRejectTarget] = useState<{ + kind: "booking" | "issue"; + id: number; + } | null>(null); + const [deleteTarget, setDeleteTarget] = useState< + | { kind: "room" | "booking" | "external" | "gift" | "issue"; id: number } + | null + >(null); + const [roomDialog, setRoomDialog] = useState<{ + open: boolean; + edit?: Room; + }>({ open: false }); + const [externalDialog, setExternalDialog] = useState<{ + open: boolean; + edit?: ExternalMeeting; + }>({ open: false }); + const [giftDialog, setGiftDialog] = useState<{ + open: boolean; + edit?: Gift; + }>({ open: false }); + const [issueDialog, setIssueDialog] = useState<{ open: boolean }>({ + open: false, + }); + + const bookingAction = useMutation({ + mutationFn: (args: { id: number; action: "approve" | "cancel" }) => + apiJson(`${API}/protocol/bookings/${args.id}/${args.action}`, { + method: "POST", + body: JSON.stringify({}), + }), + onSuccess: () => invalidate("bookings"), + onError: onApiError, + }); + const rejectMut = useMutation({ + mutationFn: (args: { kind: "booking" | "issue"; id: number }) => + apiJson( + `${API}/protocol/${ + args.kind === "booking" ? "bookings" : "gift-issues" + }/${args.id}/reject`, + { method: "POST", body: JSON.stringify({}) }, + ), + onSuccess: () => { + setRejectTarget(null); + invalidate("bookings", "issues"); + }, + onError: onApiError, + }); + const issueApprove = useMutation({ + mutationFn: (id: number) => + apiJson(`${API}/protocol/gift-issues/${id}/approve`, { + method: "POST", + body: JSON.stringify({}), + }), + onSuccess: () => invalidate("issues", "gifts"), + onError: onApiError, + }); + const deleteMut = useMutation({ + mutationFn: (target: { kind: string; id: number }) => { + const path = + target.kind === "room" + ? "rooms" + : target.kind === "booking" + ? "bookings" + : target.kind === "external" + ? "external-meetings" + : target.kind === "gift" + ? "gifts" + : "gift-issues"; + return apiJson(`${API}/protocol/${path}/${target.id}`, { + method: "DELETE", + }); + }, + onSuccess: () => { + setDeleteTarget(null); + invalidate("rooms", "bookings", "external", "gifts", "issues"); + }, + onError: (e) => { + setDeleteTarget(null); + onApiError(e); + }, + }); + + const back = () => setLocation("/"); + + const TABS: Array<{ key: TabKey; label: string; icon: typeof Gift }> = [ + { key: "dashboard", label: t("protocol.tabs.dashboard"), icon: LayoutDashboard }, + { key: "bookings", label: t("protocol.tabs.bookings"), icon: DoorOpen }, + { key: "external", label: t("protocol.tabs.external"), icon: Handshake }, + { key: "gifts", label: t("protocol.tabs.gifts"), icon: Gift }, + { key: "issues", label: t("protocol.tabs.issues"), icon: PackageCheck }, + { key: "reports", label: t("protocol.tabs.reports"), icon: BarChart3 }, + ...(c?.canViewAudit + ? [{ key: "audit" as TabKey, label: t("protocol.tabs.audit"), icon: ScrollText }] + : []), + ]; + + const BackIcon = isAr ? ArrowRight : ArrowLeft; + + return ( +
+
+
+ +
+
+ +
+

+ {t("protocol.title")} +

+
+
+ +
+ +
+ {tab === "dashboard" && ( + + )} + + {tab === "bookings" && ( +
+
+

+ {t("protocol.tabs.bookings")} +

+ {c?.canRequest && ( + + )} +
+
+ {(bookings.data ?? []).map((b) => ( +
+
+
+
{b.title}
+
+ {roomName(b.roomId)} · {fmtDateTime(b.startsAt)} –{" "} + {fmtDateTime(b.endsAt)} +
+ {b.requesterName && ( +
+ {t("protocol.bookings.requester")}: {b.requesterName} +
+ )} +
+ + {t(`protocol.status.${b.status}`)} + +
+
+ {c?.canApprove && b.status === "pending" && ( + <> + + + + )} + {c?.canMutate && + (b.status === "pending" || b.status === "approved") && ( + + )} + {c?.canMutate && ( + <> + + + + )} +
+
+ ))} + {bookings.data?.length === 0 && ( + + )} +
+
+ )} + + {tab === "external" && ( +
+
+

+ {t("protocol.tabs.external")} +

+ {c?.canMutate && ( + + )} +
+
+ {(external.data ?? []).map((m) => ( +
+
+
+
{m.title}
+
+ {m.partyName ? `${m.partyName} · ` : ""} + {fmtDateTime(m.startsAt)} +
+ {m.location && ( +
{m.location}
+ )} +
+ + {t(`protocol.status.${m.status}`)} + +
+ {c?.canMutate && ( +
+ + +
+ )} +
+ ))} + {external.data?.length === 0 && ( + + )} +
+
+ )} + + {tab === "gifts" && ( +
+
+

+ {t("protocol.tabs.gifts")} +

+
+ {c?.canManageRooms && ( + + )} + {c?.canMutate && ( + + )} +
+
+ + {c?.canManageRooms && ( +
+
+ {t("protocol.rooms.title")} +
+
+ {(rooms.data ?? []).map((r) => ( +
+ + {nameOf(r.nameAr, r.nameEn)} + {r.capacity ? ` · ${r.capacity}` : ""} + {!r.isActive ? ` · ${t("protocol.rooms.inactive")}` : ""} + +
+ + +
+
+ ))} +
+
+ )} + +
+ {(gifts.data ?? []).map((g) => ( +
+
+
+
+ {nameOf(g.nameAr, g.nameEn)} +
+
+ {t(`protocol.giftKind.${g.kind}`)} ·{" "} + {t("protocol.gifts.stock")}: {g.quantityInStock} +
+
+
+ {c?.canRequest && ( + + )} + {c?.canMutate && ( + <> + + + + )} +
+
+
+ ))} + {gifts.data?.length === 0 && ( + + )} +
+
+ )} + + {tab === "issues" && ( +
+
+

+ {t("protocol.tabs.issues")} +

+ {c?.canRequest && ( + + )} +
+
+ {(issues.data ?? []).map((it) => ( +
+
+
+
+ {giftName(it.giftId)} × {it.quantity} +
+
+ {t("protocol.issues.recipient")}: {it.recipientName} + {it.occasion ? ` · ${it.occasion}` : ""} +
+
+ + {t(`protocol.status.${it.status}`)} + +
+ {c?.canApprove && it.status === "pending" && ( +
+ + +
+ )} +
+ ))} + {issues.data?.length === 0 && ( + + )} +
+
+ )} + + {tab === "reports" && ( + + )} + + {tab === "audit" && ( +
+

+ {t("protocol.tabs.audit")} +

+
+ {(audit.data ?? []).map((a) => ( +
+ + {t(`protocol.audit.${a.action}`, a.action)} + + {" "} + · {a.entityType} + {a.entityId ? `#${a.entityId}` : ""} + + + + {isAr ? a.actorNameAr : a.actorNameEn || a.actorUsername} ·{" "} + {fmtDateTime(a.performedAt)} + +
+ ))} + {audit.data?.length === 0 && ( + + )} +
+
+ )} +
+ + {/* Dialogs */} + {bookingDialog.open && ( + setBookingDialog({ open: false })} + onSaved={() => { + setBookingDialog({ open: false }); + invalidate("bookings"); + }} + onError={onApiError} + /> + )} + {roomDialog.open && ( + setRoomDialog({ open: false })} + onSaved={() => { + setRoomDialog({ open: false }); + invalidate("rooms"); + }} + onError={onApiError} + /> + )} + {externalDialog.open && ( + setExternalDialog({ open: false })} + onSaved={() => { + setExternalDialog({ open: false }); + invalidate("external"); + }} + onError={onApiError} + /> + )} + {giftDialog.open && ( + setGiftDialog({ open: false })} + onSaved={() => { + setGiftDialog({ open: false }); + invalidate("gifts"); + }} + onError={onApiError} + /> + )} + {issueDialog.open && ( + g.isActive)} + nameOf={nameOf} + onClose={() => setIssueDialog({ open: false })} + onSaved={() => { + setIssueDialog({ open: false }); + invalidate("issues"); + }} + onError={onApiError} + /> + )} + + !o && setRejectTarget(null)} + > + + + {t("protocol.actions.reject")} + + {t("protocol.confirmReject")} + + + + {t("common.cancel")} + rejectTarget && rejectMut.mutate(rejectTarget)} + > + {t("protocol.actions.reject")} + + + + + + !o && setDeleteTarget(null)} + > + + + {t("common.confirmDelete")} + + {t("protocol.confirmDelete")} + + + + {t("common.cancel")} + deleteTarget && deleteMut.mutate(deleteTarget)} + > + {t("common.delete")} + + + + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Sub-views & dialogs +// --------------------------------------------------------------------------- +function EmptyState({ text }: { text: string }) { + return ( +
{text}
+ ); +} + +function DashboardView({ + data, + t, +}: { + data: Dashboard | undefined; + t: (k: string) => string; +}) { + const cards = [ + { label: t("protocol.dashboard.activeRooms"), value: data?.activeRooms }, + { label: t("protocol.dashboard.todayBookings"), value: data?.todayBookings }, + { + label: t("protocol.dashboard.pendingBookings"), + value: data?.pendingBookings, + }, + { + label: t("protocol.dashboard.upcomingExternal"), + value: data?.upcomingExternalMeetings, + }, + { + label: t("protocol.dashboard.giftStock"), + value: data?.totalGiftStock, + }, + { + label: t("protocol.dashboard.pendingIssues"), + value: data?.pendingGiftIssues, + }, + ]; + return ( +
+ {cards.map((c) => ( +
+
{c.value ?? "—"}
+
{c.label}
+
+ ))} +
+ ); +} + +function ReportsView({ + data, + nameOf, + t, +}: { + data: ReportRow | undefined; + nameOf: (ar: string, en: string) => string; + t: (k: string) => string; +}) { + return ( +
+
+

+ {t("protocol.reports.byRoom")} +

+
+ + + + + + + + + + + {(data?.bookingsByRoom ?? []).map((r) => ( + + + + + + + ))} + {data?.bookingsByRoom.length === 0 && ( + + + + )} + +
{t("protocol.reports.room")}{t("protocol.reports.total")}{t("protocol.status.approved")}{t("protocol.status.pending")}
+ {nameOf(r.roomNameAr, r.roomNameEn)} + {r.total}{r.approved}{r.pending}
+ {t("protocol.reports.empty")} +
+
+
+ +
+

+ {t("protocol.reports.byGift")} +

+
+ + + + + + + + + + + {(data?.issuesByGift ?? []).map((r) => ( + + + + + + + ))} + {data?.issuesByGift.length === 0 && ( + + + + )} + +
{t("protocol.reports.gift")}{t("protocol.reports.requests")}{t("protocol.reports.issued")}{t("protocol.reports.issuedQty")}
+ {nameOf(r.giftNameAr, r.giftNameEn)} + {r.totalRequests}{r.issued}{r.issuedQty}
+ {t("protocol.reports.empty")} +
+
+
+
+ ); +} + +function DialogShell({ + title, + onClose, + children, + onSubmit, + submitLabel, + saving, +}: { + title: string; + onClose: () => void; + children: React.ReactNode; + onSubmit: () => void; + submitLabel: string; + saving: boolean; +}) { + const { i18n, t } = useTranslation(); + return ( + !o && onClose()}> + + + {title} + +
{ + e.preventDefault(); + onSubmit(); + }} + > + {children} + + + + +
+
+
+ ); +} + +function BookingDialog({ + rooms, + edit, + nameOf, + onClose, + onSaved, + onError, +}: { + rooms: Room[]; + edit?: Booking; + nameOf: (ar: string, en: string) => string; + onClose: () => void; + onSaved: () => void; + onError: (e: unknown) => void; +}) { + const { t } = useTranslation(); + const [title, setTitle] = useState(edit?.title ?? ""); + const [roomId, setRoomId] = useState( + edit ? String(edit.roomId) : rooms[0] ? String(rooms[0].id) : "", + ); + const [requesterName, setRequesterName] = useState(edit?.requesterName ?? ""); + const [startsAt, setStartsAt] = useState( + edit ? toLocalInput(edit.startsAt) : "", + ); + const [endsAt, setEndsAt] = useState(edit ? toLocalInput(edit.endsAt) : ""); + const [notes, setNotes] = useState(edit?.notes ?? ""); + + const mut = useMutation({ + mutationFn: () => { + const body = { + roomId: Number(roomId), + title, + requesterName: requesterName || null, + startsAt: fromLocalInput(startsAt), + endsAt: fromLocalInput(endsAt), + notes: notes || null, + }; + return edit + ? apiJson(`${API}/protocol/bookings/${edit.id}`, { + method: "PATCH", + body: JSON.stringify(body), + }) + : apiJson(`${API}/protocol/bookings`, { + method: "POST", + body: JSON.stringify(body), + }); + }, + onSuccess: onSaved, + onError, + }); + + return ( + mut.mutate()} + submitLabel={t("common.save")} + saving={mut.isPending} + > +
+ + +
+
+ + setTitle(e.target.value)} required /> +
+
+ + setRequesterName(e.target.value)} + /> +
+
+
+ + setStartsAt(e.target.value)} + required + /> +
+
+ + setEndsAt(e.target.value)} + required + /> +
+
+
+ +