485 lines
15 KiB
TypeScript
485 lines
15 KiB
TypeScript
import { Check, X, Copy, Send, ExternalLink, Play } from "lucide-react";
|
||
import { cn } from "@/lib/utils";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||
import type { PlacementWithStats, PlacementStatus, PlacementPostStatus, CostType, PlacementType, InviteLinkType } from "@/lib/types/api";
|
||
import { PLACEMENT_DEAL_STATUSES, PLACEMENT_POST_STATUSES, COST_FORMATS, PLACEMENT_TYPES, INVITE_TYPES, canEditPostStatus } from "@/lib/config/placement-columns";
|
||
|
||
// ============================================================================
|
||
// Cell Renderers
|
||
// ============================================================================
|
||
|
||
interface CellProps {
|
||
placement: PlacementWithStats;
|
||
idx: number;
|
||
channelNumber: number;
|
||
canEdit: boolean;
|
||
isEditing?: boolean;
|
||
onEdit?: () => void;
|
||
onSave?: (value: any) => void;
|
||
}
|
||
|
||
// Checkbox column
|
||
export const CheckboxCell = ({ checked, onCheckedChange, onClick }: { checked: boolean; onCheckedChange: () => void; onClick?: (e: React.MouseEvent) => void }) => (
|
||
<td className="py-2 px-3">
|
||
<Checkbox checked={checked} onCheckedChange={onCheckedChange} onClick={onClick} />
|
||
</td>
|
||
);
|
||
|
||
// Number column
|
||
export const NumberCell = ({ channelNumber, idx }: { channelNumber: number; idx: number }) => (
|
||
<td className="py-2 px-3">
|
||
<span className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||
{channelNumber}.{idx + 1}
|
||
</span>
|
||
</td>
|
||
);
|
||
|
||
// Status deal column
|
||
export const StatusDealCell = ({
|
||
status,
|
||
canEdit,
|
||
isEditing,
|
||
onEdit,
|
||
onSave,
|
||
}: {
|
||
status: PlacementStatus;
|
||
canEdit: boolean;
|
||
isEditing: boolean;
|
||
onEdit: () => void;
|
||
onSave: (value: PlacementStatus) => void;
|
||
}) => {
|
||
const getStatusColor = (s: PlacementStatus): string => {
|
||
switch (s) {
|
||
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";
|
||
}
|
||
};
|
||
|
||
return (
|
||
<td className="py-2 px-3">
|
||
{isEditing ? (
|
||
<Select value={status} onValueChange={(v) => onSave(v as PlacementStatus)}>
|
||
<SelectTrigger className="h-7 w-full">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{PLACEMENT_DEAL_STATUSES.map((s) => (
|
||
<SelectItem key={s} value={s}>
|
||
{s}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
disabled={!canEdit}
|
||
className={cn(
|
||
"h-7 w-full rounded border px-2 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default",
|
||
getStatusColor(status)
|
||
)}
|
||
onClick={onEdit}
|
||
>
|
||
{status}
|
||
</button>
|
||
)}
|
||
</td>
|
||
);
|
||
};
|
||
|
||
// Status post column
|
||
export const StatusPostCell = ({
|
||
status,
|
||
canEdit,
|
||
isEditing,
|
||
onEdit,
|
||
onSave,
|
||
}: {
|
||
status: PlacementPostStatus;
|
||
canEdit: boolean;
|
||
isEditing: boolean;
|
||
onEdit: () => void;
|
||
onSave: (value: PlacementPostStatus) => void;
|
||
}) => {
|
||
const canEditStatus = canEdit && canEditPostStatus(status);
|
||
const isAuto = !canEditPostStatus(status);
|
||
|
||
return (
|
||
<td className="py-2 px-3">
|
||
{isEditing && canEditStatus ? (
|
||
<Select value={status} onValueChange={(v) => onSave(v as PlacementPostStatus)}>
|
||
<SelectTrigger className="h-7 w-full">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{PLACEMENT_POST_STATUSES.filter(s => canEditPostStatus(s as PlacementPostStatus)).map((s) => (
|
||
<SelectItem key={s} value={s}>
|
||
{s}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
<div className={cn(
|
||
"h-7 w-full rounded border px-2 text-xs font-medium flex items-center",
|
||
isAuto ? "bg-muted text-muted-foreground" : "bg-background",
|
||
canEditStatus && canEdit ? "cursor-pointer hover:bg-muted/50" : "cursor-default"
|
||
)} onClick={canEditStatus && canEdit ? onEdit : undefined}>
|
||
{status}
|
||
</div>
|
||
)}
|
||
</td>
|
||
);
|
||
};
|
||
|
||
// Creative column
|
||
export const CreativeCell = ({
|
||
creativeId,
|
||
creativeName,
|
||
canEdit,
|
||
onToggleCreative,
|
||
}: {
|
||
creativeId: string | null;
|
||
creativeName: string | null;
|
||
canEdit: boolean;
|
||
onToggleCreative: () => void;
|
||
}) => (
|
||
<td className="py-2 px-3">
|
||
{creativeId ? (
|
||
<div className="flex items-center gap-2">
|
||
<div className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 rounded p-1" onClick={onToggleCreative}>
|
||
<div className="w-8 h-8 rounded bg-muted flex items-center justify-center overflow-hidden">
|
||
<span className="text-xs">📷</span>
|
||
</div>
|
||
<span className="text-xs truncate max-w-[140px]">{creativeName || "Без названия"}</span>
|
||
</div>
|
||
<Button variant="outline" size="sm" className="h-6 px-2 text-xs">
|
||
<Send className="h-3 w-3 mr-1" />
|
||
Отправить
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
disabled={!canEdit}
|
||
className="inline-flex min-h-[1.75rem] w-full items-center rounded px-2 text-xs text-muted-foreground hover:bg-muted/50 hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
|
||
onClick={onToggleCreative}
|
||
>
|
||
Выбрать креатив +
|
||
</button>
|
||
)}
|
||
</td>
|
||
);
|
||
|
||
// Invite link column
|
||
export const InviteLinkCell = ({ inviteLink, onCopy }: { inviteLink: string | null; onCopy: () => void }) => (
|
||
<td className="py-2 px-3">
|
||
{inviteLink ? (
|
||
<div className="flex items-center gap-1">
|
||
<code className="text-xs bg-muted/50 px-2 py-1 rounded truncate max-w-[200px]">{inviteLink}</code>
|
||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={onCopy}>
|
||
<Copy className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<span className="text-muted-foreground">—</span>
|
||
)}
|
||
</td>
|
||
);
|
||
|
||
// Cost column
|
||
export const CostCell = ({
|
||
cost,
|
||
costType,
|
||
canEdit,
|
||
isEditing,
|
||
onEdit,
|
||
onSave,
|
||
}: {
|
||
cost: number | null;
|
||
costType: CostType | null;
|
||
canEdit: boolean;
|
||
isEditing: boolean;
|
||
onEdit: () => void;
|
||
onSave: (value: { type: CostType; value: number } | null) => void;
|
||
}) => (
|
||
<td className="py-2 px-3">
|
||
{isEditing ? (
|
||
<Input
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
autoFocus
|
||
className="h-7 min-w-[80px] bg-background px-2 text-xs"
|
||
defaultValue={cost ?? ""}
|
||
onChange={(e) => {
|
||
const raw = e.target.value;
|
||
if (!raw) {
|
||
onSave(null);
|
||
return;
|
||
}
|
||
const value = Number(raw);
|
||
if (Number.isNaN(value)) return;
|
||
onSave({ type: costType || "fixed", value });
|
||
}}
|
||
onBlur={(e) => {
|
||
e.currentTarget.blur();
|
||
}}
|
||
/>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
disabled={!canEdit}
|
||
className="inline-flex min-h-[1.75rem] w-full items-center rounded px-2 text-xs text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
|
||
onClick={onEdit}
|
||
>
|
||
{cost ? new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB", minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(cost) : "—"}
|
||
</button>
|
||
)}
|
||
</td>
|
||
);
|
||
|
||
// Date column
|
||
export const DateCell = ({
|
||
date,
|
||
canEdit,
|
||
isEditing,
|
||
onEdit,
|
||
onSave,
|
||
}: {
|
||
date: string | null;
|
||
canEdit: boolean;
|
||
isEditing: boolean;
|
||
onEdit: () => void;
|
||
onSave: (value: string | null) => void;
|
||
}) => {
|
||
const formatDate = (d: string | null): string => {
|
||
if (!d) return "—";
|
||
return new Date(d).toLocaleDateString("ru-RU", {
|
||
day: "2-digit",
|
||
month: "short",
|
||
year: "numeric",
|
||
});
|
||
};
|
||
|
||
const toDateInputValue = (iso: string | null): string => {
|
||
if (!iso) return "";
|
||
const date = new Date(iso);
|
||
if (Number.isNaN(date.getTime())) return "";
|
||
const year = date.getFullYear();
|
||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||
const day = String(date.getDate()).padStart(2, "0");
|
||
return `${year}-${month}-${day}`;
|
||
};
|
||
|
||
const toIsoFromDateInput = (value: string): string | null => {
|
||
if (!value) return null;
|
||
const date = new Date(`${value}T00:00:00.000Z`);
|
||
if (Number.isNaN(date.getTime())) return null;
|
||
return date.toISOString();
|
||
};
|
||
|
||
return (
|
||
<td className="py-2 px-3">
|
||
{isEditing ? (
|
||
<Input
|
||
type="date"
|
||
autoFocus
|
||
className="h-7 min-w-[100px] bg-background px-2 text-xs"
|
||
defaultValue={toDateInputValue(date)}
|
||
onChange={(e) => onSave(toIsoFromDateInput(e.target.value))}
|
||
onBlur={(e) => e.currentTarget.blur()}
|
||
/>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
disabled={!canEdit}
|
||
className="inline-flex min-h-[1.75rem] w-full items-center rounded px-2 text-xs text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
|
||
onClick={onEdit}
|
||
>
|
||
{formatDate(date)}
|
||
</button>
|
||
)}
|
||
</td>
|
||
);
|
||
};
|
||
|
||
// Text column (for format, comment)
|
||
export const TextCell = ({
|
||
value,
|
||
placeholder = "—",
|
||
canEdit,
|
||
isEditing,
|
||
onEdit,
|
||
onSave,
|
||
className = "",
|
||
}: {
|
||
value: string | null;
|
||
placeholder?: string;
|
||
canEdit: boolean;
|
||
isEditing: boolean;
|
||
onEdit: () => void;
|
||
onSave: (value: string | null) => void;
|
||
className?: string;
|
||
}) => (
|
||
<td className={cn("py-2 px-3", className)}>
|
||
{isEditing ? (
|
||
<Input
|
||
autoFocus
|
||
className="h-7 min-w-[120px] bg-background px-2 text-xs"
|
||
defaultValue={value ?? ""}
|
||
onChange={(e) => onSave(e.target.value || null)}
|
||
onBlur={(e) => e.currentTarget.blur()}
|
||
/>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
disabled={!canEdit}
|
||
className="inline-flex min-h-[1.75rem] w-full items-center rounded px-0 text-left text-xs hover:bg-muted/50 hover:underline focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
|
||
onClick={onEdit}
|
||
>
|
||
<span className="truncate w-full">{value ?? placeholder}</span>
|
||
</button>
|
||
)}
|
||
</td>
|
||
);
|
||
|
||
// Metrics column
|
||
export const MetricCell = ({ value, format = "number" }: { value: number | null; format?: "number" | "currency" | "percent" }) => {
|
||
const formatValue = () => {
|
||
if (value === null || value === undefined) return "—";
|
||
|
||
switch (format) {
|
||
case "currency":
|
||
return new Intl.NumberFormat("ru-RU", {
|
||
style: "currency",
|
||
currency: "RUB",
|
||
minimumFractionDigits: 0,
|
||
maximumFractionDigits: 0,
|
||
}).format(value);
|
||
case "percent":
|
||
return `${value.toFixed(1)}%`;
|
||
case "number":
|
||
default:
|
||
return new Intl.NumberFormat("ru-RU").format(value);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<td className="py-2 px-3 text-muted-foreground">
|
||
{formatValue()}
|
||
</td>
|
||
);
|
||
};
|
||
|
||
// Select column (for cost_format, placement_type, invite_type)
|
||
export const SelectCell = ({
|
||
value,
|
||
options,
|
||
canEdit,
|
||
isEditing,
|
||
onEdit,
|
||
onSave,
|
||
displayMap,
|
||
}: {
|
||
value: string | null;
|
||
options: string[];
|
||
canEdit: boolean;
|
||
isEditing: boolean;
|
||
onEdit: () => void;
|
||
onSave: (value: string) => void;
|
||
displayMap?: Record<string, string>;
|
||
}) => {
|
||
const getDisplayValue = (v: string) => {
|
||
if (displayMap) return displayMap[v] || v;
|
||
|
||
switch (v) {
|
||
case "fixed":
|
||
return "Фикс";
|
||
case "cpm":
|
||
return "CPM";
|
||
case "self_promo":
|
||
return "Взаимный пиар";
|
||
case "standard":
|
||
return "Стандартный";
|
||
case "public":
|
||
return "Открытая";
|
||
case "approval":
|
||
return "С заявками";
|
||
default:
|
||
return v;
|
||
}
|
||
};
|
||
|
||
return (
|
||
<td className="py-2 px-3">
|
||
{isEditing ? (
|
||
<Select value={value ?? ""} onValueChange={onSave}>
|
||
<SelectTrigger className="h-7 w-full">
|
||
<SelectValue placeholder="—" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{options.map((option) => (
|
||
<SelectItem key={option} value={option}>
|
||
{getDisplayValue(option)}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
disabled={!canEdit}
|
||
className="inline-flex min-h-[1.75rem] w-full items-center rounded px-2 text-xs text-left hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-default"
|
||
onClick={onEdit}
|
||
>
|
||
{value ? getDisplayValue(value) : "—"}
|
||
</button>
|
||
)}
|
||
</td>
|
||
);
|
||
};
|
||
|
||
// Link column
|
||
export const LinkCell = ({ url, external = false }: { url: string | null; external?: boolean }) => (
|
||
<td className="py-2 px-3">
|
||
{url ? (
|
||
<a
|
||
href={url}
|
||
target={external ? "_blank" : undefined}
|
||
rel={external ? "noopener noreferrer" : undefined}
|
||
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
|
||
>
|
||
{url.substring(0, 50)}
|
||
{url.length > 50 ? "..." : ""}
|
||
{external && <ExternalLink className="h-3 w-3" />}
|
||
</a>
|
||
) : (
|
||
<span className="text-muted-foreground">—</span>
|
||
)}
|
||
</td>
|
||
);
|
||
|
||
// Empty column (for future columns not yet implemented)
|
||
export const EmptyCell = ({ label }: { label?: string }) => (
|
||
<td className="py-2 px-3 text-muted-foreground text-xs italic">
|
||
{label || "Недоступно"}
|
||
</td>
|
||
);
|