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["Без статуса"]; +};