feat: upgrade reminders tab — group by event, status tags, amber "Нет ответа"
- Group reminders by event within each day (e.g. "Master class · 16:00") - Stats (придёт/не придёт/нет ответа/не спрошены) shown per event, not per day - People separated by status with colored tag labels for easy scanning - "Нет ответа" now amber when active (was neutral gray, confused with unselected) - Cancelled people faded (opacity-50) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -340,7 +340,7 @@ interface ReminderItem {
|
|||||||
type ReminderStatus = "pending" | "coming" | "cancelled";
|
type ReminderStatus = "pending" | "coming" | "cancelled";
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<ReminderStatus, { label: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }> = {
|
const STATUS_CONFIG: Record<ReminderStatus, { label: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }> = {
|
||||||
pending: { label: "Нет ответа", icon: Clock, color: "text-neutral-400", bg: "bg-neutral-500/10", border: "border-neutral-500/20" },
|
pending: { label: "Нет ответа", icon: Clock, color: "text-amber-400", bg: "bg-amber-500/10", border: "border-amber-500/20" },
|
||||||
coming: { label: "Придёт", icon: CheckCircle2, color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
|
coming: { label: "Придёт", icon: CheckCircle2, color: "text-emerald-400", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
|
||||||
cancelled: { label: "Не придёт", icon: XCircle, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20" },
|
cancelled: { label: "Не придёт", icon: XCircle, color: "text-red-400", bg: "bg-red-500/10", border: "border-red-500/20" },
|
||||||
};
|
};
|
||||||
@@ -399,15 +399,89 @@ function RemindersTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group items by event within each day
|
||||||
|
function groupByEvent(dayItems: ReminderItem[]) {
|
||||||
|
const map: Record<string, { type: ReminderItem["type"]; label: string; items: ReminderItem[] }> = {};
|
||||||
|
for (const item of dayItems) {
|
||||||
|
const key = `${item.type}|${item.eventLabel}`;
|
||||||
|
if (!map[key]) map[key] = { type: item.type, label: item.eventLabel, items: [] };
|
||||||
|
map[key].items.push(item);
|
||||||
|
}
|
||||||
|
return Object.values(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_SECTIONS = [
|
||||||
|
{ key: "not-asked", label: "Не спрошены", color: "text-gold", bg: "bg-gold/10", match: (i: ReminderItem) => !i.reminderStatus },
|
||||||
|
{ key: "pending", label: "Нет ответа", color: "text-amber-400", bg: "bg-amber-500/10", match: (i: ReminderItem) => i.reminderStatus === "pending" },
|
||||||
|
{ key: "coming", label: "Придёт", color: "text-emerald-400", bg: "bg-emerald-500/10", match: (i: ReminderItem) => i.reminderStatus === "coming" },
|
||||||
|
{ key: "cancelled", label: "Не придёт", color: "text-red-400", bg: "bg-red-500/10", match: (i: ReminderItem) => i.reminderStatus === "cancelled" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function renderPerson(item: ReminderItem) {
|
||||||
|
const currentStatus = item.reminderStatus as ReminderStatus | undefined;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${item.table}-${item.id}`}
|
||||||
|
className={`rounded-lg border p-3 transition-colors ${
|
||||||
|
!currentStatus ? "border-gold/20 bg-gold/[0.03]"
|
||||||
|
: currentStatus === "coming" ? "border-emerald-500/15 bg-emerald-500/[0.02]"
|
||||||
|
: currentStatus === "cancelled" ? "border-red-500/15 bg-red-500/[0.02] opacity-50"
|
||||||
|
: currentStatus === "pending" ? "border-amber-500/15 bg-amber-500/[0.02]"
|
||||||
|
: "border-white/5 bg-neutral-800/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-sm">
|
||||||
|
<span className="font-medium text-white">{item.name}</span>
|
||||||
|
{item.phone && (
|
||||||
|
<a href={`tel:${item.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
||||||
|
<Phone size={10} />{item.phone}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{item.instagram && (
|
||||||
|
<a href={`https://ig.me/m/${item.instagram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs">
|
||||||
|
<Instagram size={10} />{item.instagram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{item.telegram && (
|
||||||
|
<a href={`https://t.me/${item.telegram.replace(/^@/, "")}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs">
|
||||||
|
<Send size={10} />{item.telegram}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1 ml-auto">
|
||||||
|
{(["coming", "pending", "cancelled"] as ReminderStatus[]).map((st) => {
|
||||||
|
const conf = STATUS_CONFIG[st];
|
||||||
|
const Icon = conf.icon;
|
||||||
|
const active = currentStatus === st;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={st}
|
||||||
|
onClick={() => setStatus(item, active ? null : st)}
|
||||||
|
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium transition-all ${
|
||||||
|
active
|
||||||
|
? `${conf.bg} ${conf.color} border ${conf.border}`
|
||||||
|
: "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={10} />
|
||||||
|
{conf.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{[
|
{[
|
||||||
{ label: "Сегодня", date: today, items: todayItems },
|
{ label: "Сегодня", date: today, items: todayItems },
|
||||||
{ label: "Завтра", date: tomorrow, items: tomorrowItems },
|
{ label: "Завтра", date: tomorrow, items: tomorrowItems },
|
||||||
]
|
]
|
||||||
.filter((group) => group.items.length > 0)
|
.filter((g) => g.items.length > 0)
|
||||||
.map((group) => {
|
.map((group) => {
|
||||||
const stats = countByStatus(group.items);
|
const eventGroups = groupByEvent(group.items);
|
||||||
return (
|
return (
|
||||||
<div key={group.date}>
|
<div key={group.date}>
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex items-center gap-3 mb-3">
|
||||||
@@ -415,90 +489,41 @@ function RemindersTab() {
|
|||||||
<span className="text-[10px] text-neutral-500">
|
<span className="text-[10px] text-neutral-500">
|
||||||
{new Date(group.date + "T12:00").toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" })}
|
{new Date(group.date + "T12:00").toLocaleDateString("ru-RU", { weekday: "long", day: "numeric", month: "long" })}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2 ml-auto text-[10px]">
|
|
||||||
{stats.coming > 0 && <span className="text-emerald-400">{stats.coming} придёт</span>}
|
|
||||||
{stats.cancelled > 0 && <span className="text-red-400">{stats.cancelled} не придёт</span>}
|
|
||||||
{stats.pending > 0 && <span className="text-neutral-400">{stats.pending} нет ответа</span>}
|
|
||||||
{stats.notAsked > 0 && <span className="text-gold">{stats.notAsked} не спрошены</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-3">
|
||||||
{group.items.map((item) => {
|
{eventGroups.map((eg) => {
|
||||||
const typeConf = TYPE_CONFIG[item.type];
|
const typeConf = TYPE_CONFIG[eg.type];
|
||||||
const TypeIcon = typeConf.icon;
|
const TypeIcon = typeConf.icon;
|
||||||
const currentStatus = item.reminderStatus as ReminderStatus | undefined;
|
const egStats = countByStatus(eg.items);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={eg.label} className="rounded-xl border border-white/10 overflow-hidden">
|
||||||
key={`${item.table}-${item.id}`}
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-neutral-900">
|
||||||
className={`rounded-xl border p-3 transition-colors ${
|
<TypeIcon size={13} className={typeConf.color} />
|
||||||
!currentStatus
|
<span className="text-sm font-medium text-white">{eg.label}</span>
|
||||||
? "border-gold/20 bg-gold/[0.03]"
|
<span className="text-[10px] text-neutral-500 bg-neutral-800 rounded-full px-2 py-0.5">{eg.items.length} чел.</span>
|
||||||
: currentStatus === "coming"
|
<div className="flex gap-2 ml-auto text-[10px]">
|
||||||
? "border-emerald-500/15 bg-emerald-500/[0.02]"
|
{egStats.coming > 0 && <span className="text-emerald-400">{egStats.coming} придёт</span>}
|
||||||
: currentStatus === "cancelled"
|
{egStats.cancelled > 0 && <span className="text-red-400">{egStats.cancelled} не придёт</span>}
|
||||||
? "border-red-500/15 bg-red-500/[0.02] opacity-60"
|
{egStats.pending > 0 && <span className="text-amber-400">{egStats.pending} нет ответа</span>}
|
||||||
: "border-white/10 bg-neutral-900"
|
{egStats.notAsked > 0 && <span className="text-gold">{egStats.notAsked} не спрошены</span>}
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 flex-wrap text-sm">
|
|
||||||
<span className="font-medium text-white">{item.name}</span>
|
|
||||||
{item.phone && (
|
|
||||||
<a href={`tel:${item.phone}`} className="inline-flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs">
|
|
||||||
<Phone size={10} />{item.phone}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{item.instagram && (
|
|
||||||
<a
|
|
||||||
href={`https://ig.me/m/${item.instagram.replace(/^@/, "")}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 text-pink-400 hover:text-pink-300 text-xs"
|
|
||||||
>
|
|
||||||
<Instagram size={10} />{item.instagram}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
{item.telegram && (
|
|
||||||
<a
|
|
||||||
href={`https://t.me/${item.telegram.replace(/^@/, "")}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs"
|
|
||||||
>
|
|
||||||
<Send size={10} />{item.telegram}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
|
||||||
<span className={`inline-flex items-center gap-1 text-[10px] ${typeConf.color}`}>
|
|
||||||
<TypeIcon size={10} />
|
|
||||||
{item.eventLabel}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className="flex gap-1 ml-auto">
|
|
||||||
{(["coming", "pending", "cancelled"] as ReminderStatus[]).map((st) => {
|
|
||||||
const conf = STATUS_CONFIG[st];
|
|
||||||
const Icon = conf.icon;
|
|
||||||
const active = currentStatus === st;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={st}
|
|
||||||
onClick={() => setStatus(item, active ? null : st)}
|
|
||||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium transition-all ${
|
|
||||||
active
|
|
||||||
? `${conf.bg} ${conf.color} border ${conf.border}`
|
|
||||||
: "bg-neutral-800/50 text-neutral-500 border border-transparent hover:border-white/10 hover:text-neutral-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Icon size={10} />
|
|
||||||
{conf.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="px-4 pb-3 pt-1">
|
||||||
|
{STATUS_SECTIONS
|
||||||
|
.map((sec) => ({ ...sec, items: eg.items.filter(sec.match) }))
|
||||||
|
.filter((sec) => sec.items.length > 0)
|
||||||
|
.map((sec) => (
|
||||||
|
<div key={sec.key} className="mt-2 first:mt-0">
|
||||||
|
<span className={`text-[10px] font-medium ${sec.color} ${sec.bg} rounded-full px-2 py-0.5`}>
|
||||||
|
{sec.label} · {sec.items.length}
|
||||||
|
</span>
|
||||||
|
<div className="mt-1.5 space-y-1.5">
|
||||||
|
{sec.items.map(renderPerson)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user