298 lines
13 KiB
TypeScript
298 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useParams, useRouter } from "next/navigation";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { toast } from "sonner";
|
||
import { Button, Card, CardContent, ConfirmDialog, Dialog, Input, PageHeader, Select, Textarea } from "@/components/ds";
|
||
import { useTenantId } from "@/hooks/useTenantId";
|
||
import { workspaceApi } from "@/modules/workspace/services/workspace-api";
|
||
import {
|
||
APPROVAL_TYPE,
|
||
BRIEF_FIELDS,
|
||
CONTENT_STATUS,
|
||
contentActionLabel,
|
||
displayPerson,
|
||
formatWhen,
|
||
labelOf,
|
||
} from "@/modules/workspace/labels";
|
||
import { StatusChip } from "@/modules/workspace/components/StatusChip";
|
||
import { CommentThread } from "@/modules/workspace/components/CommentThread";
|
||
import { IdentityUserSelect } from "@/modules/workspace/components/IdentityUserSelect";
|
||
import { WorkspacePageError, WorkspacePageLoader } from "@/modules/workspace/pages/shared";
|
||
|
||
const HIDDEN_FROM_REVIEW = new Set(["approved"]);
|
||
|
||
export const WorkspaceContentDetail = function WorkspaceContentDetailPage() {
|
||
const params = useParams<{ id: string }>();
|
||
const router = useRouter();
|
||
const { tenantId } = useTenantId();
|
||
const queryClient = useQueryClient();
|
||
const [approver, setApprover] = useState("");
|
||
const [approvalType, setApprovalType] = useState("internal");
|
||
const [fileRef, setFileRef] = useState("");
|
||
const [assetTitle, setAssetTitle] = useState("");
|
||
const [scheduleOpen, setScheduleOpen] = useState(false);
|
||
const [scheduledAt, setScheduledAt] = useState("");
|
||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||
const [briefDraft, setBriefDraft] = useState<Record<string, string>>({});
|
||
const item = useQuery({
|
||
queryKey: ["workspace", tenantId, "content-item", params.id],
|
||
queryFn: () => workspaceApi.content.get(tenantId!, params.id),
|
||
enabled: !!tenantId && !!params.id,
|
||
});
|
||
const invalidate = () => {
|
||
void queryClient.invalidateQueries({ queryKey: ["workspace", tenantId, "content-item", params.id] });
|
||
void queryClient.invalidateQueries({ queryKey: ["workspace", tenantId, "content"] });
|
||
};
|
||
const update = useMutation({
|
||
mutationFn: (body: Record<string, unknown>) => workspaceApi.content.update(tenantId!, params.id, body),
|
||
onSuccess: () => {
|
||
toast.success("محتوا بهروز شد");
|
||
setScheduleOpen(false);
|
||
invalidate();
|
||
},
|
||
onError: (err) => toast.error(err instanceof Error ? err.message : "این اقدام مجاز نیست"),
|
||
});
|
||
const requestApproval = useMutation({
|
||
mutationFn: () =>
|
||
workspaceApi.approvals.request(tenantId!, {
|
||
content_id: params.id,
|
||
approval_type: approvalType,
|
||
approver_user_id: approver || item.data?.reviewer_user_id,
|
||
}),
|
||
onSuccess: () => {
|
||
toast.success("درخواست تأیید ثبت شد");
|
||
invalidate();
|
||
},
|
||
onError: (err) => toast.error(err instanceof Error ? err.message : "درخواست تأیید ناموفق بود"),
|
||
});
|
||
const respond = useMutation({
|
||
mutationFn: ({ id, status, comment }: { id: string; status: string; comment?: string }) =>
|
||
workspaceApi.approvals.respond(tenantId!, id, { status, comment }),
|
||
onSuccess: () => {
|
||
toast.success("پاسخ تأیید ذخیره شد");
|
||
invalidate();
|
||
},
|
||
onError: (err) => toast.error(err instanceof Error ? err.message : "ثبت پاسخ ناموفق بود"),
|
||
});
|
||
const addAsset = useMutation({
|
||
mutationFn: () =>
|
||
workspaceApi.assets.create(tenantId!, {
|
||
work_hub_id: item.data?.work_hub_id,
|
||
content_id: params.id,
|
||
file_ref: fileRef,
|
||
title: assetTitle || fileRef,
|
||
}),
|
||
onSuccess: () => {
|
||
toast.success("ارجاع فایل ثبت شد");
|
||
setFileRef("");
|
||
setAssetTitle("");
|
||
invalidate();
|
||
},
|
||
onError: (err) => toast.error(err instanceof Error ? err.message : "ثبت ارجاع ناموفق بود"),
|
||
});
|
||
const duplicate = useMutation({
|
||
mutationFn: () => {
|
||
const row = item.data!;
|
||
return workspaceApi.content.create(tenantId!, {
|
||
work_hub_id: row.work_hub_id,
|
||
project_id: row.project_id,
|
||
team_id: row.team_id,
|
||
title: `${row.title || "محتوا"} (رونوشت)`,
|
||
content_type: row.content_type,
|
||
channel: row.channel,
|
||
brief: row.brief,
|
||
owner_user_id: row.owner_user_id,
|
||
reviewer_user_id: row.reviewer_user_id,
|
||
});
|
||
},
|
||
onSuccess: (created) => {
|
||
toast.success("رونوشت ایجاد شد");
|
||
if (created.id) router.push(`/workspace/content/${created.id}`);
|
||
},
|
||
});
|
||
|
||
if (!tenantId || item.isLoading) return <WorkspacePageLoader />;
|
||
if (item.error) return <WorkspacePageError error={item.error} onRetry={() => item.refetch()} />;
|
||
const row = item.data!;
|
||
const brief = { ...((row.brief || {}) as Record<string, string>), ...briefDraft };
|
||
const current = String(row.status || "draft");
|
||
const nextActions = (row.next_actions || []).filter((status) => {
|
||
if (["internal_review", "client_review"].includes(current) && HIDDEN_FROM_REVIEW.has(status)) return false;
|
||
return true;
|
||
});
|
||
|
||
function applyStatus(status: string) {
|
||
if (status === "scheduled") {
|
||
setScheduleOpen(true);
|
||
return;
|
||
}
|
||
update.mutate({ status });
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<PageHeader
|
||
title={String(row.title || "محتوا")}
|
||
description={`وضعیت فعلی: ${labelOf(CONTENT_STATUS, current)}`}
|
||
actions={
|
||
<div className="flex gap-2">
|
||
<Button variant="outline" onClick={() => duplicate.mutate()} disabled={duplicate.isPending}>
|
||
رونوشت
|
||
</Button>
|
||
<Button variant="outline" onClick={() => setArchiveOpen(true)}>
|
||
بایگانی
|
||
</Button>
|
||
</div>
|
||
}
|
||
/>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<StatusChip value={current} />
|
||
<span className="text-sm text-[var(--muted)]">مسئول: {displayPerson(String(row.owner_user_id || ""))}</span>
|
||
<span className="text-sm text-[var(--muted)]">بازبین: {displayPerson(String(row.reviewer_user_id || ""))}</span>
|
||
</div>
|
||
<Card>
|
||
<CardContent className="space-y-3 p-5">
|
||
<h2 className="font-semibold text-secondary">اقدام بعدی</h2>
|
||
<div className="flex flex-wrap gap-2">
|
||
{nextActions.map((status) => (
|
||
<Button key={status} variant="outline" onClick={() => applyStatus(status)}>
|
||
{contentActionLabel(current, status)}
|
||
</Button>
|
||
))}
|
||
{["internal_review", "client_review"].includes(current) ? (
|
||
<Button onClick={() => requestApproval.mutate()} disabled={requestApproval.isPending}>
|
||
درخواست تأیید
|
||
</Button>
|
||
) : null}
|
||
{current === "scheduled" ? (
|
||
<Button variant="outline" onClick={() => setScheduleOpen(true)}>
|
||
ویرایش زمانبندی
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
<div className="grid gap-2 md:grid-cols-2">
|
||
<IdentityUserSelect
|
||
value={String(row.owner_user_id || "")}
|
||
onChange={(id) => update.mutate({ owner_user_id: id })}
|
||
emptyLabel="سازنده / مسئول"
|
||
/>
|
||
<IdentityUserSelect
|
||
value={String(row.reviewer_user_id || "")}
|
||
onChange={(id) => update.mutate({ reviewer_user_id: id })}
|
||
emptyLabel="بازبین"
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="space-y-3 p-5">
|
||
<h2 className="font-semibold text-secondary">بریف محتوا</h2>
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
{BRIEF_FIELDS.map((field) =>
|
||
field.multiline ? (
|
||
<Textarea
|
||
key={field.key}
|
||
value={String(brief[field.key] || "")}
|
||
onChange={(event) => setBriefDraft((prev) => ({ ...prev, [field.key]: event.target.value }))}
|
||
onBlur={() => update.mutate({ brief })}
|
||
placeholder={field.label}
|
||
rows={2}
|
||
/>
|
||
) : (
|
||
<Input
|
||
key={field.key}
|
||
value={String(brief[field.key] || "")}
|
||
onChange={(event) => setBriefDraft((prev) => ({ ...prev, [field.key]: event.target.value }))}
|
||
onBlur={() => update.mutate({ brief })}
|
||
placeholder={field.label}
|
||
/>
|
||
)
|
||
)}
|
||
</div>
|
||
<Textarea
|
||
defaultValue={String(row.caption || row.body || "")}
|
||
placeholder="متن / کپشن"
|
||
onBlur={(event) => update.mutate({ caption: event.target.value, body: event.target.value })}
|
||
/>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="space-y-3 p-5">
|
||
<h2 className="font-semibold text-secondary">تأییدیهها</h2>
|
||
<div className="grid gap-2 md:grid-cols-[1fr_160px_auto]">
|
||
<IdentityUserSelect value={approver} onChange={setApprover} emptyLabel="تأییدکننده" />
|
||
<Select value={approvalType} onChange={(event) => setApprovalType(event.target.value)}>
|
||
{Object.entries(APPROVAL_TYPE).map(([value, label]) => (
|
||
<option key={value} value={value}>{label}</option>
|
||
))}
|
||
</Select>
|
||
<Button onClick={() => requestApproval.mutate()} disabled={requestApproval.isPending}>
|
||
درخواست تأیید
|
||
</Button>
|
||
</div>
|
||
{(row.approvals ?? []).map((approval) => (
|
||
<div key={String(approval.id)} className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-[var(--border)] px-3 py-2">
|
||
<div>
|
||
<p className="text-sm">
|
||
{labelOf(APPROVAL_TYPE, String(approval.approval_type || ""))} · {displayPerson(String(approval.approver_user_id || ""))}
|
||
</p>
|
||
<p className="text-xs text-[var(--muted)]">{formatWhen(String(approval.requested_at || approval.created_at || ""))}</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<StatusChip value={String(approval.status || "")} kind="approval" />
|
||
{approval.status === "pending" ? (
|
||
<>
|
||
<Button size="sm" onClick={() => respond.mutate({ id: String(approval.id), status: "approved" })}>تأیید</Button>
|
||
<Button size="sm" variant="outline" onClick={() => respond.mutate({ id: String(approval.id), status: "changes_requested", comment: "نیاز به اصلاح" })}>درخواست اصلاح</Button>
|
||
<Button size="sm" variant="danger" onClick={() => respond.mutate({ id: String(approval.id), status: "rejected" })}>رد</Button>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="space-y-3 p-5">
|
||
<h2 className="font-semibold text-secondary">ارجاع دارایی</h2>
|
||
<p className="text-sm text-amber-800">ذخیره فایل در فضای کاری انجام نمیشود. فقط شناسه فایل سامانه ذخیرهسازی ثبت میشود.</p>
|
||
<div className="grid gap-2 md:grid-cols-[1fr_1fr_auto]">
|
||
<Input value={fileRef} onChange={(event) => setFileRef(event.target.value)} placeholder="شناسه فایل خارجی" />
|
||
<Input value={assetTitle} onChange={(event) => setAssetTitle(event.target.value)} placeholder="عنوان ارجاع" />
|
||
<Button disabled={!fileRef.trim() || addAsset.isPending} onClick={() => addAsset.mutate()}>ثبت ارجاع</Button>
|
||
</div>
|
||
{(row.assets ?? []).map((asset) => (
|
||
<p key={String(asset.id)} className="text-sm">{String(asset.title || "فایل")} · ارجاعشده</p>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
<CommentThread tenantId={tenantId} workHubId={String(row.work_hub_id || "")} subjectType="content" subjectId={params.id} />
|
||
<Dialog open={scheduleOpen} onClose={() => setScheduleOpen(false)} title="زمانبندی انتشار">
|
||
<div className="space-y-3">
|
||
<Input type="datetime-local" value={scheduledAt} onChange={(event) => setScheduledAt(event.target.value)} />
|
||
<Button
|
||
className="w-full"
|
||
disabled={!scheduledAt || update.isPending}
|
||
onClick={() => update.mutate({ status: current === "scheduled" ? current : "scheduled", scheduled_at: scheduledAt })}
|
||
>
|
||
ذخیره زمانبندی
|
||
</Button>
|
||
</div>
|
||
</Dialog>
|
||
<ConfirmDialog
|
||
open={archiveOpen}
|
||
onClose={() => setArchiveOpen(false)}
|
||
onConfirm={() => update.mutate({ status: "archived" })}
|
||
title="بایگانی محتوا"
|
||
confirmLabel="بایگانی"
|
||
danger
|
||
loading={update.isPending}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default WorkspaceContentDetail;
|