diff --git a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
index 2dd961a..c595dbe 100644
--- a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
+++ b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx
@@ -80,6 +80,8 @@ import {
import { Checkbox } from "@/components/ui/checkbox";
import { ColumnSelectorDialog } from "@/components/column-selector-dialog";
import { CreativeSelectDialog } from "@/components/creative-select-dialog";
+import { PlacementStatusSelector } from "@/components/placement-status-selector";
+import { PlacementPostStatusSelector } from "@/components/placement-post-status-selector";
import { cn } from "@/lib/utils";
import type {
Project,
@@ -98,10 +100,12 @@ import {
PLACEMENT_DEAL_STATUSES,
PLACEMENT_POST_STATUSES,
canEditPostStatus,
+ getPlacementStatusColor,
} from "@/lib/config/placement-columns";
type PlacementInlineDraft = {
status?: PlacementStatus;
+ post_status?: PlacementPostStatus | null;
comment?: string | null;
creative_id?: string | null;
creative_name?: string | null;
@@ -114,7 +118,7 @@ type PlacementInlineDraft = {
cpm?: number | null;
};
-type EditableField = "status" | "placement_at" | "payment_at" | "cost" | "cost_before_bargain" | "format" | "comment" | "cost_format" | "cpm" | "creative_id" | "creative_name";
+type EditableField = "status" | "post_status" | "placement_at" | "payment_at" | "cost" | "cost_before_bargain" | "format" | "comment" | "cost_format" | "cpm" | "creative_id" | "creative_name";
const FIXED_COST_TYPE: CostType = "fixed";
@@ -262,25 +266,6 @@ function CellRenderer({
}: CellRendererProps) {
const isEditing = editingCell?.placementId === placement.id;
- const getStatusColor = (status: PlacementStatus): string => {
- switch (status) {
- case "Оплачено":
- return "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800";
- case "Согласование условий":
- case "Ждём ответа":
- return "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800";
- case "Отмена":
- case "Не подходит цена":
- case "Неактуально":
- case "Не отвечает":
- return "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800";
- case "Оплатить":
- return "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800";
- default:
- return "bg-muted text-muted-foreground border-border";
- }
- };
-
const formatCurrency = (value: number): string => {
return new Intl.NumberFormat("ru-RU", {
style: "currency",
@@ -358,43 +343,14 @@ function CellRenderer({
case "status_deal":
return (
- {isEditing && editingCell?.field === "status" ? (
-
- ) : (
-
- )}
+ canEdit && setEditingCell({ placementId: placement.id, field: "status" })}
+ onChange={(s) => handleDraftChange(placement.id, { status: s })}
+ onCloseEdit={() => setEditingCell(null)}
+ />
|
);
@@ -402,13 +358,14 @@ function CellRenderer({
const postStatus = placement.placement_post?.status;
return (
- {postStatus ? (
-
- {postStatus}
-
- ) : (
- —
- )}
+ canEdit && setEditingCell({ placementId: placement.id, field: "post_status" })}
+ onChange={(s) => handleDraftChange(placement.id, { post_status: s })}
+ onCloseEdit={() => setEditingCell(null)}
+ />
|
);
@@ -1070,36 +1027,36 @@ function ChannelGroup({
? (effectiveCost / viewPlacement.placement_post.views_count) * 1000
: null;
- return (
-
- {ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => (
-
- ))}
-
- );
- })}
+ return (
+
+ {ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => (
+
+ ))}
+
+ );
+ })}
@@ -1313,6 +1270,10 @@ export default function PurchasePlanDetailPage() {
const nextStatus = draft.status ?? placement.status;
const nextComment = draft.comment !== undefined ? draft.comment : placement.comment;
+ const nextPlacementPost = draft.post_status !== null && draft.post_status !== undefined && placement.placement_post
+ ? { ...placement.placement_post, status: draft.post_status }
+ : placement.placement_post;
+
const nextDetails = {
...(placement.details || {
placement_at: null,
@@ -1357,6 +1318,7 @@ export default function PurchasePlanDetailPage() {
return {
...placement,
status: nextStatus,
+ placement_post: nextPlacementPost,
comment: nextComment,
creative_id: draft.creative_id ?? placement.creative_id,
creative_name: draft.creative_name ?? placement.creative_name,
diff --git a/components/placement-post-status-selector.tsx b/components/placement-post-status-selector.tsx
new file mode 100644
index 0000000..3a761ca
--- /dev/null
+++ b/components/placement-post-status-selector.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { Pencil } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { PlacementPostStatus } from "@/lib/types/api";
+import {
+ PLACEMENT_POST_STATUSES,
+ getPlacementPostStatusIcon,
+ getPlacementPostStatusColor,
+ canEditPostStatus,
+} from "@/lib/config/placement-columns";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Button } from "@/components/ui/button";
+
+interface PlacementPostStatusSelectorProps {
+ status: PlacementPostStatus | null;
+ isEditing: boolean;
+ canEdit: boolean;
+ onEdit: () => void;
+ onChange: (status: PlacementPostStatus) => void;
+ onCloseEdit: () => void;
+}
+
+export function PlacementPostStatusSelector({
+ status,
+ isEditing,
+ canEdit,
+ onEdit,
+ onChange,
+ onCloseEdit,
+}: PlacementPostStatusSelectorProps) {
+ if (!status) {
+ return —;
+ }
+
+ const canEditThisStatus = canEdit && canEditPostStatus(status);
+ const StatusIcon = getPlacementPostStatusIcon(status);
+ const statusColor = getPlacementPostStatusColor(status);
+
+ if (isEditing && canEditThisStatus) {
+ return (
+ !open && onCloseEdit()}>
+
+
+
+
+
+ {PLACEMENT_POST_STATUSES.map((s) => {
+ const Icon = getPlacementPostStatusIcon(s);
+ const color = getPlacementPostStatusColor(s);
+ const isSelected = s === status;
+ return (
+
+ );
+ })}
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/components/placement-status-selector.tsx b/components/placement-status-selector.tsx
new file mode 100644
index 0000000..9b3c5d8
--- /dev/null
+++ b/components/placement-status-selector.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import { Pencil, Check } from "lucide-react";
+import { cn } from "@/lib/utils";
+import type { PlacementStatus } from "@/lib/types/api";
+import {
+ PLACEMENT_DEAL_STATUSES,
+ getPlacementStatusIcon,
+ getPlacementStatusColor,
+} from "@/lib/config/placement-columns";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Button } from "@/components/ui/button";
+
+interface PlacementStatusSelectorProps {
+ status: PlacementStatus;
+ isEditing: boolean;
+ canEdit: boolean;
+ onEdit: () => void;
+ onChange: (status: PlacementStatus) => void;
+ onCloseEdit: () => void;
+}
+
+export function PlacementStatusSelector({
+ status,
+ isEditing,
+ canEdit,
+ onEdit,
+ onChange,
+ onCloseEdit,
+}: PlacementStatusSelectorProps) {
+ const StatusIcon = getPlacementStatusIcon(status);
+ const statusColor = getPlacementStatusColor(status);
+
+ if (isEditing) {
+ return (
+ !open && onCloseEdit()}>
+
+
+
+
+
+ {PLACEMENT_DEAL_STATUSES.map((s) => {
+ const Icon = getPlacementStatusIcon(s);
+ const color = getPlacementStatusColor(s);
+ const isSelected = s === status;
+ return (
+
+ );
+ })}
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/lib/config/placement-columns.ts b/lib/config/placement-columns.ts
index 8abcd59..86d50f0 100644
--- a/lib/config/placement-columns.ts
+++ b/lib/config/placement-columns.ts
@@ -147,3 +147,86 @@ export const canEditPostStatus = (currentStatus: PlacementPostStatus): boolean =
export const isAutomaticPostStatus = (status: PlacementPostStatus): boolean => {
return AUTOMATIC_POST_STATUSES.has(status);
};
+
+// Status icon and color helpers
+import {
+ CircleHelp,
+ FileText,
+ Clock,
+ DollarSign,
+ CheckCircle2,
+ XCircle,
+ AlertTriangle,
+ Send,
+ Calendar,
+} from "lucide-react";
+
+const StatusIconMap: Record = {
+ "Без статуса": CircleHelp,
+ "Написать": FileText,
+ "Ждём ответа": Clock,
+ "Согласование условий": FileText,
+ "Оплатить": DollarSign,
+ "Оплачено": CheckCircle2,
+ "Отмена": XCircle,
+ "Не подходит цена": AlertTriangle,
+ "Неактуально": XCircle,
+ "Не отвечает": AlertTriangle,
+};
+
+const PostStatusIconMap: Record = {
+ "Без статуса": CircleHelp,
+ "Отправить пост": Send,
+ "Согласование поста": FileText,
+ "Ожидание отложки": Clock,
+ "Запланирован": Calendar,
+ "Пост вышел": CheckCircle2,
+ "Размещение отработало - пост удалён": CheckCircle2,
+ "Размещение отработало - пост не удалён": CheckCircle2,
+ "Проверить - пост удалён раньше срока": AlertTriangle,
+ "Проверить - пост не вышел": Clock,
+ "Размещение отработало": CheckCircle2,
+};
+
+const StatusColorMap: Record = {
+ "Без статуса": "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-900/30 dark:text-gray-400 dark:border-gray-800",
+ "Написать": "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800",
+ "Ждём ответа": "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800",
+ "Согласование условий": "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:border-purple-800",
+ "Оплатить": "bg-green-100 text-green-700 border-green-200 dark:bg-green-900/30 dark:text-green-400 dark:border-green-800",
+ "Оплачено": "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800",
+ "Отмена": "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800",
+ "Не подходит цена": "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:border-orange-800",
+ "Неактуально": "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-900/30 dark:text-gray-400 dark:border-gray-800",
+ "Не отвечает": "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800",
+};
+
+const PostStatusColorMap: Record = {
+ "Без статуса": "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-900/30 dark:text-gray-400 dark:border-gray-800",
+ "Отправить пост": "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:border-blue-800",
+ "Согласование поста": "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-900/30 dark:text-purple-400 dark:border-purple-800",
+ "Ожидание отложки": "bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400 dark:border-amber-800",
+ "Запланирован": "bg-cyan-100 text-cyan-700 border-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400 dark:border-cyan-800",
+ "Пост вышел": "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800",
+ "Размещение отработало - пост удалён": "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800",
+ "Размещение отработало - пост не удалён": "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800",
+ "Проверить - пост удалён раньше срока": "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:border-orange-800",
+ "Проверить - пост не вышел": "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-400 dark:border-red-800",
+ "Размещение отработало": "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400 dark:border-emerald-800",
+};
+
+export const getPlacementStatusIcon = (status: PlacementStatus): any => {
+ return StatusIconMap[status] || CircleHelp;
+};
+
+export const getPlacementStatusColor = (status: PlacementStatus): string => {
+ return StatusColorMap[status] || StatusColorMap["Без статуса"];
+};
+
+export const getPlacementPostStatusIcon = (status: PlacementPostStatus): any => {
+ return PostStatusIconMap[status] || CircleHelp;
+};
+
+export const getPlacementPostStatusColor = (status: PlacementPostStatus): string => {
+ return PostStatusColorMap[status] || PostStatusColorMap["Без статуса"];
+};