199 lines
8.9 KiB
TypeScript
199 lines
8.9 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import dayjs from "dayjs";
|
||
import { toast } from "sonner";
|
||
import { Button, Card, CardContent, PageHeader, Select } from "@/components/ds";
|
||
import { useCurrentHub } from "@/modules/workspace/hooks/useCurrentHub";
|
||
import { workspaceApi } from "@/modules/workspace/services/workspace-api";
|
||
import { CHANNEL, CONTENT_STATUS, CONTENT_TYPE, displayPerson, formatWhen, labelOf } from "@/modules/workspace/labels";
|
||
import { StatusChip } from "@/modules/workspace/components/StatusChip";
|
||
import { WorkspaceEmptyState, WorkspacePageError, WorkspacePageLoader } from "@/modules/workspace/pages/shared";
|
||
import type { WorkspaceRecord } from "@/modules/workspace/types";
|
||
|
||
type View = "month" | "week" | "list";
|
||
|
||
function startOfWeek(date: dayjs.Dayjs) {
|
||
const weekday = date.day();
|
||
return date.subtract((weekday + 1) % 7, "day").startOf("day");
|
||
}
|
||
|
||
export const WorkspaceContentCalendar = function WorkspaceContentCalendarPage() {
|
||
const queryClient = useQueryClient();
|
||
const { tenantId, hubId, loading } = useCurrentHub();
|
||
const [view, setView] = useState<View>("month");
|
||
const [cursor, setCursor] = useState(dayjs());
|
||
const [channel, setChannel] = useState("");
|
||
const [status, setStatus] = useState("");
|
||
const [contentType, setContentType] = useState("");
|
||
const projects = useQuery({
|
||
queryKey: ["workspace", tenantId, "projects", hubId],
|
||
queryFn: () => workspaceApi.projects.list(tenantId!, { work_hub_id: hubId! }),
|
||
enabled: !!tenantId && !!hubId,
|
||
});
|
||
const projectNames = Object.fromEntries(
|
||
(projects.data ?? []).map((project) => [String(project.id), String(project.name || "پروژه")]),
|
||
);
|
||
const items = useQuery({
|
||
queryKey: ["workspace", tenantId, "calendar", hubId, channel, status, contentType],
|
||
queryFn: () =>
|
||
workspaceApi.content.calendar(tenantId!, {
|
||
work_hub_id: hubId!,
|
||
channel: channel || undefined,
|
||
status: status || undefined,
|
||
content_type: contentType || undefined,
|
||
}),
|
||
enabled: !!tenantId && !!hubId,
|
||
});
|
||
const reschedule = useMutation({
|
||
mutationFn: ({ id, scheduled_at }: { id: string; scheduled_at: string }) =>
|
||
workspaceApi.content.update(tenantId!, id, { scheduled_at }),
|
||
onSuccess: () => {
|
||
toast.success("زمانبندی ذخیره شد");
|
||
void queryClient.invalidateQueries({ queryKey: ["workspace", tenantId, "calendar"] });
|
||
},
|
||
onError: (err) => toast.error(err instanceof Error ? err.message : "تغییر زمانبندی ناموفق بود"),
|
||
});
|
||
|
||
const days = useMemo(() => {
|
||
if (view === "week") {
|
||
const start = startOfWeek(cursor);
|
||
return Array.from({ length: 7 }, (_, index) => start.add(index, "day"));
|
||
}
|
||
const start = cursor.startOf("month");
|
||
const gridStart = startOfWeek(start);
|
||
return Array.from({ length: 42 }, (_, index) => gridStart.add(index, "day"));
|
||
}, [cursor, view]);
|
||
|
||
const byDay = useMemo(() => {
|
||
const map = new Map<string, WorkspaceRecord[]>();
|
||
for (const item of items.data ?? []) {
|
||
const key = item.scheduled_at ? dayjs(String(item.scheduled_at)).format("YYYY-MM-DD") : "";
|
||
if (!key) continue;
|
||
map.set(key, [...(map.get(key) ?? []), item]);
|
||
}
|
||
return map;
|
||
}, [items.data]);
|
||
|
||
if (!tenantId || loading || items.isLoading) return <WorkspacePageLoader />;
|
||
if (!hubId) return <WorkspaceEmptyState title="مرکز کار لازم است" description="ابتدا مرکز کار را انتخاب کنید." />;
|
||
if (items.error) return <WorkspacePageError error={items.error} onRetry={() => items.refetch()} />;
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<PageHeader
|
||
title="تقویم محتوا"
|
||
description="آیتمهای زمانبندیشده. کشیدن کارت به روز دیگر، زمان را در سرور ذخیره میکند."
|
||
actions={
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button variant="outline" onClick={() => setCursor((value) => value.subtract(1, view === "week" ? "week" : "month"))}>
|
||
قبلی
|
||
</Button>
|
||
<Button variant="outline" onClick={() => setCursor(dayjs())}>
|
||
امروز
|
||
</Button>
|
||
<Button variant="outline" onClick={() => setCursor((value) => value.add(1, view === "week" ? "week" : "month"))}>
|
||
بعدی
|
||
</Button>
|
||
</div>
|
||
}
|
||
/>
|
||
<div className="flex flex-wrap gap-2">
|
||
{(["month", "week", "list"] as View[]).map((mode) => (
|
||
<Button key={mode} variant={view === mode ? "default" : "outline"} onClick={() => setView(mode)}>
|
||
{mode === "month" ? "ماه" : mode === "week" ? "هفته" : "فهرست"}
|
||
</Button>
|
||
))}
|
||
<Select value={channel} onChange={(event) => setChannel(event.target.value)}>
|
||
<option value="">همه کانالها</option>
|
||
{Object.entries(CHANNEL).map(([value, label]) => (
|
||
<option key={value} value={value}>
|
||
{label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
<Select value={contentType} onChange={(event) => setContentType(event.target.value)}>
|
||
<option value="">همه انواع</option>
|
||
{Object.entries(CONTENT_TYPE).map(([value, label]) => (
|
||
<option key={value} value={value}>
|
||
{label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
<Select value={status} onChange={(event) => setStatus(event.target.value)}>
|
||
<option value="">همه وضعیتها</option>
|
||
{Object.entries(CONTENT_STATUS).map(([value, label]) => (
|
||
<option key={value} value={value}>
|
||
{label}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
</div>
|
||
{view === "list" ? (
|
||
<div className="space-y-2">
|
||
{(items.data ?? []).length === 0 ? (
|
||
<WorkspaceEmptyState title="زمانبندیای نیست" description="محتوا را زمانبندی کنید تا در تقویم دیده شود." />
|
||
) : (
|
||
(items.data ?? []).map((item) => (
|
||
<Card key={String(item.id)}>
|
||
<CardContent className="flex flex-wrap items-center justify-between gap-3 p-4">
|
||
<div>
|
||
<Link href={`/workspace/content/${item.id}`} className="font-medium text-secondary">
|
||
{String(item.title || "محتوا")}
|
||
</Link>
|
||
<p className="text-xs text-[var(--muted)]">
|
||
{labelOf(CONTENT_TYPE, String(item.content_type || ""))} · {labelOf(CHANNEL, String(item.channel || ""))} ·{" "}
|
||
{projectNames[String(item.project_id || "")] || "بدون پروژه"} ·{" "}
|
||
{displayPerson(String(item.owner_user_id || ""))}
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<StatusChip value={String(item.status || "")} />
|
||
<span className="text-sm text-[var(--muted)]">{formatWhen(String(item.scheduled_at || ""))}</span>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-7 gap-2">
|
||
{days.map((day) => {
|
||
const key = day.format("YYYY-MM-DD");
|
||
return (
|
||
<div
|
||
key={key}
|
||
className="min-h-[120px] rounded-xl border border-[var(--border)] bg-[var(--surface)] p-2"
|
||
onDragOver={(event) => event.preventDefault()}
|
||
onDrop={(event) => {
|
||
const id = event.dataTransfer.getData("text/content-id");
|
||
if (id) reschedule.mutate({ id, scheduled_at: `${key}T10:00:00` });
|
||
}}
|
||
>
|
||
<p className="mb-2 text-xs text-[var(--muted)]">{day.format("D MMM")}</p>
|
||
<div className="space-y-1">
|
||
{(byDay.get(key) ?? []).map((item) => (
|
||
<button
|
||
key={String(item.id)}
|
||
type="button"
|
||
draggable
|
||
onDragStart={(event) => event.dataTransfer.setData("text/content-id", String(item.id))}
|
||
className="block w-full rounded-lg bg-[var(--surface-muted)] px-2 py-1 text-right text-xs"
|
||
>
|
||
{String(item.title || "محتوا")}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default WorkspaceContentCalendar;
|