182 lines
8.3 KiB
TypeScript
182 lines
8.3 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import Link from "next/link";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { toast } from "sonner";
|
||
import { Button, Card, CardContent, Dialog, Input, PageHeader, Select, Textarea } from "@/components/ds";
|
||
import { useCurrentHub } from "@/modules/workspace/hooks/useCurrentHub";
|
||
import { workspaceApi } from "@/modules/workspace/services/workspace-api";
|
||
import { BRIEF_FIELDS, CHANNEL, CONTENT_TYPE, labelOf } from "@/modules/workspace/labels";
|
||
import { StatusChip } from "@/modules/workspace/components/StatusChip";
|
||
import { IdentityUserSelect } from "@/modules/workspace/components/IdentityUserSelect";
|
||
import { WorkspaceEmptyState, WorkspacePageError, WorkspacePageLoader } from "@/modules/workspace/pages/shared";
|
||
|
||
export const WorkspaceContentStudio = function WorkspaceContentStudioPage() {
|
||
const queryClient = useQueryClient();
|
||
const { tenantId, hubId, loading } = useCurrentHub();
|
||
const [open, setOpen] = useState(false);
|
||
const [title, setTitle] = useState("");
|
||
const [contentType, setContentType] = useState("instagram_post");
|
||
const [projectId, setProjectId] = useState("");
|
||
const [brief, setBrief] = useState<Record<string, string>>({});
|
||
const [ownerId, setOwnerId] = useState("");
|
||
const [reviewerId, setReviewerId] = useState("");
|
||
const [teamId, setTeamId] = useState("");
|
||
const [search, setSearch] = useState("");
|
||
const types = useQuery({
|
||
queryKey: ["workspace", tenantId, "content-types"],
|
||
queryFn: () => workspaceApi.contentTypes.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const projects = useQuery({
|
||
queryKey: ["workspace", tenantId, "projects", hubId],
|
||
queryFn: () => workspaceApi.projects.list(tenantId!, { work_hub_id: hubId || undefined }),
|
||
enabled: !!tenantId && !!hubId,
|
||
});
|
||
const teams = useQuery({
|
||
queryKey: ["workspace", tenantId, "teams", hubId],
|
||
queryFn: () => workspaceApi.teams.list(tenantId!, hubId || undefined),
|
||
enabled: !!tenantId && !!hubId,
|
||
});
|
||
const items = useQuery({
|
||
queryKey: ["workspace", tenantId, "content", hubId],
|
||
queryFn: () => workspaceApi.content.list(tenantId!, { work_hub_id: hubId || undefined }),
|
||
enabled: !!tenantId && !!hubId,
|
||
});
|
||
const create = useMutation({
|
||
mutationFn: () =>
|
||
workspaceApi.content.create(tenantId!, {
|
||
work_hub_id: hubId,
|
||
project_id: projectId || undefined,
|
||
title,
|
||
content_type: contentType,
|
||
brief,
|
||
owner_user_id: ownerId || undefined,
|
||
reviewer_user_id: reviewerId || undefined,
|
||
team_id: teamId || undefined,
|
||
}),
|
||
onSuccess: () => {
|
||
toast.success("محتوا ایجاد شد");
|
||
setOpen(false);
|
||
setTitle("");
|
||
setBrief({});
|
||
void queryClient.invalidateQueries({ queryKey: ["workspace", tenantId, "content"] });
|
||
},
|
||
onError: (err) => toast.error(err instanceof Error ? err.message : "ایجاد محتوا ناموفق بود"),
|
||
});
|
||
|
||
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 gap-2">
|
||
<Link href="/workspace/content/board">
|
||
<Button variant="outline">برد محتوا</Button>
|
||
</Link>
|
||
<Link href="/workspace/content/calendar">
|
||
<Button variant="outline">تقویم محتوا</Button>
|
||
</Link>
|
||
<Button onClick={() => setOpen(true)}>محتوای جدید</Button>
|
||
</div>
|
||
}
|
||
/>
|
||
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="جستجوی عنوان محتوا" />
|
||
{(items.data ?? []).filter((item) => String(item.title || "").includes(search)).length === 0 ? (
|
||
<WorkspaceEmptyState
|
||
title="محتوایی نیست"
|
||
description="اولین آیتم محتوا را با بریف ساختاریافته بسازید."
|
||
action={<Button onClick={() => setOpen(true)}>ایجاد محتوا</Button>}
|
||
/>
|
||
) : (
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{(items.data ?? [])
|
||
.filter((item) => String(item.title || "").includes(search))
|
||
.map((item) => (
|
||
<Card key={String(item.id)}>
|
||
<CardContent className="space-y-3 p-5">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<h2 className="font-semibold text-secondary">{String(item.title || "محتوا")}</h2>
|
||
<StatusChip value={String(item.status || "")} />
|
||
</div>
|
||
<p className="text-sm text-[var(--muted)]">
|
||
{labelOf(CONTENT_TYPE, String(item.content_type || ""), String(item.content_type || "سایر"))} ·{" "}
|
||
{labelOf(CHANNEL, String(item.channel || ""), "بدون کانال")}
|
||
</p>
|
||
<Link href={`/workspace/content/${item.id}`}>
|
||
<Button variant="outline" className="w-full">
|
||
باز کردن
|
||
</Button>
|
||
</Link>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)}
|
||
<Dialog open={open} onClose={() => setOpen(false)} title="محتوای جدید" size="lg">
|
||
<div className="space-y-3">
|
||
<Input value={title} onChange={(event) => setTitle(event.target.value)} placeholder="عنوان" />
|
||
<Select value={contentType} onChange={(event) => setContentType(event.target.value)}>
|
||
{(types.data ?? []).map((type) => (
|
||
<option key={String(type.code)} value={String(type.code)}>
|
||
{String(type.label || labelOf(CONTENT_TYPE, String(type.code || "")))}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
<Select value={projectId} onChange={(event) => setProjectId(event.target.value)}>
|
||
<option value="">بدون پروژه</option>
|
||
{(projects.data ?? []).map((project) => (
|
||
<option key={String(project.id)} value={String(project.id)}>
|
||
{String(project.name)}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
<Select value={teamId} onChange={(event) => setTeamId(event.target.value)}>
|
||
<option value="">بدون تیم</option>
|
||
{(teams.data ?? []).map((team) => (
|
||
<option key={String(team.id)} value={String(team.id)}>
|
||
{String(team.name)}
|
||
</option>
|
||
))}
|
||
</Select>
|
||
<div className="grid gap-2 md:grid-cols-2">
|
||
<IdentityUserSelect value={ownerId} onChange={setOwnerId} emptyLabel="سازنده" />
|
||
<IdentityUserSelect value={reviewerId} onChange={setReviewerId} emptyLabel="بازبین" />
|
||
</div>
|
||
<div className="grid gap-3 md:grid-cols-2">
|
||
{BRIEF_FIELDS.map((field) =>
|
||
field.multiline ? (
|
||
<Textarea
|
||
key={field.key}
|
||
value={brief[field.key] || ""}
|
||
onChange={(event) => setBrief((prev) => ({ ...prev, [field.key]: event.target.value }))}
|
||
placeholder={field.label}
|
||
rows={2}
|
||
/>
|
||
) : (
|
||
<Input
|
||
key={field.key}
|
||
value={brief[field.key] || ""}
|
||
onChange={(event) => setBrief((prev) => ({ ...prev, [field.key]: event.target.value }))}
|
||
placeholder={field.label}
|
||
/>
|
||
)
|
||
)}
|
||
</div>
|
||
<Button className="w-full" disabled={!title.trim() || create.isPending} onClick={() => create.mutate()}>
|
||
ایجاد
|
||
</Button>
|
||
</div>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default WorkspaceContentStudio;
|