Files
tgex-frontend/components/placement-cells.tsx
2026-02-02 21:01:11 +03:00

485 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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>
);