Files
badoHair_fe/app/admin/planning/page.tsx
2026-05-12 00:28:37 +00:00

575 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { ChevronLeft, ChevronRight, Plus, Trash2, RefreshCw, Ban, Check } from "lucide-react";
import { toast } from "sonner";
import {
adminGetSchedule, adminCreateSchedule, adminDeleteSchedule,
adminListSlots, adminGenerateSlots, adminUpdateSlot, adminDeleteSlot,
adminGetBlockedDates, adminAddBlockedDate, adminRemoveBlockedDate,
WeeklySchedule, BlockedDate, TimeSlotApi,
} from "@/lib/api/bookings";
import { useLanguage } from "@/contexts/LanguageContext";
const DURATIONS = [30, 45, 60, 90, 120];
function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10);
}
function monthStart(year: number, month: number) {
return new Date(year, month, 1);
}
function daysInMonth(year: number, month: number) {
return new Date(year, month + 1, 0).getDate();
}
// Monday-first weekday index (0=Mon, 6=Sun)
function weekdayMon(date: Date) {
return (date.getDay() + 6) % 7;
}
// ── Schedule Tab ──────────────────────────────────────────────────────────────
function ScheduleTab() {
const { t } = useLanguage();
const DAY_NAMES = Array.from({ length: 7 }, (_, i) => t(`admin.day.${i}`));
const [schedule, setSchedule] = useState<WeeklySchedule[]>([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState({
day_of_week: 0,
start_time: "09:00",
end_time: "18:00",
slot_duration_minutes: 60,
});
const [saving, setSaving] = useState(false);
const [genFrom, setGenFrom] = useState(toDateStr(new Date()));
const [genTo, setGenTo] = useState(() => {
const d = new Date();
d.setDate(d.getDate() + 30);
return toDateStr(d);
});
const [generating, setGenerating] = useState(false);
useEffect(() => {
adminGetSchedule()
.then(setSchedule)
.catch(() => toast.error(t("admin.error")))
.finally(() => setLoading(false));
}, []);
const addEntry = async () => {
setSaving(true);
try {
const entry = await adminCreateSchedule({
day_of_week: Number(form.day_of_week),
start_time: form.start_time + ":00",
end_time: form.end_time + ":00",
slot_duration_minutes: Number(form.slot_duration_minutes),
});
setSchedule((prev) => [...prev, entry].sort((a, b) => a.day_of_week - b.day_of_week));
toast.success(t("admin.planning.schedule_added"));
} catch {
toast.error(t("admin.error"));
} finally {
setSaving(false);
}
};
const removeEntry = async (id: string) => {
try {
await adminDeleteSchedule(id);
setSchedule((prev) => prev.filter((e) => e.id !== id));
toast.success(t("admin.planning.schedule_deleted"));
} catch {
toast.error(t("admin.error"));
}
};
const generate = async () => {
setGenerating(true);
try {
const res = await adminGenerateSlots(genFrom, genTo);
toast.success(t("admin.planning.generated", { n: res.created }));
} catch {
toast.error(t("admin.error"));
} finally {
setGenerating(false);
}
};
const grouped = DAY_NAMES.map((name, idx) => ({
name,
entries: schedule.filter((e) => e.day_of_week === idx),
}));
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">{t("admin.planning.weekly_title")}</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-10 bg-muted animate-pulse rounded" />
))}
</div>
) : (
<div className="space-y-3">
{grouped.map(({ name, entries }) => (
<div key={name} className="flex items-start gap-3">
<span className="w-24 text-sm font-medium pt-1 shrink-0">{name}</span>
<div className="flex flex-wrap gap-2 flex-1">
{entries.length === 0 ? (
<span className="text-xs text-muted-foreground pt-1">{t("admin.planning.not_available")}</span>
) : (
entries.map((e) => (
<div key={e.id} className="flex items-center gap-1.5 bg-muted rounded px-2 py-1 text-xs">
<span>{e.start_time.slice(0, 5)} {e.end_time.slice(0, 5)}</span>
<span className="text-muted-foreground">({e.slot_duration_minutes} min)</span>
<button
onClick={() => removeEntry(e.id)}
className="text-muted-foreground hover:text-destructive ml-1"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("admin.planning.add_title")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<Label className="text-xs">{t("admin.planning.day")}</Label>
<select
className="w-full mt-1 text-sm border border-input rounded-md px-3 py-2 bg-background"
value={form.day_of_week}
onChange={(e) => setForm((f) => ({ ...f, day_of_week: Number(e.target.value) }))}
>
{DAY_NAMES.map((d, i) => (
<option key={i} value={i}>{d}</option>
))}
</select>
</div>
<div>
<Label className="text-xs">{t("admin.planning.start")}</Label>
<Input
type="time"
className="mt-1"
value={form.start_time}
onChange={(e) => setForm((f) => ({ ...f, start_time: e.target.value }))}
/>
</div>
<div>
<Label className="text-xs">{t("admin.planning.end")}</Label>
<Input
type="time"
className="mt-1"
value={form.end_time}
onChange={(e) => setForm((f) => ({ ...f, end_time: e.target.value }))}
/>
</div>
<div>
<Label className="text-xs">{t("admin.planning.duration_min")}</Label>
<select
className="w-full mt-1 text-sm border border-input rounded-md px-3 py-2 bg-background"
value={form.slot_duration_minutes}
onChange={(e) => setForm((f) => ({ ...f, slot_duration_minutes: Number(e.target.value) }))}
>
{DURATIONS.map((d) => (
<option key={d} value={d}>{d} min</option>
))}
</select>
</div>
</div>
<Button onClick={addEntry} disabled={saving} className="mt-3" size="sm">
<Plus className="h-4 w-4 mr-1.5" />
{saving ? t("admin.planning.adding") : t("admin.add")}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("admin.planning.generate_title")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-3">{t("admin.planning.generate_desc")}</p>
<div className="flex flex-wrap gap-3 items-end">
<div>
<Label className="text-xs">{t("admin.planning.from")}</Label>
<Input type="date" className="mt-1 w-40" value={genFrom}
onChange={(e) => setGenFrom(e.target.value)} />
</div>
<div>
<Label className="text-xs">{t("admin.planning.to")}</Label>
<Input type="date" className="mt-1 w-40" value={genTo}
onChange={(e) => setGenTo(e.target.value)} />
</div>
<Button onClick={generate} disabled={generating} size="sm">
<RefreshCw className={`h-4 w-4 mr-1.5 ${generating ? "animate-spin" : ""}`} />
{generating ? t("admin.planning.generating") : t("admin.planning.generate_btn")}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
// ── Calendar Tab ──────────────────────────────────────────────────────────────
function CalendarTab() {
const { t, locale } = useLanguage();
const DAY_NAMES = Array.from({ length: 7 }, (_, i) => t(`admin.day.${i}`));
const MONTH_NAMES = Array.from({ length: 12 }, (_, i) => t(`admin.month.${i}`));
const today = new Date();
const [year, setYear] = useState(today.getFullYear());
const [month, setMonth] = useState(today.getMonth());
const [slots, setSlots] = useState<TimeSlotApi[]>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [selectedDay, setSelectedDay] = useState<string | null>(null);
const loadSlots = useCallback(() => {
setLoadingSlots(true);
const from = toDateStr(monthStart(year, month));
const last = new Date(year, month + 1, 0);
const to = toDateStr(last);
adminListSlots(from, to)
.then(setSlots)
.catch(() => toast.error(t("admin.error")))
.finally(() => setLoadingSlots(false));
}, [year, month]);
useEffect(() => { loadSlots(); }, [loadSlots]);
const prevMonth = () => {
if (month === 0) { setYear((y) => y - 1); setMonth(11); }
else setMonth((m) => m - 1);
setSelectedDay(null);
};
const nextMonth = () => {
if (month === 11) { setYear((y) => y + 1); setMonth(0); }
else setMonth((m) => m + 1);
setSelectedDay(null);
};
const blockSlot = async (slot: TimeSlotApi) => {
try {
const updated = await adminUpdateSlot(slot.id, !slot.is_blocked);
setSlots((prev) => prev.map((s) => s.id === slot.id ? updated : s));
toast.success(slot.is_blocked ? t("admin.planning.slot_unblocked") : t("admin.planning.slot_blocked"));
} catch {
toast.error(t("admin.error"));
}
};
const deleteSlot = async (id: string) => {
try {
await adminDeleteSlot(id);
setSlots((prev) => prev.filter((s) => s.id !== id));
toast.success(t("admin.planning.slot_deleted"));
} catch {
toast.error(t("admin.error"));
}
};
const firstDay = monthStart(year, month);
const totalDays = daysInMonth(year, month);
const startOffset = weekdayMon(firstDay);
const cells: (number | null)[] = [
...Array(startOffset).fill(null),
...Array.from({ length: totalDays }, (_, i) => i + 1),
];
while (cells.length % 7 !== 0) cells.push(null);
const slotsByDay: Record<string, TimeSlotApi[]> = {};
slots.forEach((s) => {
(slotsByDay[s.date] ||= []).push(s);
});
const selectedSlots = selectedDay ? (slotsByDay[selectedDay] ?? []) : [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button variant="outline" size="icon" onClick={prevMonth}><ChevronLeft className="h-4 w-4" /></Button>
<h3 className="font-medium">{MONTH_NAMES[month]} {year}</h3>
<Button variant="outline" size="icon" onClick={nextMonth}><ChevronRight className="h-4 w-4" /></Button>
</div>
<Card>
<CardContent className="p-3">
<div className="grid grid-cols-7 gap-px">
{DAY_NAMES.map((d) => (
<div key={d} className="text-center text-xs font-medium text-muted-foreground py-2">
{d.slice(0, 3)}
</div>
))}
{cells.map((day, i) => {
if (!day) return <div key={i} />;
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const daySlots = slotsByDay[dateStr] ?? [];
const available = daySlots.filter((s) => !s.is_blocked && !s.is_booked).length;
const booked = daySlots.filter((s) => s.is_booked).length;
const blocked = daySlots.filter((s) => s.is_blocked).length;
const isToday = dateStr === toDateStr(today);
const isSelected = dateStr === selectedDay;
return (
<button
key={i}
onClick={() => setSelectedDay(isSelected ? null : dateStr)}
className={`rounded-lg p-1.5 text-left transition-colors min-h-[60px] ${
isSelected ? "bg-primary/10 ring-1 ring-primary" :
isToday ? "bg-muted ring-1 ring-muted-foreground/30" : "hover:bg-muted/50"
}`}
>
<div className={`text-xs font-medium mb-1 ${isToday ? "text-primary" : ""}`}>{day}</div>
{daySlots.length > 0 && (
<div className="space-y-0.5">
{available > 0 && <div className="text-[10px] text-green-600">{available} {t("admin.status.free").toLowerCase()}</div>}
{booked > 0 && <div className="text-[10px] text-blue-600">{booked} {t("admin.status.booked").toLowerCase()}</div>}
{blocked > 0 && <div className="text-[10px] text-red-500">{blocked} {t("admin.status.blocked").toLowerCase()}</div>}
</div>
)}
</button>
);
})}
</div>
</CardContent>
</Card>
{selectedDay && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">
{t("admin.planning.slots_for")} {new Date(selectedDay + "T00:00:00").toLocaleDateString(locale, {
weekday: "long", day: "numeric", month: "long"
})}
</CardTitle>
</CardHeader>
<CardContent>
{loadingSlots ? (
<div className="text-sm text-muted-foreground">{t("admin.loading")}</div>
) : selectedSlots.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("admin.planning.no_slots")}</p>
) : (
<div className="space-y-2">
{selectedSlots
.sort((a, b) => a.start_time.localeCompare(b.start_time))
.map((slot) => (
<div key={slot.id} className="flex items-center justify-between py-2 border-b border-border last:border-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{slot.start_time.slice(0, 5)} {slot.end_time.slice(0, 5)}</span>
{slot.is_booked && <Badge variant="secondary" className="text-xs">{t("admin.status.booked")}</Badge>}
{slot.is_blocked && <Badge variant="destructive" className="text-xs">{t("admin.status.blocked")}</Badge>}
{!slot.is_booked && !slot.is_blocked && <Badge variant="outline" className="text-xs text-green-600 border-green-300">{t("admin.status.free")}</Badge>}
</div>
{!slot.is_booked && (
<div className="flex gap-1">
<Button
variant="ghost" size="icon"
className="h-7 w-7"
title={slot.is_blocked ? t("admin.planning.unblock_title") : t("admin.planning.block_btn")}
onClick={() => blockSlot(slot)}
>
{slot.is_blocked
? <Check className="h-3.5 w-3.5 text-green-600" />
: <Ban className="h-3.5 w-3.5 text-amber-500" />
}
</Button>
<Button
variant="ghost" size="icon"
className="h-7 w-7"
title={t("admin.delete")}
onClick={() => deleteSlot(slot.id)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
)}
</div>
);
}
// ── Blocked Dates Tab ─────────────────────────────────────────────────────────
function BlockedDatesTab() {
const { t, locale } = useLanguage();
const [dates, setDates] = useState<BlockedDate[]>([]);
const [loading, setLoading] = useState(true);
const [newDate, setNewDate] = useState(toDateStr(new Date()));
const [newReason, setNewReason] = useState("");
const [adding, setAdding] = useState(false);
useEffect(() => {
adminGetBlockedDates()
.then(setDates)
.catch(() => toast.error(t("admin.error")))
.finally(() => setLoading(false));
}, []);
const add = async () => {
setAdding(true);
try {
const entry = await adminAddBlockedDate(newDate, newReason || undefined);
setDates((prev) => [...prev, entry].sort((a, b) => a.date.localeCompare(b.date)));
setNewReason("");
toast.success(t("admin.planning.date_blocked"));
} catch {
toast.error(t("admin.error"));
} finally {
setAdding(false);
}
};
const remove = async (id: string) => {
try {
await adminRemoveBlockedDate(id);
setDates((prev) => prev.filter((d) => d.id !== id));
toast.success(t("admin.planning.date_unblocked"));
} catch {
toast.error(t("admin.error"));
}
};
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base">{t("admin.planning.block_title")}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-3">{t("admin.planning.block_desc")}</p>
<div className="flex flex-wrap gap-3 items-end">
<div>
<Label className="text-xs">{t("admin.planning.date_lbl")}</Label>
<Input type="date" className="mt-1 w-44" value={newDate}
onChange={(e) => setNewDate(e.target.value)} />
</div>
<div>
<Label className="text-xs">{t("admin.planning.reason")}</Label>
<Input className="mt-1 w-56" placeholder={t("admin.planning.reason_ph")} value={newReason}
onChange={(e) => setNewReason(e.target.value)} />
</div>
<Button onClick={add} disabled={adding} size="sm">
<Plus className="h-4 w-4 mr-1.5" />
{adding ? t("admin.planning.blocking") : t("admin.planning.block_btn")}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("admin.planning.blocked_list")}</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-10 bg-muted animate-pulse rounded" />
))}
</div>
) : dates.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("admin.planning.no_blocked")}</p>
) : (
<div className="space-y-2">
{dates.map((d) => (
<div key={d.id} className="flex items-center justify-between py-2 border-b border-border last:border-0">
<div>
<span className="text-sm font-medium">
{new Date(d.date + "T00:00:00").toLocaleDateString(locale, {
weekday: "long", day: "numeric", month: "long", year: "numeric",
})}
</span>
{d.reason && <span className="text-xs text-muted-foreground ml-2"> {d.reason}</span>}
</div>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => remove(d.id)}>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
type Tab = "schedule" | "calendar" | "blocked";
export default function PlanningPage() {
const { t } = useLanguage();
const [tab, setTab] = useState<Tab>("schedule");
const tabs: { id: Tab; label: string }[] = [
{ id: "schedule", label: t("admin.planning.tab_schedule") },
{ id: "calendar", label: t("admin.planning.tab_calendar") },
{ id: "blocked", label: t("admin.planning.tab_blocked") },
];
return (
<div className="space-y-6">
<div>
<h2 className="font-serif text-2xl font-semibold">{t("admin.planning.title")}</h2>
<p className="text-sm text-muted-foreground mt-1">{t("admin.planning.subtitle")}</p>
</div>
<div className="flex gap-1 bg-muted p-1 rounded-lg w-fit">
{tabs.map((item) => (
<button
key={item.id}
onClick={() => setTab(item.id)}
className={`px-4 py-1.5 text-sm rounded-md transition-colors ${
tab === item.id
? "bg-background shadow-sm font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
{item.label}
</button>
))}
</div>
{tab === "schedule" && <ScheduleTab />}
{tab === "calendar" && <CalendarTab />}
{tab === "blocked" && <BlockedDatesTab />}
</div>
);
}