Task #710: Weekly availability intervals (Calendly-style) for protocol booking slots
- DB: protocol_booking_slots.days_of_week integer[] NOT NULL default {0..6} (0=Sun..6=Sat, Riyadh); pushed via drizzle-kit, legacy rows keep all-days behaviour.
- API (routes/protocol.ts): slot create/patch accept daysOfWeek (validated, deduped, sorted); new POST /protocol/booking-slots/generate (requireManageRooms) generates stepped slots in [from,to) skipping existing start times (onConflictDoNothing), returns {created,skipped,slots}, 400 interval_too_short; matchesActiveSlot and public availability now filter by Riyadh weekday.
- UI (protocol.tsx): interval-generation form (from/to/duration + day toggle pills + workdays/all-days presets), slot list shows day names, booking dialog filters slots by chosen date weekday with stale-selection reset and empty-day hint. i18n keys added (ar/en).
- Tests: 13/13 pass incl. generation, duplicate skipping, validation, weekday filtering in availability + booking rejection, viewer 403 on generate.
- Architect review: Pass; added the suggested authz test.
This commit is contained in:
@@ -286,9 +286,16 @@ function isUniqueViolation(e: unknown): boolean {
|
||||
}
|
||||
|
||||
const slotTimeRegex = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
// Days of week: 0=Sunday .. 6=Saturday (Riyadh calendar). Deduped + sorted.
|
||||
const daysOfWeekSchema = z
|
||||
.array(z.number().int().min(0).max(6))
|
||||
.min(1)
|
||||
.max(7)
|
||||
.transform((days) => [...new Set(days)].sort((a, b) => a - b));
|
||||
const slotCreateSchema = z.object({
|
||||
startTime: z.string().regex(slotTimeRegex, "startTime must be HH:mm"),
|
||||
durationMinutes: z.number().int().min(5).max(480).optional(),
|
||||
daysOfWeek: daysOfWeekSchema.optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
sortOrder: z.number().int().min(0).max(100000).optional(),
|
||||
});
|
||||
@@ -296,6 +303,31 @@ const slotPatchSchema = slotCreateSchema
|
||||
.partial()
|
||||
.refine((v) => Object.keys(v).length > 0, { message: "no fields to update" });
|
||||
|
||||
// Interval generation ("Calendly-style"): every `durationMinutes` step in
|
||||
// [startTime, endTime) becomes a slot on the given days. HH:mm arithmetic
|
||||
// happens on Riyadh wall-clock minutes, no timezone math needed.
|
||||
const slotIntervalSchema = z
|
||||
.object({
|
||||
startTime: z.string().regex(slotTimeRegex, "startTime must be HH:mm"),
|
||||
endTime: z.string().regex(slotTimeRegex, "endTime must be HH:mm"),
|
||||
durationMinutes: z.number().int().min(5).max(480),
|
||||
daysOfWeek: daysOfWeekSchema,
|
||||
})
|
||||
.refine((v) => hhmmToMinutes(v.startTime) < hhmmToMinutes(v.endTime), {
|
||||
message: "startTime must be before endTime",
|
||||
path: ["endTime"],
|
||||
});
|
||||
|
||||
function hhmmToMinutes(hhmm: string): number {
|
||||
const [h, m] = hhmm.split(":").map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
function minutesToHHmm(total: number): string {
|
||||
const h = Math.floor(total / 60);
|
||||
const m = total % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const settingsPatchSchema = z.object({
|
||||
// Minimum advance notice for PUBLIC booking requests, in minutes.
|
||||
// 0 disables the restriction; capped at 7 days.
|
||||
@@ -492,13 +524,19 @@ async function getMinLeadMinutes(executor: DbExecutor): Promise<number> {
|
||||
return Number.isInteger(n) && n >= 0 ? n : DEFAULT_MIN_LEAD_MINUTES;
|
||||
}
|
||||
|
||||
type ActiveSlot = { id: number; startTime: string; durationMinutes: number };
|
||||
type ActiveSlot = {
|
||||
id: number;
|
||||
startTime: string;
|
||||
durationMinutes: number;
|
||||
daysOfWeek: number[];
|
||||
};
|
||||
async function getActiveSlots(executor: DbExecutor): Promise<ActiveSlot[]> {
|
||||
return executor
|
||||
.select({
|
||||
id: protocolBookingSlotsTable.id,
|
||||
startTime: protocolBookingSlotsTable.startTime,
|
||||
durationMinutes: protocolBookingSlotsTable.durationMinutes,
|
||||
daysOfWeek: protocolBookingSlotsTable.daysOfWeek,
|
||||
})
|
||||
.from(protocolBookingSlotsTable)
|
||||
.where(eq(protocolBookingSlotsTable.isActive, true))
|
||||
@@ -522,12 +560,26 @@ function matchesActiveSlot(
|
||||
return false;
|
||||
}
|
||||
const wall = riyadhHHmm(startsAt);
|
||||
const weekday = riyadhWeekday(startsAt);
|
||||
const durationMs = endsAt.getTime() - startsAt.getTime();
|
||||
return slots.some(
|
||||
(s) => s.startTime === wall && durationMs === s.durationMinutes * 60_000,
|
||||
(s) =>
|
||||
s.startTime === wall &&
|
||||
durationMs === s.durationMinutes * 60_000 &&
|
||||
s.daysOfWeek.includes(weekday),
|
||||
);
|
||||
}
|
||||
|
||||
// Day of week (0=Sunday .. 6=Saturday) of the Riyadh calendar day that
|
||||
// contains `d`. Riyadh is fixed UTC+3, no DST.
|
||||
function riyadhWeekday(d: Date): number {
|
||||
return new Date(d.getTime() + 3 * 60 * 60 * 1000).getUTCDay();
|
||||
}
|
||||
// Same for a plain "YYYY-MM-DD" Riyadh calendar day.
|
||||
function ymdWeekday(dateStr: string): number {
|
||||
return new Date(`${dateStr}T00:00:00Z`).getUTCDay();
|
||||
}
|
||||
|
||||
const INVALID_SLOT_MESSAGE_AR =
|
||||
"الوقت المحدد غير متاح للحجز، يرجى اختيار أحد الأوقات المتاحة.";
|
||||
const LEAD_TIME_MESSAGE_AR =
|
||||
@@ -797,7 +849,10 @@ router.get(
|
||||
]);
|
||||
|
||||
const now = Date.now();
|
||||
const result = slots.map((s) => {
|
||||
const weekday = ymdWeekday(dateRaw);
|
||||
const result = slots
|
||||
.filter((s) => s.daysOfWeek.includes(weekday))
|
||||
.map((s) => {
|
||||
const start = slotStartOn(dateRaw, s.startTime);
|
||||
const end = new Date(start.getTime() + s.durationMinutes * 60_000);
|
||||
const booked = busy.some(
|
||||
@@ -868,6 +923,7 @@ router.post(
|
||||
.values({
|
||||
startTime: body.startTime,
|
||||
durationMinutes: body.durationMinutes ?? 30,
|
||||
daysOfWeek: body.daysOfWeek ?? [0, 1, 2, 3, 4, 5, 6],
|
||||
isActive: body.isActive ?? true,
|
||||
sortOrder: body.sortOrder ?? 0,
|
||||
createdBy: req.session.userId!,
|
||||
@@ -895,6 +951,58 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
// Generate slots from a weekly availability interval (Calendly-style):
|
||||
// every `durationMinutes` step in [startTime, endTime) whose full duration
|
||||
// still fits becomes a slot on the given days. Start times that already
|
||||
// exist are skipped (global start_time uniqueness), never overwritten.
|
||||
router.post(
|
||||
"/protocol/booking-slots/generate",
|
||||
requireManageRooms,
|
||||
async (req: Request, res: Response) => {
|
||||
const body = parseBody(res, slotIntervalSchema, req.body);
|
||||
if (!body) return;
|
||||
const from = hhmmToMinutes(body.startTime);
|
||||
const to = hhmmToMinutes(body.endTime);
|
||||
const candidates: string[] = [];
|
||||
for (let m = from; m + body.durationMinutes <= to; m += body.durationMinutes) {
|
||||
candidates.push(minutesToHHmm(m));
|
||||
}
|
||||
if (candidates.length === 0) {
|
||||
res.status(400).json({
|
||||
error: "الفترة أقصر من مدة الوقت الواحد، لا يمكن توليد أي وقت.",
|
||||
code: "interval_too_short",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const inserted = await db
|
||||
.insert(protocolBookingSlotsTable)
|
||||
.values(
|
||||
candidates.map((startTime) => ({
|
||||
startTime,
|
||||
durationMinutes: body.durationMinutes,
|
||||
daysOfWeek: body.daysOfWeek,
|
||||
createdBy: req.session.userId!,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing({ target: protocolBookingSlotsTable.startTime })
|
||||
.returning();
|
||||
for (const row of inserted) {
|
||||
await logAudit(db, {
|
||||
action: "slot.create",
|
||||
entityType: "booking_slot",
|
||||
entityId: row.id,
|
||||
newValue: row,
|
||||
performedBy: req.session.userId!,
|
||||
});
|
||||
}
|
||||
res.status(201).json({
|
||||
created: inserted.length,
|
||||
skipped: candidates.length - inserted.length,
|
||||
slots: inserted,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/protocol/booking-slots/:id",
|
||||
requireManageRooms,
|
||||
@@ -918,6 +1026,9 @@ router.patch(
|
||||
...(body.durationMinutes !== undefined
|
||||
? { durationMinutes: body.durationMinutes }
|
||||
: {}),
|
||||
...(body.daysOfWeek !== undefined
|
||||
? { daysOfWeek: body.daysOfWeek }
|
||||
: {}),
|
||||
...(body.isActive !== undefined ? { isActive: body.isActive } : {}),
|
||||
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
|
||||
})
|
||||
|
||||
@@ -193,12 +193,13 @@ after(async () => {
|
||||
await pool.query(`DELETE FROM protocol_booking_slots`);
|
||||
for (const s of savedSlots) {
|
||||
await pool.query(
|
||||
`INSERT INTO protocol_booking_slots (id, start_time, duration_minutes, is_active, sort_order, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
`INSERT INTO protocol_booking_slots (id, start_time, duration_minutes, days_of_week, is_active, sort_order, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
s.id,
|
||||
s.start_time,
|
||||
s.duration_minutes,
|
||||
s.days_of_week,
|
||||
s.is_active,
|
||||
s.sort_order,
|
||||
s.created_at,
|
||||
@@ -424,7 +425,7 @@ test("protocol viewer can only read active slots; admin reads/writes are 403", a
|
||||
for (const row of activeRows) {
|
||||
assert.deepEqual(
|
||||
Object.keys(row).sort(),
|
||||
["durationMinutes", "id", "startTime"],
|
||||
["daysOfWeek", "durationMinutes", "id", "startTime"],
|
||||
"active endpoint must expose only minimal fields",
|
||||
);
|
||||
}
|
||||
@@ -438,6 +439,13 @@ test("protocol viewer can only read active slots; admin reads/writes are 403", a
|
||||
startTime: "20:20",
|
||||
});
|
||||
assert.equal(post.status, 403, "viewer must not create slots");
|
||||
const gen = await api(viewerCookie, "POST", "/protocol/booking-slots/generate", {
|
||||
startTime: "10:00",
|
||||
endTime: "12:00",
|
||||
durationMinutes: 30,
|
||||
daysOfWeek: [1],
|
||||
});
|
||||
assert.equal(gen.status, 403, "viewer must not generate slots");
|
||||
const patch = await api(viewerCookie, "PATCH", "/protocol/settings", {
|
||||
minLeadMinutes: 5,
|
||||
});
|
||||
@@ -461,3 +469,134 @@ test("deleting a slot removes it from the admin list", async () => {
|
||||
const rows = await list.json();
|
||||
assert.ok(!rows.some((s) => s.id === slotBId));
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task #710: weekly availability intervals + per-slot days of week.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const dayWeekday = new Date(`${day}T00:00:00Z`).getUTCDay();
|
||||
const otherWeekday = (dayWeekday + 1) % 7;
|
||||
|
||||
test("slots created without daysOfWeek default to every day (legacy behaviour)", async () => {
|
||||
const list = await api(adminCookie, "GET", "/protocol/booking-slots");
|
||||
assert.equal(list.status, 200);
|
||||
const a = (await list.json()).find((s) => s.id === slotAId);
|
||||
assert.ok(a, "slot A should still exist");
|
||||
assert.deepEqual(a.daysOfWeek, [0, 1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
|
||||
test("interval generation creates slots, skips duplicates, validates input", async () => {
|
||||
const gen = await api(adminCookie, "POST", "/protocol/booking-slots/generate", {
|
||||
startTime: "18:07",
|
||||
endTime: "19:37",
|
||||
durationMinutes: 30,
|
||||
daysOfWeek: [dayWeekday],
|
||||
});
|
||||
assert.equal(gen.status, 201);
|
||||
const body = await gen.json();
|
||||
assert.equal(body.created, 3, "18:07, 18:37, 19:07 should be generated");
|
||||
assert.equal(body.skipped, 0);
|
||||
assert.deepEqual(
|
||||
body.slots.map((s) => s.startTime).sort(),
|
||||
["18:07", "18:37", "19:07"],
|
||||
);
|
||||
for (const s of body.slots) {
|
||||
assert.deepEqual(s.daysOfWeek, [dayWeekday]);
|
||||
}
|
||||
|
||||
// Re-running the exact same interval generates nothing new.
|
||||
const again = await api(adminCookie, "POST", "/protocol/booking-slots/generate", {
|
||||
startTime: "18:07",
|
||||
endTime: "19:37",
|
||||
durationMinutes: 30,
|
||||
daysOfWeek: [dayWeekday],
|
||||
});
|
||||
assert.equal(again.status, 201);
|
||||
const b2 = await again.json();
|
||||
assert.equal(b2.created, 0);
|
||||
assert.equal(b2.skipped, 3);
|
||||
|
||||
// Overlapping an existing start time (SLOT_A) only creates the free ones.
|
||||
const overlap = await api(adminCookie, "POST", "/protocol/booking-slots/generate", {
|
||||
startTime: SLOT_A,
|
||||
endTime: isoLikeAdd(SLOT_A, 60),
|
||||
durationMinutes: 30,
|
||||
daysOfWeek: [0, 1, 2, 3, 4, 5, 6],
|
||||
});
|
||||
assert.equal(overlap.status, 201);
|
||||
const b3 = await overlap.json();
|
||||
assert.equal(b3.skipped, 1, "existing SLOT_A start must be skipped");
|
||||
assert.equal(b3.created, 1);
|
||||
|
||||
// Invalid inputs are rejected.
|
||||
const badRange = await api(adminCookie, "POST", "/protocol/booking-slots/generate", {
|
||||
startTime: "12:00",
|
||||
endTime: "11:00",
|
||||
durationMinutes: 30,
|
||||
daysOfWeek: [1],
|
||||
});
|
||||
assert.equal(badRange.status, 400, "from must be before to");
|
||||
const badDay = await api(adminCookie, "POST", "/protocol/booking-slots/generate", {
|
||||
startTime: "11:00",
|
||||
endTime: "12:00",
|
||||
durationMinutes: 30,
|
||||
daysOfWeek: [7],
|
||||
});
|
||||
assert.equal(badDay.status, 400, "day 7 is invalid");
|
||||
const tooShort = await api(adminCookie, "POST", "/protocol/booking-slots/generate", {
|
||||
startTime: "11:00",
|
||||
endTime: "11:10",
|
||||
durationMinutes: 30,
|
||||
daysOfWeek: [1],
|
||||
});
|
||||
assert.equal(tooShort.status, 400);
|
||||
assert.equal((await tooShort.json()).code, "interval_too_short");
|
||||
});
|
||||
|
||||
// "HH:mm" plus minutes, staying inside the same day (test helper).
|
||||
function isoLikeAdd(hhmm, plusMinutes) {
|
||||
const [h, m] = hhmm.split(":").map(Number);
|
||||
const total = h * 60 + m + plusMinutes;
|
||||
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(
|
||||
total % 60,
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
test("availability and booking validation filter slots by weekday", async () => {
|
||||
// A slot only offered on a different weekday than `day`.
|
||||
const res = await api(adminCookie, "POST", "/protocol/booking-slots", {
|
||||
startTime: "21:11",
|
||||
durationMinutes: DURATION,
|
||||
daysOfWeek: [otherWeekday],
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
const offDay = await res.json();
|
||||
assert.deepEqual(offDay.daysOfWeek, [otherWeekday]);
|
||||
|
||||
// Public availability for `day` must not list the off-day slot but must
|
||||
// still list slot A (all days).
|
||||
const avail = await fetch(
|
||||
`${API_BASE}/api/protocol/public/availability?roomId=${roomId}&date=${day}`,
|
||||
);
|
||||
assert.equal(avail.status, 200);
|
||||
const data = await avail.json();
|
||||
assert.ok(
|
||||
!data.slots.some((s) => s.id === offDay.id),
|
||||
"off-day slot must be hidden for this date",
|
||||
);
|
||||
assert.ok(
|
||||
data.slots.some((s) => s.id === slotAId),
|
||||
"all-days slot must still be listed",
|
||||
);
|
||||
|
||||
// Internal booking on the off-day slot's time is rejected: right start
|
||||
// time, wrong weekday.
|
||||
const wrongDay = await api(adminCookie, "POST", "/protocol/bookings", {
|
||||
roomId,
|
||||
title: "Wrong weekday booking",
|
||||
requesterName: "Staff",
|
||||
startsAt: isoAt("21:11"),
|
||||
endsAt: isoAt("21:11", DURATION),
|
||||
});
|
||||
assert.equal(wrongDay.status, 400);
|
||||
assert.equal((await wrongDay.json()).code, "invalid_slot");
|
||||
});
|
||||
|
||||
@@ -1911,7 +1911,29 @@
|
||||
"leadSaved": "تم حفظ المهلة",
|
||||
"dateLabel": "التاريخ",
|
||||
"slotLabel": "الوقت",
|
||||
"pickSlot": "اختر الوقت"
|
||||
"pickSlot": "اختر الوقت",
|
||||
"days": "الأيام",
|
||||
"allDays": "كل الأيام",
|
||||
"weekdaysPreset": "أيام العمل",
|
||||
"allDaysPreset": "كل الأيام",
|
||||
"dayNames": {
|
||||
"0": "الأحد",
|
||||
"1": "الاثنين",
|
||||
"2": "الثلاثاء",
|
||||
"3": "الأربعاء",
|
||||
"4": "الخميس",
|
||||
"5": "الجمعة",
|
||||
"6": "السبت"
|
||||
},
|
||||
"intervalTitle": "إضافة فترة توفر",
|
||||
"intervalHint": "حدد فترة (من – إلى) والأيام ومدة الوقت، وستتولّد الأوقات تلقائيًا ضمن الفترة. الأوقات المكررة تُتجاهل.",
|
||||
"intervalFrom": "من",
|
||||
"intervalTo": "إلى",
|
||||
"generate": "توليد الأوقات",
|
||||
"generated": "تم توليد {{created}} وقتًا (تم تجاهل {{skipped}} مكررًا)",
|
||||
"generatedNone": "لم يتولد أي وقت — كل الأوقات موجودة مسبقًا.",
|
||||
"pickDay": "اختر يومًا واحدًا على الأقل",
|
||||
"noSlotsForDay": "لا توجد أوقات متاحة في هذا اليوم"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1764,7 +1764,29 @@
|
||||
"leadSaved": "Lead time saved",
|
||||
"dateLabel": "Date",
|
||||
"slotLabel": "Time",
|
||||
"pickSlot": "Pick a time"
|
||||
"pickSlot": "Pick a time",
|
||||
"days": "Days",
|
||||
"allDays": "Every day",
|
||||
"weekdaysPreset": "Workdays",
|
||||
"allDaysPreset": "All days",
|
||||
"dayNames": {
|
||||
"0": "Sun",
|
||||
"1": "Mon",
|
||||
"2": "Tue",
|
||||
"3": "Wed",
|
||||
"4": "Thu",
|
||||
"5": "Fri",
|
||||
"6": "Sat"
|
||||
},
|
||||
"intervalTitle": "Add availability interval",
|
||||
"intervalHint": "Pick a range (from – to), the days and the slot duration; slots are generated automatically within the range. Duplicate start times are skipped.",
|
||||
"intervalFrom": "From",
|
||||
"intervalTo": "To",
|
||||
"generate": "Generate slots",
|
||||
"generated": "{{created}} slot(s) generated ({{skipped}} duplicate(s) skipped)",
|
||||
"generatedNone": "No slots generated — all start times already exist.",
|
||||
"pickDay": "Pick at least one day",
|
||||
"noSlotsForDay": "No slots available on this day"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ type Slot = {
|
||||
id: number;
|
||||
startTime: string; // "HH:mm" Riyadh wall clock
|
||||
durationMinutes: number;
|
||||
daysOfWeek: number[]; // 0=Sunday .. 6=Saturday (Riyadh calendar)
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
};
|
||||
@@ -114,8 +115,32 @@ type ActiveSlotInfo = {
|
||||
id: number;
|
||||
startTime: string;
|
||||
durationMinutes: number;
|
||||
daysOfWeek: number[];
|
||||
};
|
||||
|
||||
// Weekday (0=Sunday .. 6=Saturday) of a plain "YYYY-MM-DD" calendar day.
|
||||
function ymdWeekday(ymd: string): number {
|
||||
return new Date(`${ymd}T00:00:00Z`).getUTCDay();
|
||||
}
|
||||
|
||||
const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
|
||||
// Saudi work week: Sunday..Thursday.
|
||||
const WORKDAYS = [0, 1, 2, 3, 4];
|
||||
|
||||
function fmtSlotDays(
|
||||
days: number[],
|
||||
t: (k: string) => string,
|
||||
isAr: boolean,
|
||||
): string {
|
||||
if (ALL_DAYS.every((d) => days.includes(d))) {
|
||||
return t("protocol.slots.allDays");
|
||||
}
|
||||
return [...days]
|
||||
.sort((a, b) => a - b)
|
||||
.map((d) => t(`protocol.slots.dayNames.${d}`))
|
||||
.join(isAr ? "، " : ", ");
|
||||
}
|
||||
|
||||
type BookingStatus = "pending" | "approved" | "rejected" | "cancelled";
|
||||
type MeetingType = "internal" | "external";
|
||||
type KeyAttendee = {
|
||||
@@ -2412,6 +2437,17 @@ function SlotsAdminSection({
|
||||
const qc = useQueryClient();
|
||||
const [newTime, setNewTime] = useState("");
|
||||
const [newDuration, setNewDuration] = useState("30");
|
||||
// Interval-generation form (Calendly-style weekly availability).
|
||||
const [intFrom, setIntFrom] = useState("");
|
||||
const [intTo, setIntTo] = useState("");
|
||||
const [intDuration, setIntDuration] = useState("30");
|
||||
const [intDays, setIntDays] = useState<number[]>(WORKDAYS);
|
||||
const toggleDay = (d: number) =>
|
||||
setIntDays((prev) =>
|
||||
prev.includes(d)
|
||||
? prev.filter((x) => x !== d)
|
||||
: [...prev, d].sort((a, b) => a - b),
|
||||
);
|
||||
|
||||
// Full slot list (incl. inactive) — admin-only endpoint; this component is
|
||||
// only rendered for canManageRooms.
|
||||
@@ -2450,6 +2486,34 @@ function SlotsAdminSection({
|
||||
},
|
||||
onError,
|
||||
});
|
||||
const generateMut = useMutation({
|
||||
mutationFn: () =>
|
||||
apiJson<{ created: number; skipped: number }>(
|
||||
`${API}/protocol/booking-slots/generate`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
startTime: intFrom,
|
||||
endTime: intTo,
|
||||
durationMinutes: Number(intDuration),
|
||||
daysOfWeek: intDays,
|
||||
}),
|
||||
},
|
||||
),
|
||||
onSuccess: (r) => {
|
||||
toast({
|
||||
title:
|
||||
r.created === 0
|
||||
? t("protocol.slots.generatedNone")
|
||||
: t("protocol.slots.generated", {
|
||||
created: r.created,
|
||||
skipped: r.skipped,
|
||||
}),
|
||||
});
|
||||
changed();
|
||||
},
|
||||
onError,
|
||||
});
|
||||
const toggleMut = useMutation({
|
||||
mutationFn: (s: Slot) =>
|
||||
apiJson(`${API}/protocol/booking-slots/${s.id}`, {
|
||||
@@ -2480,6 +2544,12 @@ function SlotsAdminSection({
|
||||
});
|
||||
|
||||
const canAdd = /^\d{2}:\d{2}$/.test(newTime) && Number(newDuration) >= 5;
|
||||
const canGenerate =
|
||||
/^\d{2}:\d{2}$/.test(intFrom) &&
|
||||
/^\d{2}:\d{2}$/.test(intTo) &&
|
||||
intFrom < intTo &&
|
||||
Number(intDuration) >= 5 &&
|
||||
intDays.length > 0;
|
||||
const leadDirty =
|
||||
leadInput !== null &&
|
||||
settings.data !== undefined &&
|
||||
@@ -2530,6 +2600,103 @@ function SlotsAdminSection({
|
||||
{t("protocol.slots.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 space-y-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-slate-700">
|
||||
{t("protocol.slots.intervalTitle")}
|
||||
</h4>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
{t("protocol.slots.intervalHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">
|
||||
{t("protocol.slots.intervalFrom")}
|
||||
</Label>
|
||||
<Input
|
||||
type="time"
|
||||
dir="ltr"
|
||||
className="w-32"
|
||||
value={intFrom}
|
||||
onChange={(e) => setIntFrom(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">{t("protocol.slots.intervalTo")}</Label>
|
||||
<Input
|
||||
type="time"
|
||||
dir="ltr"
|
||||
className="w-32"
|
||||
value={intTo}
|
||||
onChange={(e) => setIntTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">{t("protocol.slots.duration")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={5}
|
||||
max={480}
|
||||
step={5}
|
||||
className="w-24"
|
||||
value={intDuration}
|
||||
onChange={(e) => setIntDuration(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">{t("protocol.slots.days")}</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ALL_DAYS.map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => toggleDay(d)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
intDays.includes(d)
|
||||
? "border-sky-500 bg-sky-50 text-sky-700"
|
||||
: "border-slate-200 bg-white text-slate-500 hover:border-slate-300",
|
||||
)}
|
||||
>
|
||||
{t(`protocol.slots.dayNames.${d}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-sky-600 hover:underline"
|
||||
onClick={() => setIntDays(WORKDAYS)}
|
||||
>
|
||||
{t("protocol.slots.weekdaysPreset")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-sky-600 hover:underline"
|
||||
onClick={() => setIntDays(ALL_DAYS)}
|
||||
>
|
||||
{t("protocol.slots.allDaysPreset")}
|
||||
</button>
|
||||
</div>
|
||||
{intDays.length === 0 && (
|
||||
<p className="text-xs text-amber-600">
|
||||
{t("protocol.slots.pickDay")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canGenerate || generateMut.isPending}
|
||||
onClick={() => generateMut.mutate()}
|
||||
>
|
||||
<Plus size={16} className="me-1" />
|
||||
{t("protocol.slots.generate")}
|
||||
</Button>
|
||||
</div>
|
||||
{slots.length === 0 ? (
|
||||
<p className="text-sm text-slate-400">{t("protocol.slots.empty")}</p>
|
||||
) : (
|
||||
@@ -2540,12 +2707,17 @@ function SlotsAdminSection({
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-slate-200 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="text-sm font-medium text-slate-700"
|
||||
dir={isAr ? "rtl" : "ltr"}
|
||||
>
|
||||
{fmtSlotRange(s.startTime, s.durationMinutes, isAr)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<span
|
||||
className="block text-sm font-medium text-slate-700"
|
||||
dir={isAr ? "rtl" : "ltr"}
|
||||
>
|
||||
{fmtSlotRange(s.startTime, s.durationMinutes, isAr)}
|
||||
</span>
|
||||
<span className="block text-[11px] text-slate-500 truncate">
|
||||
{fmtSlotDays(s.daysOfWeek, t, isAr)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] px-2 py-0.5 rounded-full shrink-0",
|
||||
@@ -2928,6 +3100,17 @@ function BookingDialog({
|
||||
return match ? String(match.id) : "";
|
||||
});
|
||||
const selectedSlot = slots.find((s) => String(s.id) === slotId);
|
||||
// Only slots offered on the chosen date's weekday (Riyadh calendar) can
|
||||
// be picked — same rule the server enforces.
|
||||
const daySlots = slotDate
|
||||
? slots.filter((s) => s.daysOfWeek.includes(ymdWeekday(slotDate)))
|
||||
: slots;
|
||||
useEffect(() => {
|
||||
if (slotId && !daySlots.some((s) => String(s.id) === slotId)) {
|
||||
setSlotId("");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [slotDate]);
|
||||
const [notes, setNotes] = useState(edit?.notes ?? "");
|
||||
const [requesterPhone, setRequesterPhone] = useState(
|
||||
edit?.requesterPhone ?? "",
|
||||
@@ -3138,7 +3321,7 @@ function BookingDialog({
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{slots.map((s) => (
|
||||
{daySlots.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
<span dir={isAr ? "rtl" : "ltr"}>
|
||||
{fmtSlotRange(s.startTime, s.durationMinutes, isAr)}
|
||||
@@ -3147,6 +3330,11 @@ function BookingDialog({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{slotDate && daySlots.length === 0 && (
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
{t("protocol.slots.noSlotsForDay")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user