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}} مكررًا.",
|
||||
"generatedNone": "لا جديد — كل الأوقات والأيام موجودة مسبقًا.",
|
||||
"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.",
|
||||
"generatedNone": "Nothing new — all start times and days already exist.",
|
||||
"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, 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
|
||||
// only rendered for canManageRooms.
|
||||
@@ -2562,6 +2579,9 @@ function SlotsAdminSection({
|
||||
queryFn: () => apiJson<Slot[]>(`${API}/protocol/booking-slots`),
|
||||
});
|
||||
const slots = slotsQuery.data ?? [];
|
||||
const sortedSlots = [...slots].sort((a, b) =>
|
||||
a.startTime < b.startTime ? -1 : a.startTime > b.startTime ? 1 : 0,
|
||||
);
|
||||
const changed = () => {
|
||||
qc.invalidateQueries({ queryKey: ["protocol", "slots-admin"] });
|
||||
onChanged();
|
||||
@@ -2630,6 +2650,32 @@ function SlotsAdminSection({
|
||||
onSuccess: changed,
|
||||
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({
|
||||
mutationFn: (id: number) =>
|
||||
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 canSaveEdit =
|
||||
/^\d{2}:\d{2}$/.test(editTime) &&
|
||||
Number(editDuration) >= 5 &&
|
||||
Number(editDuration) <= 480 &&
|
||||
editDays.length > 0;
|
||||
const canGenerate =
|
||||
/^\d{2}:\d{2}$/.test(intFrom) &&
|
||||
/^\d{2}:\d{2}$/.test(intTo) &&
|
||||
@@ -2807,64 +2858,185 @@ function SlotsAdminSection({
|
||||
{slots.length === 0 ? (
|
||||
<p className="text-sm text-slate-400">{t("protocol.slots.empty")}</p>
|
||||
) : (
|
||||
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||
{slots.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
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">
|
||||
<div className="min-w-0">
|
||||
<span
|
||||
className="block text-sm font-medium text-slate-700"
|
||||
<div className="overflow-x-auto rounded-lg border border-slate-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 hover:bg-slate-50">
|
||||
<TableHead className="h-8 w-10 px-2 text-center text-[11px] font-semibold text-slate-500">
|
||||
#
|
||||
</TableHead>
|
||||
<TableHead className="h-8 px-2 text-start text-[11px] font-semibold text-slate-500">
|
||||
{t("protocol.slots.colTime")}
|
||||
</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"}
|
||||
>
|
||||
{fmtSlotRange(s.startTime, s.durationMinutes, isAr)}
|
||||
</span>
|
||||
<span className="block text-[11px] text-slate-500 truncate">
|
||||
</TableCell>
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[11px] px-2 py-0.5 rounded-full shrink-0",
|
||||
s.isActive
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-slate-200 text-slate-500",
|
||||
)}
|
||||
>
|
||||
{s.isActive
|
||||
? t("protocol.rooms.active")
|
||||
: t("protocol.rooms.inactive")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
title={
|
||||
s.isActive
|
||||
? t("protocol.slots.deactivate")
|
||||
: t("protocol.slots.activate")
|
||||
}
|
||||
onClick={() => toggleMut.mutate(s)}
|
||||
>
|
||||
{s.isActive ? <Ban size={14} /> : <Check size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteMut.mutate(s.id)}
|
||||
>
|
||||
<Trash2 size={14} className="text-rose-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell className="px-2 py-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block whitespace-nowrap rounded-full px-2 py-0.5 text-[11px]",
|
||||
s.isActive
|
||||
? "bg-emerald-100 text-emerald-700"
|
||||
: "bg-slate-200 text-slate-500",
|
||||
)}
|
||||
>
|
||||
{s.isActive
|
||||
? t("protocol.rooms.active")
|
||||
: t("protocol.rooms.inactive")}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-2 py-1">
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<Button
|
||||
size="sm"
|
||||
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={
|
||||
s.isActive
|
||||
? t("protocol.slots.deactivate")
|
||||
: t("protocol.slots.activate")
|
||||
}
|
||||
onClick={() => toggleMut.mutate(s)}
|
||||
>
|
||||
{s.isActive ? <Ban size={13} /> : <Check size={13} />}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
title={t("common.delete")}
|
||||
onClick={() => deleteMut.mutate(s.id)}
|
||||
>
|
||||
<Trash2 size={13} className="text-rose-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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">
|
||||
<h3 className="font-semibold text-slate-700">
|
||||
{t("protocol.slots.leadTitle")}
|
||||
|
||||
Reference in New Issue
Block a user