diff --git a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx index d35b29f..5a687db 100644 --- a/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx +++ b/app/dashboard/[workspaceId]/purchase-plans/[projectId]/page.tsx @@ -92,6 +92,7 @@ import type { PlacementPostStatus, PlacementType, CostType, + InviteLinkType, UpdatePlacementInProjectRequest, } from "@/lib/types/api"; import { @@ -214,6 +215,7 @@ interface ChannelGroupProps { setEditingCell: (cell: EditingCell | null) => void; expanded: boolean; selectedPlacementIds: Set; + draftsByPlacementId: Record; onToggle: (channelId: string, placementIds: string[]) => void; onTogglePlacement: (placementId: string) => void; visibleColumns: Set; @@ -223,6 +225,63 @@ interface ChannelGroupProps { onAddPlacement?: (channel: Placement["channel"]) => void; } +// ============================================================================ +// Column Width Helper +// ============================================================================ + +function getGridStyle(visibleColumns: Set) { + const columns = ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)); + const template = columns.map((c) => c.width || "1fr").join(" "); + return { gridTemplateColumns: template }; +} + +// ============================================================================ +// Table Header Component +// ============================================================================ + +function TableHeader({ visibleColumns, isScrolled }: { visibleColumns: Set; isScrolled: boolean }) { + const { gridTemplateColumns } = getGridStyle(visibleColumns); + const visibleList = ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)); + + return ( +
+ {visibleList.map((column) => ( +
+
+ {column.editable && ( + +

+ {column.partialEdit ? ( + ( + ) + ) : ( + )} +

+
+ )} + {column.label} +
+
+ ))} +
+ ); +} + // ============================================================================ // Cell Renderer Component // ============================================================================ @@ -240,6 +299,7 @@ interface CellRendererProps { cpf: number | null; cpm: number | null; selectedPlacementIds: Set; + draftsByPlacementId: Record; onTogglePlacement: (placementId: string) => void; onOpenCreativeSelect?: (placement: PlacementWithStats) => void; onClearCreative?: (placementId: string) => void; @@ -259,6 +319,7 @@ function CellRenderer({ cpf, cpm, selectedPlacementIds, + draftsByPlacementId, onTogglePlacement, onOpenCreativeSelect, onClearCreative, @@ -318,31 +379,41 @@ function CellRenderer({ return `${hours}ч ${minutes}м`; }; + const getCellClassName = () => { + return "py-2 px-3 flex items-center"; + }; + + const getCellTextClassName = () => { + return "text-muted-foreground text-xs"; + }; + // Render specific columns switch (column.id) { case "checkbox": return ( - - onTogglePlacement(placement.id)} - onClick={(e) => e.stopPropagation()} - /> - +
+
+ onTogglePlacement(placement.id)} + onClick={(e) => e.stopPropagation()} + /> +
+
); case "number": return ( - +
{channelNumber}.{idx + 1} - +
); case "status_deal": return ( - +
handleDraftChange(placement.id, { status: s })} onCloseEdit={() => setEditingCell(null)} /> - +
); case "status_post": const postStatus = placement.placement_post?.status; return ( - +
handleDraftChange(placement.id, { post_status: s })} onCloseEdit={() => setEditingCell(null)} /> - +
); case "creative": const viewCreativeId = placement.creative_id; const hasCreative = !!viewCreativeId; return ( - +
{hasCreative ? ( -
-
- {placement.creative_name || "Без названия"} +
+
+ {placement.creative_name || "Без названия"}
)} - +
); case "invite_link": return ( - +
{placement.invite_link ? ( - + {placement.invite_link.slice(8, 20)}… ) : ( )} - +
); case "post_link": return ( - +
{placement.placement_post?.post?.url ? ( — )} - +
); case "cost": @@ -459,14 +530,12 @@ function CellRenderer({ const costValue = placement.details?.cost?.value || 0; const viewsCount = placement.placement_post?.views_count || 0; const subsCount = placement.placement_post?.subscriptions_count || 0; - - // Если тип CPM - показываем рассчитанную стоимость = CPM * views / 1000 - // Если тип Fixed - показываем фиксированную стоимость + const calculatedCost = costType === "cpm" ? (viewsCount > 0 ? (costValue * viewsCount) / 1000 : null) : costValue; - + const canEditCost = canEdit && costType === "fixed"; return ( - +
{isEditing && editingCell?.field === "cost" ? ( } )} - +
); case "cpm": - // Если тип CPM - показываем CPM значение - // Если тип Fixed - рассчитываем CPM = cost / views * 1000 const cpmType = placement.details?.cost?.type || "fixed"; const cpmValue = placement.details?.cost?.value || 0; const cpmViewsCount = placement.placement_post?.views_count || 0; - + const calculatedCpm = cpmType === "cpm" ? cpmValue : (cpmViewsCount > 0 ? (cpmValue / cpmViewsCount) * 1000 : null); - + const canEditCpm = canEdit && cpmType === "cpm"; return ( - +
{isEditing && editingCell?.field === "cpm" ? ( } )} - +
); case "cost_before": const costBefore = placement.details?.cost_before_bargain?.value; return ( - - {costBefore !== null && costBefore !== undefined ? formatCurrency(costBefore) : "—"} - +
+ {isEditing && editingCell?.field === "cost_before" ? ( + { + const raw = e.target.value; + if (!raw) { + handleDraftChange(placement.id, { cost_before_bargain: null }); + return; + } + const value = Number(raw); + if (Number.isNaN(value)) return; + handleDraftChange(placement.id, { + cost_before_bargain: { type: "fixed", value }, + }); + }} + onBlur={() => setEditingCell(null)} + /> + ) : ( + + )} +
); case "cost_format": const costFormat = placement.details?.cost?.type || "fixed" as CostType; return ( - +
{isEditing && editingCell?.field === "cost_format" ? ( { + handleDraftChange(placement.id, { + placement_type: value, + }); + }} + > + + + + + Стандартный + Взаимный пиар + + + ) : ( + + )} +
); case "discount": @@ -610,14 +740,14 @@ function CellRenderer({ const cost = placement.details?.cost?.value; const discount = costBeforeBargain && cost && costBeforeBargain > 0 ? ((costBeforeBargain - cost) / costBeforeBargain) * 100 : null; return ( - +
{discount ? `${discount.toFixed(1)}%` : "—"} - +
); case "payment_date": return ( - +
{isEditing && editingCell?.field === "payment_at" ? ( )} - +
); case "planned_date": return ( - +
{isEditing && editingCell?.field === "placement_at" ? ( )} - +
); case "actual_date": return ( - +
{placement.placement_post?.post?.published_at ? formatDate(placement.placement_post.post.published_at) : "—"} - +
); case "format": + const formatOptions = [ + { value: "1/24", label: "24 часа" }, + { value: "1/48", label: "48 часов" }, + { value: "1/72", label: "72 часа" }, + { value: "1/(7 дней)", label: "7 дней" }, + { value: "1/(30 дней)", label: "30 дней" }, + { value: "1/(без удаления)", label: "Без удаления" }, + { value: "2/24", label: "2×24 часа" }, + { value: "2/48", label: "2×48 часов" }, + { value: "2/72", label: "2×72 часа" }, + ]; + const currentFormat = placement.details?.format || ""; + const customFormatDraft = draftsByPlacementId[placement.id]?.format; + const formatValue = customFormatDraft !== undefined ? customFormatDraft : currentFormat; + return ( - - {isEditing && editingCell?.field === "format" ? ( - - handleDraftChange(placement.id, { - format: e.target.value ? e.target.value : null, - }) - } - onBlur={() => setEditingCell(null)} - /> - ) : ( +
+
- )} - + {canEdit && ( + + + + + + + Формат размещения + + Срок, на который пост останется на канале до удаления + + +
+ {formatOptions.map((opt) => ( + + ))} +
+
+ + + handleDraftChange(placement.id, { + format: e.target.value ? e.target.value : null, + }) + } + /> +

+ Укажите количество постов и срок через слеш, например: X/24, X/48, X/(7 дней) +

+
+
+
+ )} +
+
); case "time_top": const timeOnTop = placement.placement_post?.time_on_top; return ( - +
{formatTime(timeOnTop)} - +
); case "link_created": return ( - +
{formatDate(placement.invite_link_created_at)} - +
); case "post_deleted": return ( - +
{formatDate(placement.placement_post?.post?.deleted_from_channel_at)} - +
); case "subscriptions": return ( - +
{formatNumber(placement.placement_post?.subscriptions_count)} - +
); case "views": return ( - +
{formatNumber(placement.placement_post?.views_count)} - +
); case "cpf": return ( - +
{cpf ? formatCurrency(cpf) : "—"} - +
); case "conversion_24h": return ( - +
{(placement as any).conversion_24h !== undefined ? `${(placement as any).conversion_24h}%` : "—"} - +
); case "conversion_48h": return ( - +
{(placement as any).conversion_48h !== undefined ? `${(placement as any).conversion_48h}%` : "—"} - +
); case "conversion_total": return ( - +
{(placement as any).conversion_total !== undefined ? `${(placement as any).conversion_total}%` : "—"} - +
); case "total_unsubs": return ( - +
{(placement as any).total_unsubs !== undefined ? (placement as any).total_unsubs : "—"} - +
); case "unsub_percent": return ( - +
{(placement as any).unsub_percent !== undefined ? `${(placement as any).unsub_percent}%` : "—"} - +
); case "total_active": return ( - +
{(placement as any).total_active !== undefined ? (placement as any).total_active : "—"} - +
); case "time_in_feed": return ( - +
{(placement as any).time_in_feed !== undefined ? (placement as any).time_in_feed : "—"} - +
); case "invite_type": + const inviteType = placement.invite_link_type; return ( - - {placement.invite_link_type === "approval" ? "С заявками" : "Открытая"} - +
+ {isEditing && editingCell?.field === "invite_type" ? ( + + ) : ( + + )} +
); case "comment": return ( - +
{isEditing && editingCell?.field === "comment" ? ( canEdit && setEditingCell({ placementId: placement.id, field: "comment" }) } > - {placement.comment || "—"} - + {placement.comment || "—"} + )} - +
); default: return ( - +
Недоступно - +
); } } @@ -870,6 +1092,7 @@ function ChannelGroup({ setEditingCell, expanded, selectedPlacementIds, + draftsByPlacementId, onToggle, onTogglePlacement, visibleColumns, @@ -900,7 +1123,7 @@ function ChannelGroup({
-
+
onToggle(channel.id, placementIds)} @@ -981,849 +1204,55 @@ function ChannelGroup({
-
- - - - {ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => ( - - ))} - - - - {placements.map((placement, idx) => { - const viewPlacement = getPlacementView(placement); - const effectiveCost = viewPlacement.details?.cost?.value || 0; +
+ {placements.map((placement, idx) => { + const viewPlacement = getPlacementView(placement); + const effectiveCost = viewPlacement.details?.cost?.value || 0; - const cpf = viewPlacement.placement_post?.subscriptions_count - ? effectiveCost / viewPlacement.placement_post.subscriptions_count - : null; - const cpm = viewPlacement.placement_post?.views_count - ? (effectiveCost / viewPlacement.placement_post.views_count) * 1000 - : null; + const cpf = viewPlacement.placement_post?.subscriptions_count + ? effectiveCost / viewPlacement.placement_post.subscriptions_count + : null; + const cpm = viewPlacement.placement_post?.views_count + ? (effectiveCost / viewPlacement.placement_post.views_count) * 1000 + : null; - return ( -
- {ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => ( - - ))} - - ); - })} - -
-
- {column.editable && ( - -

- {column.partialEdit ? ( - ( - ) - ) : ( - )} -

-
- )} - {column.label} -
-
+ return ( +
+ {ALL_COLUMNS.filter((c) => visibleColumns.has(c.id)).map((column) => ( + + ))}
); } - -// ============================================================================ -// Main Component -// ============================================================================ - -export default function PurchasePlanDetailPage() { - const params = useParams(); - const router = useRouter(); - const searchParams = useSearchParams(); - const workspaceId = params?.workspaceId as string; - const projectId = params?.projectId as string; - const { projects, hasPermission } = useWorkspace(); - - const [placements, setPlacements] = useState([]); - const [loading, setLoading] = useState(true); - const [project, setProject] = useState(null); - const [draftsByPlacementId, setDraftsByPlacementId] = useState>({}); - const [savingEdits, setSavingEdits] = useState(false); - const [saveError, setSaveError] = useState(null); - const [editingCell, setEditingCell] = useState(null); - - // Placement wizard state - const prefilledCreativeId = searchParams.get("creative_id"); - const [showPlacementWizard, setShowPlacementWizard] = useState(false); - - const [searchQuery, setSearchQuery] = useState(""); - const [statusFilter, setStatusFilter] = useState("all"); - const [expandedAll, setExpandedAll] = useState(true); - const [filtersOpen, setFiltersOpen] = useState(false); - const [selectedPlacementIds, setSelectedPlacementIds] = useState>(new Set()); - const [showCopyDialog, setShowCopyDialog] = useState(false); - const [targetProjectId, setTargetProjectId] = useState(null); - const [copyingChannels, setCopyingChannels] = useState(false); - const [visibleColumns, setVisibleColumns] = useState>( - new Set(DEFAULT_VISIBLE_COLUMNS) - ); - const [showColumnsDialog, setShowColumnsDialog] = useState(false); - - // Creative selection dialog - const [showCreativeDialog, setShowCreativeDialog] = useState(false); - const [selectedPlacementForCreative, setSelectedPlacementForCreative] = useState(null); - const [creatives, setCreatives] = useState>([]); - - // Placement wizard - const [wizardInitialCreative, setWizardInitialCreative] = useState(null); - - const loadCreatives = async () => { - try { - const response = await creativesApi.list(workspaceId, { - project_id: projectId, - include_archived: false, - size: 100, - }); - setCreatives(response.items); - } catch (err) { - console.error("Failed to load creatives:", err); - } - }; - - useEffect(() => { - if (showCreativeDialog && creatives.length === 0) { - loadCreatives(); - } - }, [showCreativeDialog]); - - const togglePlacementSelection = (placementId: string) => { - setSelectedPlacementIds((prev) => { - const next = new Set(prev); - if (next.has(placementId)) { - next.delete(placementId); - } else { - next.add(placementId); - } - return next; - }); - }; - const [filters, setFilters] = useState({ - channelFilters: { - name: { value: "", condition: "contains" }, - username: { value: "", condition: "contains" }, - subscribersMin: null, - subscribersMax: null, - }, - placementFilters: { - statuses: [], - postStatuses: [], - hasCreative: null, - costMin: null, - costMax: null, - costFormat: null, - placementType: null, - dateFrom: null, - dateTo: null, - }, - sort: null, - }); - - const { isDemoMode } = useWorkspace(); - - useEffect(() => { - const proj = projects.find((p) => p.id === projectId); - if (proj) { - setProject(proj); - } - }, [projects, projectId]); - - // Handle URL params for placement wizard - useEffect(() => { - const openPlacement = searchParams.get("openPlacement"); - if (openPlacement === "true") { - const creativeId = searchParams.get("creative_id"); - setWizardInitialCreative(creativeId); - setShowPlacementWizard(true); - } - }, [searchParams]); - - useEffect(() => { - loadPlacements(); - }, [workspaceId, projectId]); - - const loadPlacements = async () => { - try { - setLoading(true); - const api = isDemoMode ? demoAwarePlacementsApi : placementsApi; - const data = await api.getByProject(workspaceId, projectId); - - const placementsWithCreativeNames = await Promise.all( - data.map(async (placement) => { - if (placement.creative_id && !placement.creative_name && !isDemoMode) { - try { - const creative = await creativesApi.get(workspaceId, placement.creative_id); - return { ...placement, creative_name: creative.name }; - } catch (err) { - console.error("Failed to fetch creative name:", err); - return placement; - } - } - return placement; - }) - ); - - setPlacements(placementsWithCreativeNames); - setDraftsByPlacementId({}); - setSaveError(null); - } catch (err) { - console.error("Failed to load placements:", err); - } finally { - setLoading(false); - } - }; - - // Creative selection handlers - const handleOpenCreativeSelect = (placement: PlacementWithStats) => { - setSelectedPlacementForCreative(placement); - setShowCreativeDialog(true); - }; - - const handleClearCreative = (placementId: string) => { - handleDraftChange(placementId, { creative_id: null, creative_name: null }); - }; - - const handleOpenCreativeSend = async (placement: PlacementWithStats) => { - if (!placement.creative_id || isDemoMode) return; - - try { - await placementsApi.sendCreative(workspaceId, projectId, placement.id); - alert("Креатив успешно отправлен"); - } catch (err) { - console.error("Failed to send creative:", err); - alert("Не удалось отправить креатив"); - } - }; - - const handleSelectCreative = (creative: { id: string; name: string }) => { - if (selectedPlacementForCreative) { - handleDraftChange(selectedPlacementForCreative.id, { - creative_id: creative.id, - creative_name: creative.name, - }); - } - }; - - const canEditPlacements = hasPermission("placements_write") && !isDemoMode; - const hasUnsavedChanges = Object.keys(draftsByPlacementId).length > 0; - - const handleDraftChange = (placementId: string, patch: PlacementInlineDraft) => { - if (!canEditPlacements) return; - setDraftsByPlacementId((prev) => ({ - ...prev, - [placementId]: { - ...(prev[placementId] || {}), - ...patch, - }, - })); - }; - - const getPlacementView = (placement: PlacementWithStats): PlacementWithStats => { - const draft = draftsByPlacementId[placement.id]; - if (!draft) return placement; - - 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, - payment_at: null, - cost: null, - cost_before_bargain: null, - placement_type: null, - format: null, - comment: null, - creative_id: null, - }), - placement_at: - draft.placement_at !== undefined - ? draft.placement_at - : placement.details?.placement_at ?? null, - payment_at: - draft.payment_at !== undefined - ? draft.payment_at - : placement.details?.payment_at ?? null, - cost: - draft.cost !== undefined - ? draft.cost - : placement.details?.cost ?? null, - cost_before_bargain: - draft.cost_before_bargain !== undefined - ? draft.cost_before_bargain - : placement.details?.cost_before_bargain ?? null, - placement_type: - draft.placement_type !== undefined - ? draft.placement_type - : placement.details?.placement_type ?? null, - format: - draft.format !== undefined - ? draft.format - : placement.details?.format ?? null, - creative_id: - draft.creative_id !== undefined - ? draft.creative_id - : placement.details?.creative_id ?? null, - }; - - 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, - details: nextDetails, - }; - }; - - const handleCancelEdits = () => { - setDraftsByPlacementId({}); - setSaveError(null); - setEditingCell(null); - }; - - const handleSaveEdits = async () => { - if (!canEditPlacements) return; - const entries = Object.entries(draftsByPlacementId); - if (entries.length === 0) return; - - try { - setSavingEdits(true); - setSaveError(null); - - for (const [placementId, draft] of entries) { - await placementsApi.updateInProject(workspaceId, projectId, placementId, draft as UpdatePlacementInProjectRequest); - } - - await loadPlacements(); - setSavingEdits(false); - } catch (err: unknown) { - console.error("Failed to save placement edits:", err); - const message = - typeof err === "object" && err && "detail" in err - ? String((err as any).detail) - : "Не удалось сохранить изменения"; - setSaveError(message); - setSavingEdits(false); - } - }; - - const toggleChannelSelection = (channelId: string, placementIds: string[]) => { - const allSelected = placementIds.length > 0 && placementIds.every((id) => selectedPlacementIds.has(id)); - setSelectedPlacementIds((prev) => { - const next = new Set(prev); - if (allSelected) { - placementIds.forEach((id) => next.delete(id)); - } else { - placementIds.forEach((id) => next.add(id)); - } - return next; - }); - }; - - const toggleAllChannels = () => { - const allPlacementIds = new Set(filteredGroups.flatMap((g) => g.placements.map((p) => p.id))); - if (selectedPlacementIds.size === allPlacementIds.size) { - setSelectedPlacementIds(new Set()); - } else { - setSelectedPlacementIds(allPlacementIds); - } - }; - - const clearSelection = () => { - setSelectedPlacementIds(new Set()); - }; - - const handleDeletePlacements = async () => { - if (!canEditPlacements) return; - const selectedIds = Array.from(selectedPlacementIds); - if (selectedIds.length === 0) return; - - try { - for (const placementId of selectedIds) { - await placementsApi.deleteInProject(workspaceId, projectId, placementId); - } - await loadPlacements(); - clearSelection(); - } catch (err) { - console.error("Failed to delete placements:", err); - } - }; - - const handleCopyPlacements = async () => { - if (!targetProjectId) return; - const selectedPlacements = placements.filter((p) => selectedPlacementIds.has(p.id)); - if (selectedPlacements.length === 0) return; - - try { - setCopyingChannels(true); - await placementsApi.createBulk(workspaceId, targetProjectId, { - creative_id: undefined, - channels: selectedPlacements.map((p) => ({ - channel_id: p.channel.id, - status: String(p.status), - comment: p.comment || undefined, - details: p.details ? { - placement_at: p.details.placement_at || undefined, - payment_at: p.details.payment_at || undefined, - cost: p.details.cost || undefined, - cost_before_bargain: p.details.cost_before_bargain?.value || undefined, - placement_type: p.details.placement_type || undefined, - format: p.details.format || undefined, - creative_id: p.details.creative_id || undefined, - } : undefined, - })), - }); - setShowCopyDialog(false); - setTargetProjectId(null); - clearSelection(); - } catch (err) { - console.error("Failed to copy placements:", err); - } finally { - setCopyingChannels(false); - } - }; - - const handleAddPlacement = async (channel: Placement["channel"]) => { - if (!canEditPlacements) return; - - try { - const result = await placementsApi.createInProject(workspaceId, projectId, { - creative_id: undefined, - channels: [{ - channel_id: channel.id, - status: "Без статуса", - comment: undefined, - details: {}, - }], - details: {}, - }); - - // Extract the new placement from the response - const newPlacement = result.placements[0]; - - // Add the new placement to the list and switch to edit mode - setPlacements((prev) => [...prev, newPlacement]); - - // Scroll to and focus the new placement after a short delay - setTimeout(() => { - const rowElement = document.querySelector(`[data-placement-id="${newPlacement.id}"]`); - if (rowElement) { - rowElement.scrollIntoView({ behavior: "smooth", block: "center" }); - // Set editing state for the first editable field - setEditingCell({ placementId: newPlacement.id, field: "status" }); - } - }, 100); - } catch (err) { - console.error("Failed to add placement:", err); - } - }; - - const groupedPlacements = useMemo(() => { - const groups: Record = {}; - - placements.forEach((placement) => { - const channelId = placement.channel.id; - if (!groups[channelId]) { - groups[channelId] = { - channel: placement.channel, - placements: [], - }; - } - - const cpf = placement.placement_post?.subscriptions_count - ? (placement.details?.cost?.value || 0) / placement.placement_post.subscriptions_count - : null; - const cpm = placement.placement_post?.views_count - ? ((placement.details?.cost?.value || 0) / placement.placement_post.views_count) * 1000 - : null; - - groups[channelId].placements.push({ - ...placement, - cpf, - cpm, - }); - }); - - Object.values(groups).forEach((group) => { - group.placements.sort((a, b) => { - const dateA = new Date(a.created_at).getTime(); - const dateB = new Date(b.created_at).getTime(); - return dateA - dateB; - }); - }); - - return Object.entries(groups).map(([_, group]) => ({ - channel: group.channel, - placements: group.placements, - })).sort((a, b) => a.channel.title.localeCompare(b.channel.title)); - }, [placements]); - - const summary = useMemo(() => { - const totalCost = placements.reduce((sum, p) => sum + (p.details?.cost?.value || 0), 0); - const totalSubscriptions = placements.reduce((sum, p) => sum + (p.placement_post?.subscriptions_count || 0), 0); - const totalViews = placements.reduce((sum, p) => sum + (p.placement_post?.views_count || 0), 0); - - return { - totalPlacements: placements.length, - totalChannels: groupedPlacements.length, - totalCost, - totalSubscriptions, - totalViews, - avgCpf: totalSubscriptions > 0 ? totalCost / totalSubscriptions : null, - avgCpm: totalViews > 0 ? (totalCost / totalViews) * 1000 : null, - }; - }, [placements, groupedPlacements]); - - const filteredGroups = useMemo(() => { - let filtered = groupedPlacements.filter((group) => { - // Channel filters - const nameFilter = filters.channelFilters.name; - const channelNameMatch = !nameFilter.value || - (nameFilter.condition === "contains" - ? group.channel.title.toLowerCase().includes(nameFilter.value.toLowerCase()) - : nameFilter.condition === "not_contains" - ? !group.channel.title.toLowerCase().includes(nameFilter.value.toLowerCase()) - : group.channel.title.toLowerCase() === nameFilter.value.toLowerCase()); - - const usernameFilter = filters.channelFilters.username; - const channelUsernameMatch = !usernameFilter.value || - (usernameFilter.condition === "contains" - ? group.channel.username?.toLowerCase().includes(usernameFilter.value.toLowerCase()) - : usernameFilter.condition === "not_contains" - ? !group.channel.username?.toLowerCase().includes(usernameFilter.value.toLowerCase()) - : group.channel.username?.toLowerCase() === usernameFilter.value.toLowerCase()); - - const subscribersMinMatch = filters.channelFilters.subscribersMin === null || - (group.channel.subscribers_count || 0) >= filters.channelFilters.subscribersMin; - const subscribersMaxMatch = filters.channelFilters.subscribersMax === null || - (group.channel.subscribers_count || 0) <= filters.channelFilters.subscribersMax; - - // Placement filters - const placementsWithStatus = filters.placementFilters.statuses.length === 0 || - group.placements.some((p) => filters.placementFilters.statuses.includes(p.status)); - - const costMinMatch = filters.placementFilters.costMin === null || - group.placements.some((p) => (p.details?.cost?.value || 0) >= filters.placementFilters.costMin!); - const costMaxMatch = filters.placementFilters.costMax === null || - group.placements.some((p) => (p.details?.cost?.value || 0) <= filters.placementFilters.costMax!); - - let dateMatch = true; - if (filters.placementFilters.dateFrom || filters.placementFilters.dateTo) { - dateMatch = group.placements.some((p) => { - if (!p.details?.placement_at) return false; - const placementDate = new Date(p.details.placement_at); - const fromMatch = !filters.placementFilters.dateFrom || - placementDate >= new Date(filters.placementFilters.dateFrom); - const toMatch = !filters.placementFilters.dateTo || - placementDate <= new Date(filters.placementFilters.dateTo); - return fromMatch && toMatch; - }); - } - - // New filters - const postStatusMatch = filters.placementFilters.postStatuses.length === 0 || - group.placements.some((p) => filters.placementFilters.postStatuses.includes(p.placement_post?.status as PlacementPostStatus)); - - const hasCreativeMatch = filters.placementFilters.hasCreative === null || - group.placements.some((p) => - (filters.placementFilters.hasCreative === true && p.creative_id !== null) || - (filters.placementFilters.hasCreative === false && p.creative_id === null) - ); - - const costFormatMatch = filters.placementFilters.costFormat === null || - group.placements.some((p) => p.details?.cost?.type === filters.placementFilters.costFormat); - - const placementTypeMatch = filters.placementFilters.placementType === null || - group.placements.some((p) => p.details?.placement_type === filters.placementFilters.placementType); - - return channelNameMatch && channelUsernameMatch && subscribersMinMatch && - subscribersMaxMatch && placementsWithStatus && costMinMatch && costMaxMatch && dateMatch && - postStatusMatch && hasCreativeMatch && costFormatMatch && placementTypeMatch; - }); - - // Sort placements inside each channel - if (filters.sort) { - filtered = filtered.map((group) => ({ - ...group, - placements: [...group.placements].sort((a, b) => { - let aValue = 0; - let bValue = 0; - - switch (filters.sort!.field) { - case "cost": - aValue = a.details?.cost?.value || 0; - bValue = b.details?.cost?.value || 0; - break; - case "date": - aValue = a.details?.placement_at ? new Date(a.details.placement_at).getTime() : 0; - bValue = b.details?.placement_at ? new Date(b.details.placement_at).getTime() : 0; - break; - case "cpm": - aValue = a.cpm || 0; - bValue = b.cpm || 0; - break; - case "cpf": - aValue = a.cpf || 0; - bValue = b.cpf || 0; - break; - case "subscribers": - aValue = a.placement_post?.subscriptions_count || 0; - bValue = b.placement_post?.subscriptions_count || 0; - break; - case "views": - aValue = a.placement_post?.views_count || 0; - bValue = b.placement_post?.views_count || 0; - break; - case "created": - aValue = new Date(a.created_at).getTime(); - bValue = new Date(b.created_at).getTime(); - break; - } - - return filters.sort!.direction === "asc" - ? aValue - bValue - : bValue - aValue; - }), - })); - } - - return filtered; - }, [groupedPlacements, filters]); - - const canWrite = hasPermission("placements_write"); - - const hasActiveFilters = filters.channelFilters.name.value || - filters.channelFilters.username.value || - filters.channelFilters.subscribersMin !== null || - filters.channelFilters.subscribersMax !== null || - filters.placementFilters.statuses.length > 0 || - filters.placementFilters.postStatuses.length > 0 || - filters.placementFilters.hasCreative !== null || - filters.placementFilters.costMin !== null || - filters.placementFilters.costMax !== null || - filters.placementFilters.costFormat !== null || - filters.placementFilters.placementType !== null || - filters.placementFilters.dateFrom || - filters.placementFilters.dateTo || - filters.sort; - - if (loading) { - return ( - <> - -
- -
- - ); - } - - return ( - <> - - -
- {/* Header */} -
-
- -
-

- План закупов проекта «{project?.title || "Проект"}» -

-

- Список каналов, в которых планируем размещения для проекта -

-
-
-
- -
-
- - {/* Filters */} -
-
- 0 && filteredGroups.every((g) => g.placements.every((p) => selectedPlacementIds.has(p.id)))} - onCheckedChange={toggleAllChannels} - /> - -
-
- - setSearchQuery(e.target.value)} - className="pl-9" - /> -
- - - - {selectedPlacementIds.size > 0 && ( - - - - - - setShowCopyDialog(true)}> - - Добавить в другой проект - - - - - Удалить - - - - Отменить выбор - - - - )} -
- - {/* Placements List */} - {filteredGroups.length === 0 ? ( - - - -

Нет размещений

-

- {placements.length === 0 - ? "Добавьте первое размещение для этого проекта" - : "Попробуйте изменить параметры поиска"} -

-
-
- ) : ( -
- {filteredGroups.map((group, idx) => ( - toggleChannelSelection(channelId, placementIds)} - onTogglePlacement={togglePlacementSelection} - visibleColumns={visibleColumns} - onOpenCreativeSelect={handleOpenCreativeSelect} - onClearCreative={handleClearCreative} - onOpenCreativeSend={handleOpenCreativeSend} - onAddPlacement={handleAddPlacement} - /> - ))} -
- )} -
- - {hasUnsavedChanges && (
diff --git a/app/dashboard/[workspaceId]/settings/page.tsx b/app/dashboard/[workspaceId]/settings/page.tsx index 6c9e564..d285887 100644 --- a/app/dashboard/[workspaceId]/settings/page.tsx +++ b/app/dashboard/[workspaceId]/settings/page.tsx @@ -30,14 +30,6 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuCheckboxItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, @@ -58,8 +50,8 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; -import type { WorkspaceMember, WorkspaceInvite, PermissionKey } from "@/lib/types/api"; -import { PERMISSION_LABELS, ALL_PERMISSIONS } from "@/lib/types/api"; +import { PERMISSION_LABELS } from "@/lib/types/api"; +import { PermissionModal } from "@/components/ui/permission-modal"; // ============================================================================ // Component @@ -93,6 +85,12 @@ export default function SettingsPage() { const [isCreating, setIsCreating] = useState(false); const [error, setError] = useState(null); + // Permission modal + const [showPermissionModal, setShowPermissionModal] = useState(false); + const [selectedMember, setSelectedMember] = useState(null); + const [projects, setProjects] = useState([]); + const [loadingProjects, setLoadingProjects] = useState(false); + useEffect(() => { if (currentWorkspace) { setName(currentWorkspace.name); @@ -119,6 +117,34 @@ export default function SettingsPage() { } }; + const loadProjects = async () => { + try { + setLoadingProjects(true); + const response = await workspacesApi.getProjects(workspaceId, { size: 100 }); + setProjects(response.items); + } catch (err) { + console.error("Failed to load projects:", err); + } finally { + setLoadingProjects(false); + } + }; + + const openPermissionModal = async (member: WorkspaceMember) => { + setSelectedMember(member); + await loadProjects(); + setShowPermissionModal(true); + }; + + const handlePermissionSave = async ( + wsId: string, + memberId: string, + userId: string, + permissions: { key: PermissionKey; scopes?: { type: string; id: string }[] }[] + ) => { + await membersApi.updatePermissions(wsId, userId, { permissions }); + await loadMembers(); + }; + const handleUpdate = async () => { if (!name.trim() || name === currentWorkspace?.name) return; @@ -133,7 +159,7 @@ export default function SettingsPage() { } }; - const handleAvatarUpload = async (e: React.ChangeEvent) => { +const handleAvatarUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; @@ -152,82 +178,6 @@ export default function SettingsPage() { } }; - const handleAvatarDelete = async () => { - try { - setIsDeletingAvatar(true); - await workspacesApi.deleteAvatar(workspaceId); - await refreshWorkspaces(); - } catch (err) { - console.error("Failed to delete avatar:", err); - } finally { - setIsDeletingAvatar(false); - } - }; - - const handleCreateInvite = async () => { - if (!username.trim()) return; - - const cleanUsername = username.trim().replace(/^@/, ""); - - try { - setIsCreating(true); - setError(null); - - const invite = await invitesApi.create(workspaceId, { - username: cleanUsername, - }); - - setInvites((prev) => [invite, ...prev]); - setShowInviteDialog(false); - setUsername(""); - } catch (err: any) { - const errorMessage = err?.error?.message || err?.detail; - if (errorMessage?.includes("not found")) { - setError(`Пользователь @${cleanUsername} не зарегистрирован на платформе. Для получения приглашения необходимо зарегистрироваться.`); - } else { - setError(errorMessage || "Не удалось отправить приглашение"); - } - } - }; - - const handlePermissionChange = async ( - memberId: string, - userId: string, - permission: PermissionKey, - enabled: boolean - ) => { - try { - setUpdating(memberId); - - const member = members.find((m) => m.id === memberId); - if (!member) return; - - let newPermissions = [...member.permissions]; - - if (enabled) { - if (!newPermissions.some((p) => p.key === permission)) { - newPermissions.push({ key: permission }); - } - } else { - newPermissions = newPermissions.filter((p) => p.key !== permission); - } - - await membersApi.updatePermissions(workspaceId, userId, { - permissions: newPermissions, - }); - - setMembers((prev) => - prev.map((m) => - m.id === memberId ? { ...m, permissions: newPermissions } : m - ) - ); - } catch (err) { - console.error("Failed to update permissions:", err); - } finally { - setUpdating(null); - } - }; - const handleDelete = async () => { if (deleteConfirm !== `Удалить ${currentWorkspace?.name}`) return; @@ -248,6 +198,45 @@ export default function SettingsPage() { return member.permissions.some((p) => p.key === key); }; + const renderPermissionBadges = (member: WorkspaceMember) => { + if (member.permissions.length === 0) { + return Нет прав; + } + + const adminPerm = member.permissions.find((p) => p.key === "admin_full"); + if (adminPerm) { + const isGlobal = !adminPerm.scopes || adminPerm.scopes.length === 0; + return ( +
+ + + Админ {isGlobal ? "" : "(ограничен)"} + +
+ ); + } + + return ( +
+ {member.permissions.map((p) => { + const isGlobal = !p.scopes || p.scopes.length === 0; + const projectCount = p.scopes + ? p.scopes.filter((s) => s.type === "project").length + : 0; + + return ( + + {PERMISSION_LABELS[p.key]} + {!isGlobal && projectCount > 0 && ( + ({projectCount}) + )} + + ); + })} +
+ ); + }; + // Combine members and pending invites const pendingInvites = invites.filter((inv) => inv.status === "pending"); const allPeople = [ @@ -533,76 +522,34 @@ export default function SettingsPage() { )}
- - - + + + {member.status === "active" ? "Активен" : "Приглашён"} + + + + {renderPermissionBadges(member)} + + + - - - Права доступа - - {ALL_PERMISSIONS.map((perm) => ( - - handlePermissionChange( - member.id, - member.user.id, - perm, - checked - ) - } - > - {PERMISSION_LABELS[perm]} - - ))} - - + ); @@ -681,6 +628,17 @@ export default function SettingsPage() { + + {selectedMember && ( + + )}
); diff --git a/components/ui/permission-modal.tsx b/components/ui/permission-modal.tsx new file mode 100644 index 0000000..63d8214 --- /dev/null +++ b/components/ui/permission-modal.tsx @@ -0,0 +1,464 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { Loader2, Shield, Search, X, Plus, Check, Info } from "lucide-react"; +import type { WorkspaceMember, Project, PermissionKey } from "@/lib/types/api"; +import { PERMISSION_LABELS, PERMISSION_GROUPS, PERMISSION_ICONS } from "@/lib/types/permission"; +import { + hasGlobalPermission, + hasScopedPermission, + getScopesForPermission, + setScopedPermission, + toggleScopedPermission, + removePermission, + calculatePermissionDiff, +} from "@/lib/types/permission"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; + +interface PermissionModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + member: WorkspaceMember; + projects: Project[]; + onSave: (workspaceId: string, memberId: string, userId: string, permissions: { key: PermissionKey; scopes?: { type: string; id: string }[] }[]) => Promise; + workspaceId: string; +} + +interface PermissionState { + key: PermissionKey; + isGlobal: boolean; + projectIds: string[]; +} + +const PRESETS = [ + { label: "Полный доступ", keys: ["admin_full"] }, + { label: "Только чтение", keys: ["projects_read", "creatives_read", "placements_read", "analytics_read"] }, + { label: "Менеджер", keys: ["projects_read", "projects_write", "placements_read", "placements_write"] }, + { label: "Убрать все", keys: [] }, +]; + +export function PermissionModal({ + open, + onOpenChange, + member, + projects, + onSave, + workspaceId, +}: PermissionModalProps) { + const [isSaving, setIsSaving] = useState(false); + const [permissions, setPermissions] = useState([]); + const [projectSearch, setProjectSearch] = useState(""); + + useEffect(() => { + if (open && member) { + const perms: PermissionState[] = []; + + for (const perm of member.permissions) { + const isGlobal = !perm.scopes || perm.scopes.length === 0; + const projectIds = perm.scopes + ? perm.scopes.filter((s) => s.type === "project").map((s) => s.id) + : []; + + perms.push({ + key: perm.key, + isGlobal, + projectIds, + }); + } + + setPermissions(perms); + } + }, [open, member]); + + const handleToggleGlobal = (key: PermissionKey) => { + setPermissions((prev) => { + const existing = prev.find((p) => p.key === key); + if (existing) { + return prev.map((p) => + p.key === key ? { ...p, isGlobal: !p.isGlobal, projectIds: [] } : p + ); + } + return [...prev, { key, isGlobal: true, projectIds: [] }]; + }); + }; + + const handleToggleProject = (key: PermissionKey, projectId: string) => { + setPermissions((prev) => { + const existing = prev.find((p) => p.key === key); + if (existing) { + return prev.map((p) => + p.key === key + ? { ...p, isGlobal: false, projectIds: toggleInArray(p.projectIds, projectId) } + : p + ); + } + return [ + ...prev, + { key, isGlobal: false, projectIds: [projectId] }, + ]; + }); + }; + + const handleAddProject = (key: PermissionKey, projectId: string) => { + setPermissions((prev) => { + const existing = prev.find((p) => p.key === key); + if (existing) { + return prev.map((p) => + p.key === key + ? { ...p, isGlobal: false, projectIds: [...new Set([...p.projectIds, projectId])] } + : p + ); + } + return [ + ...prev, + { key, isGlobal: false, projectIds: [projectId] }, + ]; + }); + }; + + const handleRemoveProject = (key: PermissionKey, projectId: string) => { + setPermissions((prev) => { + const existing = prev.find((p) => p.key === key); + if (!existing) return prev; + + const newProjectIds = existing.projectIds.filter((id) => id !== projectId); + + if (newProjectIds.length === 0) { + return prev.filter((p) => p.key !== key); + } + + return prev.map((p) => + p.key === key ? { ...p, projectIds: newProjectIds } : p + ); + }); + }; + + const toggleInArray = (arr: string[], item: string): string[] => { + if (arr.includes(item)) { + return arr.filter((i) => i !== item); + } + return [...arr, item]; + }; + + const applyPreset = (keys: PermissionKey[]) => { + if (keys.length === 0) { + setPermissions([]); + return; + } + + setPermissions( + keys.map((key) => ({ + key, + isGlobal: true, + projectIds: [], + })) + ); + }; + + const getProjectById = (id: string): Project | undefined => { + return projects.find((p) => p.id === id); + }; + + const availableProjects = useMemo(() => { + return projects.filter( + (p) => + p.title.toLowerCase().includes(projectSearch.toLowerCase()) || + p.username?.toLowerCase().includes(projectSearch.toLowerCase()) + ); + }, [projects, projectSearch]); + + const isDirty = useMemo(() => { + if (permissions.length !== member.permissions.length) return true; + + for (const perm of permissions) { + const orig = member.permissions.find((p) => p.key === perm.key); + if (!orig) return true; + + const origIsGlobal = !orig.scopes || orig.scopes.length === 0; + const origProjectIds = orig.scopes + ? orig.scopes.filter((s) => s.type === "project").map((s) => s.id) + : []; + + if (perm.isGlobal !== origIsGlobal) return true; + if (!perm.isGlobal) { + const permProjectSet = new Set(perm.projectIds); + const origProjectSet = new Set(origProjectIds); + if (permProjectSet.size !== origProjectSet.size) return true; + for (const id of permProjectSet) { + if (!origProjectSet.has(id)) return true; + } + } + } + return false; + }, [permissions, member.permissions]); + + const handleSave = async () => { + setIsSaving(true); + try { + const result = permissions + .filter((p) => { + if (p.isGlobal) return true; + return p.projectIds.length > 0; + }) + .map((p) => { + if (p.isGlobal) { + return { key: p.key }; + } + return { + key: p.key, + scopes: p.projectIds.map((id) => ({ type: "project" as const, id })), + }; + }); + + await onSave(workspaceId, member.id, member.user.id, result); + onOpenChange(false); + } finally { + setIsSaving(false); + } + }; + + const handleReset = () => { + const perms: PermissionState[] = []; + + for (const perm of member.permissions) { + const isGlobal = !perm.scopes || perm.scopes.length === 0; + const projectIds = perm.scopes + ? perm.scopes.filter((s) => s.type === "project").map((s) => s.id) + : []; + + perms.push({ + key: perm.key, + isGlobal, + projectIds, + }); + } + + setPermissions(perms); + }; + + const renderPermissionRow = (key: PermissionKey, group: string) => { + const current = permissions.find((p) => p.key === key); + const currentProjectIds = current?.projectIds || []; + const isGlobal = current?.isGlobal ?? false; + + return ( +
+
+
+ {PERMISSION_ICONS[key]} + {PERMISSION_LABELS[key]} + + {group} + +
+
+ + + + + + +
+
+ setProjectSearch(e.target.value)} + className="h-8" + /> +
+ +
+ {availableProjects.map((project) => { + const isSelected = currentProjectIds.includes(project.id); + return ( +
handleToggleProject(key, project.id)} + > +
+
{project.title}
+ {project.username && ( +
+ @{project.username} +
+ )} +
+ {isSelected && } +
+ ); + })} + {availableProjects.length === 0 && ( +
+ Проекты не найдены +
+ )} +
+
+
+
+
+
+
+ + {!isGlobal && currentProjectIds.length > 0 && ( +
+ {currentProjectIds.map((id) => { + const project = getProjectById(id); + return ( + + {project?.title || "Unknown"} + handleRemoveProject(key, id)} + /> + + ); + })} +
+ )} +
+ ); + }; + + return ( + + + + + + Права доступа + + + Настройте права доступа для участника {member.user.username || `User ${member.user.telegram_id}`} + + + +
+
+ Быстрые настройки: + {PRESETS.map((preset) => ( + + ))} +
+ + + +
+
+

+ 👑 Администрирование +

+ {PERMISSION_GROUPS.admin.map((key) => renderPermissionRow(key, "admin"))} +
+ + + +
+

+ 📁 Проекты +

+ {PERMISSION_GROUPS.projects.map((key) => renderPermissionRow(key, "projects"))} +
+ + + +
+

+ 🎨 Креативы +

+ {PERMISSION_GROUPS.creatives.map((key) => renderPermissionRow(key, "creatives"))} +
+ + + +
+

+ 📋 Закупы +

+ {PERMISSION_GROUPS.placements.map((key) => renderPermissionRow(key, "placements"))} +
+ + + +
+

+ 📊 Аналитика +

+ {PERMISSION_GROUPS.analytics.map((key) => renderPermissionRow(key, "analytics"))} +
+
+ +
+ + + Глобальные права применяются ко всему воркспейсу. Права на проекты применяются только к выбранным проектам. + +
+
+ + +
+ {isDirty && Есть несохранённые изменения} +
+
+ + +
+
+
+
+ ); +} diff --git a/components/ui/scroll-area.tsx b/components/ui/scroll-area.tsx new file mode 100644 index 0000000..76eb1e8 --- /dev/null +++ b/components/ui/scroll-area.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" + +import { cn } from "@/lib/utils" + +function ScrollArea({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ) +} + +function ScrollBar({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { ScrollArea, ScrollBar } diff --git a/lib/api/workspaces.ts b/lib/api/workspaces.ts index 2366874..2e3f12a 100644 --- a/lib/api/workspaces.ts +++ b/lib/api/workspaces.ts @@ -7,6 +7,7 @@ import type { Workspace, WorkspaceCreateRequest, WorkspaceUpdateRequest, + Project, PaginatedResponse, PaginationParams, } from "@/lib/types/api"; @@ -52,5 +53,11 @@ export const workspacesApi = { */ deleteAvatar: (workspaceId: string) => api.delete(`${BASE_PATH}/${workspaceId}/avatar`), + + /** + * Get projects for workspace + */ + getProjects: (workspaceId: string, params?: PaginationParams) => + api.get>(`${BASE_PATH}/${workspaceId}/projects`, { params }), }; diff --git a/lib/config/placement-columns.ts b/lib/config/placement-columns.ts index 86d50f0..48260f0 100644 --- a/lib/config/placement-columns.ts +++ b/lib/config/placement-columns.ts @@ -21,51 +21,51 @@ export const COLUMN_GROUPS = [ ] as const; export const ALL_COLUMNS: ColumnConfig[] = [ - // Basic - { id: "checkbox", label: "Чекбокс", group: "basic", editable: false, required: true }, - { id: "number", label: "Номер", group: "basic", editable: false, required: true }, - { id: "status_deal", label: "Статус сделки", group: "basic", editable: true, partialEdit: true }, - { id: "status_post", label: "Статус поста", group: "basic", editable: true, partialEdit: true }, + // Basic - узкие колонки + { id: "checkbox", label: "", group: "basic", editable: false, required: true, width: "32px" }, + { id: "number", label: "№", group: "basic", editable: false, required: true, width: "40px" }, + { id: "status_deal", label: "Статус сделки", group: "basic", editable: true, partialEdit: true, width: "minmax(120px, 1.2fr)" }, + { id: "status_post", label: "Статус поста", group: "basic", editable: true, partialEdit: true, width: "minmax(120px, 1.2fr)" }, // Content - { id: "creative", label: "Креатив", group: "content", editable: true }, - { id: "invite_link", label: "Пригл. ссылка", group: "content", editable: false }, - { id: "post_link", label: "Ссылка на пост", group: "content", editable: false }, + { id: "creative", label: "Креатив", group: "content", editable: true, width: "minmax(140px, 1.5fr)" }, + { id: "invite_link", label: "Ссылка", group: "content", editable: false, width: "minmax(80px, 0.8fr)" }, + { id: "post_link", label: "Пост", group: "content", editable: false, width: "minmax(50px, 0.5fr)" }, // Finance - { id: "cost", label: "Стоимость", group: "finance", editable: true, partialEdit: true }, - { id: "cost_before", label: "Стоимость до торга", group: "finance", editable: true }, - { id: "cost_format", label: "Формат оплаты", group: "finance", editable: true, partialEdit: true }, - { id: "placement_type", label: "Тип закупа", group: "finance", editable: true }, - { id: "discount", label: "% скидки", group: "finance", editable: false }, - { id: "payment_date", label: "Дата оплаты", group: "finance", editable: true }, + { id: "cost", label: "Стоимость", group: "finance", editable: true, partialEdit: true, width: "minmax(80px, 0.8fr)" }, + { id: "cost_before", label: "До торга", group: "finance", editable: true, width: "minmax(80px, 0.8fr)" }, + { id: "cost_format", label: "Форм. оплаты", group: "finance", editable: true, partialEdit: true, width: "minmax(80px, 0.8fr)" }, + { id: "placement_type", label: "Тип закупа", group: "finance", editable: true, width: "minmax(100px, 1fr)" }, + { id: "discount", label: "Скидка", group: "finance", editable: false, width: "minmax(50px, 0.5fr)" }, + { id: "payment_date", label: "Оплата", group: "finance", editable: true, width: "minmax(90px, 0.8fr)" }, // Dates - { id: "planned_date", label: "Плановая дата", group: "dates", editable: true }, - { id: "actual_date", label: "Фактическая дата", group: "dates", editable: false }, - { id: "format", label: "Плановый формат", group: "dates", editable: true }, - { id: "time_top", label: "Время в топе", group: "dates", editable: false }, - { id: "time_in_feed", label: "Время в ленте", group: "dates", editable: false }, - { id: "link_created", label: "Дата ссылки", group: "dates", editable: false }, - { id: "post_deleted", label: "Дата удаления", group: "dates", editable: false }, + { id: "planned_date", label: "План. выход", group: "dates", editable: true, width: "minmax(90px, 0.8fr)" }, + { id: "actual_date", label: "Факт", group: "dates", editable: false, width: "minmax(90px, 0.8fr)" }, + { id: "format", label: "Формат", group: "dates", editable: true, width: "minmax(100px, 0.8fr)" }, + { id: "time_top", label: "В топе", group: "dates", editable: false, width: "minmax(60px, 0.5fr)" }, + { id: "time_in_feed", label: "В ленте", group: "dates", editable: false, width: "minmax(60px, 0.5fr)" }, + { id: "link_created", label: "Ссылка", group: "dates", editable: false, width: "minmax(80px, 0.7fr)" }, + { id: "post_deleted", label: "Удалён", group: "dates", editable: false, width: "minmax(80px, 0.7fr)" }, // Metrics - { id: "subscriptions", label: "Всего подписок", group: "metrics", editable: false }, - { id: "views", label: "Просмотры", group: "metrics", editable: false }, + { id: "subscriptions", label: "Подписки", group: "metrics", editable: false, width: "minmax(70px, 0.6fr)" }, + { id: "views", label: "Просмотры", group: "metrics", editable: false, width: "minmax(70px, 0.6fr)" }, // Calculated - { id: "cpf", label: "CPF", group: "calculated", editable: false }, - { id: "cpm", label: "CPM", group: "calculated", editable: true, partialEdit: true }, - { id: "conversion_24h", label: "Конверсия 24ч", group: "calculated", editable: false }, - { id: "conversion_48h", label: "Конверсия 48ч", group: "calculated", editable: false }, - { id: "conversion_total", label: "Конверсия общ.", group: "calculated", editable: false }, - { id: "total_unsubs", label: "Всего отписок", group: "calculated", editable: false }, - { id: "unsub_percent", label: "% отписок", group: "calculated", editable: false }, - { id: "total_active", label: "Итого подписок", group: "calculated", editable: false }, + { id: "cpf", label: "CPF", group: "calculated", editable: false, width: "minmax(50px, 0.5fr)" }, + { id: "cpm", label: "CPM", group: "calculated", editable: true, partialEdit: true, width: "minmax(50px, 0.5fr)" }, + { id: "conversion_24h", label: "Конв. 24ч", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" }, + { id: "conversion_48h", label: "Конв. 48ч", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" }, + { id: "conversion_total", label: "Конв. общ.", group: "calculated", editable: false, width: "minmax(70px, 0.6fr)" }, + { id: "total_unsubs", label: "Отписки", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" }, + { id: "unsub_percent", label: "% отписок", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" }, + { id: "total_active", label: "Итого", group: "calculated", editable: false, width: "minmax(60px, 0.5fr)" }, // Technical - { id: "invite_type", label: "Тип ссылки", group: "technical", editable: true }, - { id: "comment", label: "Комментарий", group: "technical", editable: true }, + { id: "invite_type", label: "Тип ссылки", group: "technical", editable: true, width: "minmax(100px, 1fr)" }, + { id: "comment", label: "Комментарий", group: "technical", editable: true, width: "minmax(150px, 2fr)" }, ] as const; // Default visible columns @@ -81,9 +81,9 @@ export const DEFAULT_VISIBLE_COLUMNS = new Set([ "cost_format", "planned_date", "format", - "subscriptions", - "views", - "cpf", + // "subscriptions", + // "views", + // "cpf", "cpm", "comment", ]); diff --git a/lib/types/permission.ts b/lib/types/permission.ts new file mode 100644 index 0000000..e7bd76d --- /dev/null +++ b/lib/types/permission.ts @@ -0,0 +1,205 @@ +import type { PermissionKey, Permission, PermissionScope, PermissionScopeType } from "./api"; + +export const PERMISSION_GROUPS = { + admin: ["admin_full"] as const, + projects: ["projects_read", "projects_write"] as const, + creatives: ["creatives_read", "creatives_write"] as const, + placements: ["placements_read", "placements_write"] as const, + analytics: ["analytics_read", "analytics_without_clicks", "analytics_own_creatives"] as const, +} as const; + +export type PermissionGroup = keyof typeof PERMISSION_GROUPS; + +export const PERMISSION_ICONS: Record = { + admin_full: "👑", + projects_read: "📁", + projects_write: "📁", + creatives_read: "🎨", + creatives_write: "🎨", + placements_read: "📋", + placements_write: "📋", + analytics_read: "📊", + analytics_without_clicks: "📊", + analytics_own_creatives: "📊", +}; + +export const PERMISSION_LABELS: Record = { + admin_full: "Полный доступ к администрированию", + projects_read: "Просмотр проектов", + projects_write: "Управление проектами", + creatives_read: "Просмотр креативов", + creatives_write: "Управление креативами", + placements_read: "Просмотр размещений", + placements_write: "Управление размещениями", + analytics_read: "Просмотр аналитики", + analytics_without_clicks: "Аналитика без кликов", + analytics_own_creatives: "Аналитика своих креативов", +}; + +export function hasGlobalPermission(permissions: Permission[], key: PermissionKey): boolean { + return permissions.some((p) => p.key === key && (!p.scopes || p.scopes.length === 0)); +} + +export function hasScopedPermission( + permissions: Permission[], + key: PermissionKey, + scopeType: PermissionScopeType, + scopeId: string +): boolean { + const perm = permissions.find((p) => p.key === key); + if (!perm || !perm.scopes) return false; + return perm.scopes.some((s) => s.type === scopeType && s.id === scopeId); +} + +export function getScopesForPermission( + permissions: Permission[], + key: PermissionKey, + scopeType: PermissionScopeType +): string[] { + const perm = permissions.find((p) => p.key === key); + if (!perm || !perm.scopes) return []; + return perm.scopes.filter((s) => s.type === scopeType).map((s) => s.id); +} + +export function addGlobalPermission(permissions: Permission[], key: PermissionKey): Permission[] { + const perm = permissions.find((p) => p.key === key); + if (perm) { + if (!perm.scopes || perm.scopes.length === 0) { + return permissions; + } + const newPerms = permissions.filter((p) => p.key !== key); + return [...newPerms, { key, scopes: undefined }]; + } + return [...permissions, { key, scopes: undefined }]; +} + +export function removePermission(permissions: Permission[], key: PermissionKey): Permission[] { + return permissions.filter((p) => p.key !== key); +} + +export function setScopedPermission( + permissions: Permission[], + key: PermissionKey, + scopeType: PermissionScopeType, + scopeIds: string[] +): Permission[] { + const existing = permissions.find((p) => p.key === key); + const otherPerms = permissions.filter((p) => p.key !== key); + + if (scopeIds.length === 0) { + return otherPerms; + } + + const existingScopes = existing?.scopes?.filter((s) => s.type !== scopeType) || []; + const newScopes = [...existingScopes, ...scopeIds.map((id) => ({ type: scopeType, id }))]; + + return [...otherPerms, { key, scopes: newScopes }]; +} + +export function toggleScopedPermission( + permissions: Permission[], + key: PermissionKey, + scopeType: PermissionScopeType, + scopeId: string +): Permission[] { + const existing = permissions.find((p) => p.key === key); + const otherPerms = permissions.filter((p) => p.key !== key); + + if (!existing) { + return [...permissions, { key, scopes: [{ type: scopeType, id: scopeId }] }]; + } + + const scopes = existing.scopes || []; + const hasScope = scopes.some((s) => s.type === scopeType && s.id === scopeId); + + if (hasScope) { + const filtered = scopes.filter((s) => !(s.type === scopeType && s.id === scopeId)); + if (filtered.length === 0) { + return otherPerms; + } + return [...otherPerms, { key, scopes: filtered }]; + } + + return [...otherPerms, { key, scopes: [...scopes, { type: scopeType, id: scopeId }] }]; +} + +export function mergePermissions( + current: Permission[], + updates: Permission[] +): Permission[] { + const result = new Map(); + + for (const perm of current) { + result.set(perm.key, perm); + } + + for (const update of updates) { + const existing = result.get(update.key); + if (!existing) { + result.set(update.key, update); + } else if (!update.scopes || update.scopes.length === 0) { + result.set(update.key, update); + } else { + const existingScopes = existing.scopes || []; + const newScopes = new Map(); + + for (const scope of existingScopes) { + newScopes.set(`${scope.type}:${scope.id}`, scope); + } + for (const scope of update.scopes) { + newScopes.set(`${scope.type}:${scope.id}`, scope); + } + result.set(update.key, { key: update.key, scopes: Array.from(newScopes.values()) }); + } + } + + return Array.from(result.values()); +} + +export function calculatePermissionDiff( + original: Permission[], + updated: Permission[] +): { added: Permission[]; removed: Permission[]; modified: Permission[] } { + const added: Permission[] = []; + const removed: Permission[] = []; + const modified: Permission[] = []; + + const originalKeys = new Set(original.map((p) => p.key)); + const updatedKeys = new Set(updated.map((p) => p.key)); + + for (const perm of updated) { + if (!originalKeys.has(perm.key)) { + added.push(perm); + } + } + + for (const perm of original) { + if (!updatedKeys.has(perm.key)) { + removed.push(perm); + } + } + + for (const perm of updated) { + const orig = original.find((p) => p.key === perm.key); + if (orig && JSON.stringify(orig.scopes) !== JSON.stringify(perm.scopes)) { + modified.push(perm); + } + } + + return { added, removed, modified }; +} + +export function isPermissionFullyGlobal(permissions: Permission[], key: PermissionKey): boolean { + const perm = permissions.find((p) => p.key === key); + return perm !== undefined && (!perm.scopes || perm.scopes.length === 0); +} + +export function getAllScopeIdsForPermission( + permissions: Permission[], + key: PermissionKey, + scopeType: PermissionScopeType +): string[] { + const perm = permissions.find((p) => p.key === key); + if (!perm || !perm.scopes) return []; + return perm.scopes.filter((s) => s.type === scopeType).map((s) => s.id); +} diff --git a/package.json b/package.json index ab5dee9..f89e742 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.2", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ead1fe..e3c1d34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: '@radix-ui/react-radio-group': specifier: ^1.3.8 version: 1.3.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-scroll-area': + specifier: ^1.2.2 + version: 1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@radix-ui/react-select': specifier: ^2.2.6 version: 2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -925,6 +928,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.2.6': resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} peerDependencies: @@ -3692,6 +3708,23 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.2.3) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@radix-ui/number': 1.1.1