feat: status selectors update

This commit is contained in:
ivannoskov
2026-02-04 12:16:34 +03:00
parent e27a171c63
commit f7597270e5
4 changed files with 354 additions and 94 deletions

View File

@@ -80,6 +80,8 @@ import {
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { ColumnSelectorDialog } from "@/components/column-selector-dialog"; import { ColumnSelectorDialog } from "@/components/column-selector-dialog";
import { CreativeSelectDialog } from "@/components/creative-select-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 { cn } from "@/lib/utils";
import type { import type {
Project, Project,
@@ -98,10 +100,12 @@ import {
PLACEMENT_DEAL_STATUSES, PLACEMENT_DEAL_STATUSES,
PLACEMENT_POST_STATUSES, PLACEMENT_POST_STATUSES,
canEditPostStatus, canEditPostStatus,
getPlacementStatusColor,
} from "@/lib/config/placement-columns"; } from "@/lib/config/placement-columns";
type PlacementInlineDraft = { type PlacementInlineDraft = {
status?: PlacementStatus; status?: PlacementStatus;
post_status?: PlacementPostStatus | null;
comment?: string | null; comment?: string | null;
creative_id?: string | null; creative_id?: string | null;
creative_name?: string | null; creative_name?: string | null;
@@ -114,7 +118,7 @@ type PlacementInlineDraft = {
cpm?: number | null; 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"; const FIXED_COST_TYPE: CostType = "fixed";
@@ -262,25 +266,6 @@ function CellRenderer({
}: CellRendererProps) { }: CellRendererProps) {
const isEditing = editingCell?.placementId === placement.id; 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 => { const formatCurrency = (value: number): string => {
return new Intl.NumberFormat("ru-RU", { return new Intl.NumberFormat("ru-RU", {
style: "currency", style: "currency",
@@ -358,43 +343,14 @@ function CellRenderer({
case "status_deal": case "status_deal":
return ( return (
<td key={column.id} className="py-2 px-3"> <td key={column.id} className="py-2 px-3">
{isEditing && editingCell?.field === "status" ? ( <PlacementStatusSelector
<select status={placement.status}
aria-label="Статус размещения" isEditing={isEditing && editingCell?.field === "status"}
className={cn( canEdit={canEdit}
"h-7 w-full rounded-md border border-input bg-background px-2 text-xs font-medium shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", onEdit={() => canEdit && setEditingCell({ placementId: placement.id, field: "status" })}
getStatusColor(placement.status) onChange={(s) => handleDraftChange(placement.id, { status: s })}
)} onCloseEdit={() => setEditingCell(null)}
value={placement.status} />
onChange={(e) =>
handleDraftChange(placement.id, {
status: e.target.value as PlacementStatus,
})
}
onBlur={() => setEditingCell(null)}
>
{PLACEMENT_DEAL_STATUSES.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
) : (
<button
type="button"
disabled={!canEdit}
className={cn(
"group h-7 w-full rounded-md border px-2 text-xs font-medium shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default flex items-center justify-between gap-2",
getStatusColor(placement.status)
)}
onClick={() =>
canEdit && setEditingCell({ placementId: placement.id, field: "status" })
}
>
<span>{placement.status}</span>
<Pencil className="h-3 w-3 opacity-0 group-hover:opacity-70 transition-opacity" />
</button>
)}
</td> </td>
); );
@@ -402,13 +358,14 @@ function CellRenderer({
const postStatus = placement.placement_post?.status; const postStatus = placement.placement_post?.status;
return ( return (
<td key={column.id} className="py-2 px-3"> <td key={column.id} className="py-2 px-3">
{postStatus ? ( <PlacementPostStatusSelector
<span className="text-xs px-2 py-1 rounded border inline-block"> status={postStatus ?? null}
{postStatus} isEditing={isEditing && editingCell?.field === "post_status"}
</span> canEdit={canEdit}
) : ( onEdit={() => canEdit && setEditingCell({ placementId: placement.id, field: "post_status" })}
<span className="text-muted-foreground"></span> onChange={(s) => handleDraftChange(placement.id, { post_status: s })}
)} onCloseEdit={() => setEditingCell(null)}
/>
</td> </td>
); );
@@ -1070,36 +1027,36 @@ function ChannelGroup({
? (effectiveCost / viewPlacement.placement_post.views_count) * 1000 ? (effectiveCost / viewPlacement.placement_post.views_count) * 1000
: null; : null;
return ( return (
<tr <tr
key={placement.id} key={placement.id}
data-placement-id={placement.id} data-placement-id={placement.id}
className="border-b last:border-0 hover:bg-muted/30" className="border-b last:border-0 hover:bg-muted/30"
> >
{ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => ( {ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => (
<CellRenderer <CellRenderer
key={column.id} key={column.id}
column={column} column={column}
placement={viewPlacement} placement={viewPlacement}
idx={idx} idx={idx}
channelNumber={channelNumber} channelNumber={channelNumber}
canEdit={canEdit} canEdit={canEdit}
editingCell={editingCell} editingCell={editingCell}
setEditingCell={setEditingCell} setEditingCell={setEditingCell}
handleDraftChange={handleDraftChange} handleDraftChange={handleDraftChange}
effectiveCost={effectiveCost} effectiveCost={effectiveCost}
cpf={cpf} cpf={cpf}
cpm={cpm} cpm={cpm}
selectedPlacementIds={selectedPlacementIds} selectedPlacementIds={selectedPlacementIds}
onTogglePlacement={onTogglePlacement} onTogglePlacement={onTogglePlacement}
onOpenCreativeSelect={onOpenCreativeSelect} onOpenCreativeSelect={onOpenCreativeSelect}
onClearCreative={onClearCreative} onClearCreative={onClearCreative}
onOpenCreativeSend={onOpenCreativeSend} onOpenCreativeSend={onOpenCreativeSend}
/> />
))} ))}
</tr> </tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -1313,6 +1270,10 @@ export default function PurchasePlanDetailPage() {
const nextStatus = draft.status ?? placement.status; const nextStatus = draft.status ?? placement.status;
const nextComment = draft.comment !== undefined ? draft.comment : placement.comment; 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 = { const nextDetails = {
...(placement.details || { ...(placement.details || {
placement_at: null, placement_at: null,
@@ -1357,6 +1318,7 @@ export default function PurchasePlanDetailPage() {
return { return {
...placement, ...placement,
status: nextStatus, status: nextStatus,
placement_post: nextPlacementPost,
comment: nextComment, comment: nextComment,
creative_id: draft.creative_id ?? placement.creative_id, creative_id: draft.creative_id ?? placement.creative_id,
creative_name: draft.creative_name ?? placement.creative_name, creative_name: draft.creative_name ?? placement.creative_name,

View File

@@ -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 <span className="text-muted-foreground text-xs"></span>;
}
const canEditThisStatus = canEdit && canEditPostStatus(status);
const StatusIcon = getPlacementPostStatusIcon(status);
const statusColor = getPlacementPostStatusColor(status);
if (isEditing && canEditThisStatus) {
return (
<Popover open={isEditing} onOpenChange={(open) => !open && onCloseEdit()}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"h-7 w-full justify-between px-2 text-xs font-medium border shadow-sm",
statusColor
)}
>
<div className="flex items-center gap-1.5">
<StatusIcon className="h-3.5 w-3.5" />
<span>{status}</span>
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-1" align="start">
<div className="flex flex-col gap-0.5">
{PLACEMENT_POST_STATUSES.map((s) => {
const Icon = getPlacementPostStatusIcon(s);
const color = getPlacementPostStatusColor(s);
const isSelected = s === status;
return (
<button
key={s}
type="button"
className={cn(
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-md transition-colors",
isSelected
? "bg-muted font-medium"
: "hover:bg-muted/50"
)}
onClick={() => {
onChange(s);
onCloseEdit();
}}
>
<Icon className={cn("h-3.5 w-3.5 shrink-0", color)} />
<span className="flex-1 text-left">{s}</span>
</button>
);
})}
</div>
</PopoverContent>
</Popover>
);
}
return (
<button
type="button"
disabled={!canEditThisStatus}
className={cn(
"group h-7 w-full rounded-md border px-2 text-xs font-medium shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default flex items-center justify-between gap-2",
statusColor
)}
onClick={onEdit}
>
<div className="flex items-center gap-1.5">
<StatusIcon className="h-3.5 w-3.5" />
<span>{status}</span>
</div>
{canEditThisStatus && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-70 transition-opacity" />}
</button>
);
}

View File

@@ -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 (
<Popover open={isEditing} onOpenChange={(open) => !open && onCloseEdit()}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"h-7 w-full justify-between px-2 text-xs font-medium border shadow-sm",
statusColor
)}
>
<div className="flex items-center gap-1.5">
<StatusIcon className="h-3.5 w-3.5" />
<span>{status}</span>
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-1" align="start">
<div className="flex flex-col gap-0.5">
{PLACEMENT_DEAL_STATUSES.map((s) => {
const Icon = getPlacementStatusIcon(s);
const color = getPlacementStatusColor(s);
const isSelected = s === status;
return (
<button
key={s}
type="button"
className={cn(
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-md transition-colors",
isSelected
? "bg-muted font-medium"
: "hover:bg-muted/50"
)}
onClick={() => {
onChange(s);
onCloseEdit();
}}
>
<Icon className={cn("h-3.5 w-3.5", color)} />
<span className="flex-1 text-left">{s}</span>
{isSelected && <Check className="h-3 w-3" />}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
);
}
return (
<button
type="button"
disabled={!canEdit}
className={cn(
"group h-7 w-full rounded-md border px-2 text-xs font-medium shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default flex items-center justify-between gap-2",
statusColor
)}
onClick={onEdit}
>
<div className="flex items-center gap-1.5">
<StatusIcon className="h-3.5 w-3.5" />
<span>{status}</span>
</div>
{canEdit && <Pencil className="h-3 w-3 opacity-0 group-hover:opacity-70 transition-opacity" />}
</button>
);
}

View File

@@ -147,3 +147,86 @@ export const canEditPostStatus = (currentStatus: PlacementPostStatus): boolean =
export const isAutomaticPostStatus = (status: PlacementPostStatus): boolean => { export const isAutomaticPostStatus = (status: PlacementPostStatus): boolean => {
return AUTOMATIC_POST_STATUSES.has(status); 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<PlacementStatus, any> = {
"Без статуса": CircleHelp,
"Написать": FileText,
"Ждём ответа": Clock,
"Согласование условий": FileText,
"Оплатить": DollarSign,
"Оплачено": CheckCircle2,
"Отмена": XCircle,
"Не подходит цена": AlertTriangle,
"Неактуально": XCircle,
"Не отвечает": AlertTriangle,
};
const PostStatusIconMap: Record<PlacementPostStatus, any> = {
"Без статуса": CircleHelp,
"Отправить пост": Send,
"Согласование поста": FileText,
"Ожидание отложки": Clock,
"Запланирован": Calendar,
"Пост вышел": CheckCircle2,
"Размещение отработало - пост удалён": CheckCircle2,
"Размещение отработало - пост не удалён": CheckCircle2,
"Проверить - пост удалён раньше срока": AlertTriangle,
"Проверить - пост не вышел": Clock,
"Размещение отработало": CheckCircle2,
};
const StatusColorMap: Record<PlacementStatus, string> = {
"Без статуса": "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<PlacementPostStatus, string> = {
"Без статуса": "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["Без статуса"];
};