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:
@@ -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}} دقيقة"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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")}
|
||||||
|
|||||||
Reference in New Issue
Block a user