Task #716: Booking slots — edit dialog + compact numbered table

- SlotsAdminSection (artifacts/tx-os/src/pages/protocol.tsx): replaced the
  2-column card grid of booking slots with a compact shadcn Table sorted by
  startTime: # | time range | duration | days | status | actions.
  Rows are dense (py-1.5, small text, h-7 icon buttons), RTL/LTR safe.
- New edit flow: pencil button opens a Dialog to change start time, duration
  (5–480, step 5) and days-of-week (same day-chip UI as the generator).
  Saves via existing PATCH /api/protocol/booking-slots/:id (no backend
  changes needed — route already accepted startTime/durationMinutes/daysOfWeek).
  409 duplicate_slot shows a dedicated destructive toast; other errors fall
  back to the shared onError. Invalidates slots-admin + parent slots queries.
- i18n: new keys under protocol.slots in ar.json/en.json (colTime, colStatus,
  colActions, edit, editTitle, editSaved, duplicateSlot, durationValue with
  full Arabic plural categories).
- tsc --noEmit passes for tx-os. Architect review: Pass; added the suggested
  <=480 client-side duration guard.
- Follow-up #717 proposed (UI test for edit + 409 toast).
This commit is contained in:
Replit Agent
2026-07-09 05:54:18 +00:00
parent 893b3d5bfb
commit e3484819dc
3 changed files with 246 additions and 52 deletions
+14 -1
View File
@@ -1968,7 +1968,20 @@
"generated": "تم توليد {{created}} وقتًا، ودمج الأيام في {{merged}}، وتجاهل {{skipped}} مكررًا.", "generated": "تم توليد {{created}} وقتًا، ودمج الأيام في {{merged}}، وتجاهل {{skipped}} مكررًا.",
"generatedNone": "لا جديد — كل الأوقات والأيام موجودة مسبقًا.", "generatedNone": "لا جديد — كل الأوقات والأيام موجودة مسبقًا.",
"pickDay": "اختر يومًا واحدًا على الأقل", "pickDay": "اختر يومًا واحدًا على الأقل",
"noSlotsForDay": "لا توجد أوقات متاحة في هذا اليوم" "noSlotsForDay": "لا توجد أوقات متاحة في هذا اليوم",
"colTime": "الوقت",
"colStatus": "الحالة",
"colActions": "إجراءات",
"edit": "تعديل",
"editTitle": "تعديل وقت الحجز",
"editSaved": "تم حفظ التعديل",
"duplicateSlot": "يوجد وقت حجز يبدأ في نفس التوقيت.",
"durationValue_zero": "{{count}} دقيقة",
"durationValue_one": "دقيقة واحدة",
"durationValue_two": "دقيقتان",
"durationValue_few": "{{count}} دقائق",
"durationValue_many": "{{count}} دقيقة",
"durationValue_other": "{{count}} دقيقة"
} }
} }
} }
+10 -1
View File
@@ -1813,7 +1813,16 @@
"generated": "{{created}} slot(s) created, days merged into {{merged}}, {{skipped}} duplicate(s) skipped.", "generated": "{{created}} slot(s) created, days merged into {{merged}}, {{skipped}} duplicate(s) skipped.",
"generatedNone": "Nothing new — all start times and days already exist.", "generatedNone": "Nothing new — all start times and days already exist.",
"pickDay": "Pick at least one day", "pickDay": "Pick at least one day",
"noSlotsForDay": "No slots available on this day" "noSlotsForDay": "No slots available on this day",
"colTime": "Time",
"colStatus": "Status",
"colActions": "Actions",
"edit": "Edit",
"editTitle": "Edit booking slot",
"editSaved": "Changes saved",
"duplicateSlot": "A slot with the same start time already exists.",
"durationValue_one": "{{count}} min",
"durationValue_other": "{{count}} min"
} }
} }
} }
+192 -20
View File
@@ -2554,6 +2554,23 @@ function SlotsAdminSection({
? prev.filter((x) => x !== d) ? prev.filter((x) => x !== d)
: [...prev, d].sort((a, b) => a - b), : [...prev, d].sort((a, b) => a - b),
); );
// Inline slot editing (dialog) — startTime / duration / days.
const [editing, setEditing] = useState<Slot | null>(null);
const [editTime, setEditTime] = useState("");
const [editDuration, setEditDuration] = useState("30");
const [editDays, setEditDays] = useState<number[]>([]);
const openEdit = (s: Slot) => {
setEditing(s);
setEditTime(s.startTime);
setEditDuration(String(s.durationMinutes));
setEditDays([...s.daysOfWeek]);
};
const toggleEditDay = (d: number) =>
setEditDays((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 // Full slot list (incl. inactive) — admin-only endpoint; this component is
// only rendered for canManageRooms. // only rendered for canManageRooms.
@@ -2562,6 +2579,9 @@ function SlotsAdminSection({
queryFn: () => apiJson<Slot[]>(`${API}/protocol/booking-slots`), queryFn: () => apiJson<Slot[]>(`${API}/protocol/booking-slots`),
}); });
const slots = slotsQuery.data ?? []; const slots = slotsQuery.data ?? [];
const sortedSlots = [...slots].sort((a, b) =>
a.startTime < b.startTime ? -1 : a.startTime > b.startTime ? 1 : 0,
);
const changed = () => { const changed = () => {
qc.invalidateQueries({ queryKey: ["protocol", "slots-admin"] }); qc.invalidateQueries({ queryKey: ["protocol", "slots-admin"] });
onChanged(); onChanged();
@@ -2630,6 +2650,32 @@ function SlotsAdminSection({
onSuccess: changed, onSuccess: changed,
onError, onError,
}); });
const editMut = useMutation({
mutationFn: (s: Slot) =>
apiJson(`${API}/protocol/booking-slots/${s.id}`, {
method: "PATCH",
body: JSON.stringify({
startTime: editTime,
durationMinutes: Number(editDuration),
daysOfWeek: editDays,
}),
}),
onSuccess: () => {
setEditing(null);
toast({ title: t("protocol.slots.editSaved") });
changed();
},
onError: (e: unknown) => {
if (e instanceof ApiError && e.status === 409) {
toast({
title: t("protocol.slots.duplicateSlot"),
variant: "destructive",
});
return;
}
onError(e);
},
});
const deleteMut = useMutation({ const deleteMut = useMutation({
mutationFn: (id: number) => mutationFn: (id: number) =>
apiJson(`${API}/protocol/booking-slots/${id}`, { method: "DELETE" }), apiJson(`${API}/protocol/booking-slots/${id}`, { method: "DELETE" }),
@@ -2651,6 +2697,11 @@ function SlotsAdminSection({
}); });
const canAdd = /^\d{2}:\d{2}$/.test(newTime) && Number(newDuration) >= 5; const canAdd = /^\d{2}:\d{2}$/.test(newTime) && Number(newDuration) >= 5;
const canSaveEdit =
/^\d{2}:\d{2}$/.test(editTime) &&
Number(editDuration) >= 5 &&
Number(editDuration) <= 480 &&
editDays.length > 0;
const canGenerate = const canGenerate =
/^\d{2}:\d{2}$/.test(intFrom) && /^\d{2}:\d{2}$/.test(intFrom) &&
/^\d{2}:\d{2}$/.test(intTo) && /^\d{2}:\d{2}$/.test(intTo) &&
@@ -2807,27 +2858,54 @@ function SlotsAdminSection({
{slots.length === 0 ? ( {slots.length === 0 ? (
<p className="text-sm text-slate-400">{t("protocol.slots.empty")}</p> <p className="text-sm text-slate-400">{t("protocol.slots.empty")}</p>
) : ( ) : (
<div className="grid gap-1.5 sm:grid-cols-2"> <div className="overflow-x-auto rounded-lg border border-slate-200">
{slots.map((s) => ( <Table>
<div <TableHeader>
key={s.id} <TableRow className="bg-slate-50 hover:bg-slate-50">
className="flex items-center justify-between gap-2 rounded-lg border border-slate-200 px-3 py-2" <TableHead className="h-8 w-10 px-2 text-center text-[11px] font-semibold text-slate-500">
> #
<div className="flex items-center gap-2 min-w-0"> </TableHead>
<div className="min-w-0"> <TableHead className="h-8 px-2 text-start text-[11px] font-semibold text-slate-500">
<span {t("protocol.slots.colTime")}
className="block text-sm font-medium text-slate-700" </TableHead>
<TableHead className="h-8 px-2 text-start text-[11px] font-semibold text-slate-500">
{t("protocol.slots.duration")}
</TableHead>
<TableHead className="h-8 px-2 text-start text-[11px] font-semibold text-slate-500">
{t("protocol.slots.days")}
</TableHead>
<TableHead className="h-8 px-2 text-start text-[11px] font-semibold text-slate-500">
{t("protocol.slots.colStatus")}
</TableHead>
<TableHead className="h-8 w-24 px-2 text-center text-[11px] font-semibold text-slate-500">
{t("protocol.slots.colActions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedSlots.map((s, i) => (
<TableRow key={s.id} className="hover:bg-slate-50/60">
<TableCell className="px-2 py-1.5 text-center text-xs tabular-nums text-slate-400">
{i + 1}
</TableCell>
<TableCell
className="whitespace-nowrap px-2 py-1.5 text-xs font-medium text-slate-700"
dir={isAr ? "rtl" : "ltr"} dir={isAr ? "rtl" : "ltr"}
> >
{fmtSlotRange(s.startTime, s.durationMinutes, isAr)} {fmtSlotRange(s.startTime, s.durationMinutes, isAr)}
</span> </TableCell>
<span className="block text-[11px] text-slate-500 truncate"> <TableCell className="whitespace-nowrap px-2 py-1.5 text-xs text-slate-600">
{t("protocol.slots.durationValue", {
count: s.durationMinutes,
})}
</TableCell>
<TableCell className="px-2 py-1.5 text-[11px] text-slate-500">
{fmtSlotDays(s.daysOfWeek, t, isAr)} {fmtSlotDays(s.daysOfWeek, t, isAr)}
</span> </TableCell>
</div> <TableCell className="px-2 py-1.5">
<span <span
className={cn( className={cn(
"text-[11px] px-2 py-0.5 rounded-full shrink-0", "inline-block whitespace-nowrap rounded-full px-2 py-0.5 text-[11px]",
s.isActive s.isActive
? "bg-emerald-100 text-emerald-700" ? "bg-emerald-100 text-emerald-700"
: "bg-slate-200 text-slate-500", : "bg-slate-200 text-slate-500",
@@ -2837,11 +2915,22 @@ function SlotsAdminSection({
? t("protocol.rooms.active") ? t("protocol.rooms.active")
: t("protocol.rooms.inactive")} : t("protocol.rooms.inactive")}
</span> </span>
</div> </TableCell>
<div className="flex gap-1 shrink-0"> <TableCell className="px-2 py-1">
<div className="flex items-center justify-center gap-0.5">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-7 w-7 p-0"
title={t("protocol.slots.edit")}
onClick={() => openEdit(s)}
>
<Pencil size={13} className="text-slate-500" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
title={ title={
s.isActive s.isActive
? t("protocol.slots.deactivate") ? t("protocol.slots.deactivate")
@@ -2849,22 +2938,105 @@ function SlotsAdminSection({
} }
onClick={() => toggleMut.mutate(s)} onClick={() => toggleMut.mutate(s)}
> >
{s.isActive ? <Ban size={14} /> : <Check size={14} />} {s.isActive ? <Ban size={13} /> : <Check size={13} />}
</Button> </Button>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-7 w-7 p-0"
title={t("common.delete")}
onClick={() => deleteMut.mutate(s.id)} onClick={() => deleteMut.mutate(s.id)}
> >
<Trash2 size={14} className="text-rose-500" /> <Trash2 size={13} className="text-rose-500" />
</Button> </Button>
</div> </div>
</div> </TableCell>
</TableRow>
))} ))}
</TableBody>
</Table>
</div> </div>
)} )}
</div> </div>
<Dialog
open={editing !== null}
onOpenChange={(open) => {
if (!open) setEditing(null);
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("protocol.slots.editTitle")}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="flex flex-wrap items-end gap-2">
<div>
<Label className="text-xs">
{t("protocol.slots.startTime")}
</Label>
<Input
type="time"
dir="ltr"
className="w-32"
value={editTime}
onChange={(e) => setEditTime(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={editDuration}
onChange={(e) => setEditDuration(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={() => toggleEditDay(d)}
className={cn(
"rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
editDays.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>
{editDays.length === 0 && (
<p className="text-xs text-amber-600">
{t("protocol.slots.pickDay")}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>
{t("common.cancel")}
</Button>
<Button
disabled={!canSaveEdit || editMut.isPending}
onClick={() => editing && editMut.mutate(editing)}
>
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="bg-white rounded-xl border border-slate-200 p-4 space-y-2"> <div className="bg-white rounded-xl border border-slate-200 p-4 space-y-2">
<h3 className="font-semibold text-slate-700"> <h3 className="font-semibold text-slate-700">
{t("protocol.slots.leadTitle")} {t("protocol.slots.leadTitle")}