255 lines
9.2 KiB
JavaScript
255 lines
9.2 KiB
JavaScript
/**
|
||
* @file BusinessSchedulePanel.jsx
|
||
* @description Admin panel for configuring work days: delivery, pickup, SMS.
|
||
* Days stored in business_schedule_settings table as INT[] (1=Mon ... 7=Sun).
|
||
*/
|
||
import React, { useState, useEffect, useCallback } from "react";
|
||
import { Panel } from "../UI/Panel";
|
||
import { Button } from "../UI/Button";
|
||
import { Badge } from "../UI/Badge";
|
||
import { supabase } from "../../supabaseClient";
|
||
|
||
const DAY_LABELS = [
|
||
{ num: 1, label: "Пн", full: "Понедельник" },
|
||
{ num: 2, label: "Вт", full: "Вторник" },
|
||
{ num: 3, label: "Ср", full: "Среда" },
|
||
{ num: 4, label: "Чт", full: "Четверг" },
|
||
{ num: 5, label: "Пт", full: "Пятница" },
|
||
{ num: 6, label: "Сб", full: "Суббота" },
|
||
{ num: 7, label: "Вс", full: "Воскресенье" },
|
||
];
|
||
|
||
const DEFAULT_SCHEDULE = {
|
||
deliveryDays: [1, 2, 3, 4, 5],
|
||
pickupDays: [1, 2, 3, 4, 5],
|
||
smsDays: [1, 2, 3, 4, 5],
|
||
};
|
||
|
||
const DayPills = ({ selectedDays, onToggle, accentColor }) => {
|
||
const toggle = (num) => {
|
||
if (selectedDays.includes(num)) {
|
||
onToggle(selectedDays.filter((d) => d !== num).sort());
|
||
} else {
|
||
onToggle([...selectedDays, num].sort());
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-wrap gap-2">
|
||
{DAY_LABELS.map((day) => {
|
||
const isSelected = selectedDays.includes(day.num);
|
||
return (
|
||
<button
|
||
key={day.num}
|
||
type="button"
|
||
onClick={() => toggle(day.num)}
|
||
aria-pressed={isSelected}
|
||
title={day.full}
|
||
className={[
|
||
"min-w-[48px] rounded-[14px] border-2 px-3 py-2.5 text-center text-sm font-semibold transition select-none",
|
||
isSelected
|
||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||
: "border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
|
||
].join(" ")}
|
||
>
|
||
{day.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export const BusinessSchedulePanel = () => {
|
||
const [schedule, setSchedule] = useState(DEFAULT_SCHEDULE);
|
||
const [original, setOriginal] = useState(DEFAULT_SCHEDULE);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [savedAt, setSavedAt] = useState(null);
|
||
|
||
const loadSchedule = useCallback(async () => {
|
||
setIsLoading(true);
|
||
setError("");
|
||
try {
|
||
const { data, error: rpcError } = await supabase.rpc("get_business_schedule");
|
||
if (rpcError) throw rpcError;
|
||
if (data?.ok) {
|
||
const loaded = {
|
||
deliveryDays: data.deliveryDays || DEFAULT_SCHEDULE.deliveryDays,
|
||
pickupDays: data.pickupDays || DEFAULT_SCHEDULE.pickupDays,
|
||
smsDays: data.smsDays || DEFAULT_SCHEDULE.smsDays,
|
||
};
|
||
setSchedule(loaded);
|
||
setOriginal(loaded);
|
||
}
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Не удалось загрузить расписание");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadSchedule();
|
||
}, [loadSchedule]);
|
||
|
||
const hasChanges =
|
||
JSON.stringify(schedule.deliveryDays) !== JSON.stringify(original.deliveryDays) ||
|
||
JSON.stringify(schedule.pickupDays) !== JSON.stringify(original.pickupDays) ||
|
||
JSON.stringify(schedule.smsDays) !== JSON.stringify(original.smsDays);
|
||
|
||
const handleSave = async () => {
|
||
if (!hasChanges) return;
|
||
|
||
// Validate at least one day per category
|
||
if (schedule.deliveryDays.length === 0 || schedule.pickupDays.length === 0 || schedule.smsDays.length === 0) {
|
||
setError("Каждая категория должна иметь хотя бы один рабочий день");
|
||
return;
|
||
}
|
||
|
||
setIsSaving(true);
|
||
setError("");
|
||
try {
|
||
const { data, error: rpcError } = await supabase.rpc("update_business_schedule", {
|
||
p_delivery_days: schedule.deliveryDays,
|
||
p_pickup_days: schedule.pickupDays,
|
||
p_sms_days: schedule.smsDays,
|
||
});
|
||
if (rpcError) throw rpcError;
|
||
if (data?.ok) {
|
||
setOriginal({ ...schedule });
|
||
setSavedAt(new Date());
|
||
}
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Не удалось сохранить расписание");
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
const formatDays = (days) => {
|
||
return days
|
||
.sort((a, b) => a - b)
|
||
.map((d) => DAY_LABELS.find((dl) => dl.num === d)?.label)
|
||
.filter(Boolean)
|
||
.join(", ");
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<Panel className="p-5 sm:p-6">
|
||
<p className="text-sm text-[var(--color-text-muted)]">Загрузка расписания…</p>
|
||
</Panel>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div>
|
||
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Расписание</p>
|
||
<h1 className="mt-1 text-2xl font-bold leading-tight">Рабочие дни</h1>
|
||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||
Настройте, в какие дни доступны доставка, самовывоз и отправка SMS-приглашений.
|
||
</p>
|
||
</div>
|
||
{savedAt && !hasChanges && (
|
||
<Badge tone="accent">Сохранено</Badge>
|
||
)}
|
||
</div>
|
||
|
||
{/* Delivery days */}
|
||
<Panel className="p-5 sm:p-6 space-y-4">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xl">🚚</span>
|
||
<div>
|
||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||
Доставка
|
||
</h2>
|
||
<p className="text-xs text-[var(--color-text-muted)]">
|
||
Дни, в которые клиент может выбрать доставку. В остальные дни доставка не предлагается.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<DayPills
|
||
selectedDays={schedule.deliveryDays}
|
||
onToggle={(days) => setSchedule((s) => ({ ...s, deliveryDays: days }))}
|
||
/>
|
||
<p className="text-xs text-[var(--color-text-muted)]">
|
||
Выбрано: {formatDays(schedule.deliveryDays) || "—"}
|
||
</p>
|
||
</Panel>
|
||
|
||
{/* Pickup days */}
|
||
<Panel className="p-5 sm:p-6 space-y-4">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xl">🏪</span>
|
||
<div>
|
||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||
Самовывоз
|
||
</h2>
|
||
<p className="text-xs text-[var(--color-text-muted)]">
|
||
Дни, в которые клиент может выбрать самовывоз. В остальные дни самовывоз не предлагается.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<DayPills
|
||
selectedDays={schedule.pickupDays}
|
||
onToggle={(days) => setSchedule((s) => ({ ...s, pickupDays: days }))}
|
||
/>
|
||
<p className="text-xs text-[var(--color-text-muted)]">
|
||
Выбрано: {formatDays(schedule.pickupDays) || "—"}
|
||
</p>
|
||
</Panel>
|
||
|
||
{/* SMS days */}
|
||
<Panel className="p-5 sm:p-6 space-y-4">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xl">📨</span>
|
||
<div>
|
||
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||
SMS-приглашения
|
||
</h2>
|
||
<p className="text-xs text-[var(--color-text-muted)]">
|
||
Дни, в которые отправляются SMS-приглашения клиентам. В нерабочие дни SMS не отправляются (проверка статусов работает круглосуточно).
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<DayPills
|
||
selectedDays={schedule.smsDays}
|
||
onToggle={(days) => setSchedule((s) => ({ ...s, smsDays: days }))}
|
||
/>
|
||
<p className="text-xs text-[var(--color-text-muted)]">
|
||
Выбрано: {formatDays(schedule.smsDays) || "—"}
|
||
</p>
|
||
</Panel>
|
||
|
||
{/* Save */}
|
||
<div className="flex items-center gap-4">
|
||
<Button
|
||
variant={hasChanges ? "primary" : "ghost"}
|
||
onClick={handleSave}
|
||
disabled={!hasChanges || isSaving}
|
||
>
|
||
{isSaving ? "Сохранение…" : "Сохранить расписание"}
|
||
</Button>
|
||
{hasChanges && !isSaving && (
|
||
<Button
|
||
variant="ghost"
|
||
onClick={() => setSchedule({ ...original })}
|
||
>
|
||
Отменить изменения
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{error && (
|
||
<Panel className="border border-[var(--color-danger)] bg-[var(--color-surface)] p-4 text-sm text-[var(--color-danger)]">
|
||
{error}
|
||
</Panel>
|
||
)}
|
||
</div>
|
||
);
|
||
}; |