TorbatYar/frontend/modules/workspace/features/contentDetail.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

298 lines
13 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 { 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;