TorbatYar/frontend/modules/workspace/features/contentCalendar.tsx
Mortezakoohjani a483b47369 feat(workspace): complete production workspace and content operations
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 11:54:17 +03:30

199 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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;