import { useEffect, useMemo, useState, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' import { flexRender, getCoreRowModel, useReactTable, type ColumnDef, type RowSelectionState, } from '@tanstack/react-table' import { Search, Info, MousePointerClick, ChevronLeft, ChevronRight, } from 'lucide-react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { useIsMobile } from '@/hooks/use-mobile' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger, } from '@/components/ui/popover' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table' import { StatusBadge } from '@/components/status-badge' import { applyUpstreamOverwrite } from '../../api' import { modelsQueryKeys, vendorsQueryKeys } from '../../lib' import type { SyncOverwritePayload } from '../../types' import { useModels } from '../models-provider' const FIELD_LABELS: Record = { description: 'Description', icon: 'Icon', tags: 'Tags', vendor: 'Vendor', name_rule: 'Name Rule', status: 'Status', endpoints: 'Endpoints', quota_types: 'Quota Types', enable_groups: 'Enable Groups', } const formatValue = (value: unknown) => { if (value === null || value === undefined) return '—' if (typeof value === 'string') return value || '—' if (typeof value === 'number' || typeof value === 'boolean') { return String(value) } try { return JSON.stringify(value, null, 2) } catch { return String(value) } } type UpstreamConflictDialogProps = { open: boolean onOpenChange: (open: boolean) => void } type ConflictFieldRow = { id: string modelName: string fieldKey: string fieldLabel: string localValue: unknown upstreamValue: unknown } function ValuePreview({ value }: { value: unknown }) { return (
      {formatValue(value)}
    
) } export function UpstreamConflictDialog({ open, onOpenChange, }: UpstreamConflictDialogProps) { const { t } = useTranslation() const queryClient = useQueryClient() const { upstreamConflicts = [], setUpstreamConflicts, syncWizardOptions, } = useModels() const isMobile = useIsMobile() const [search, setSearch] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const [rowSelection, setRowSelection] = useState({}) const [pageSize, setPageSize] = useState(10) const [pageIndex, setPageIndex] = useState(0) useEffect(() => { if (open) { setRowSelection({}) setSearch('') setPageIndex(0) } }, [open, upstreamConflicts]) const conflictRows = useMemo(() => { return upstreamConflicts.flatMap((conflict) => { if (!conflict.fields?.length) { return [] } return conflict.fields.map((field) => ({ id: `${conflict.model_name}-${field.field}`, modelName: conflict.model_name, fieldKey: field.field, fieldLabel: FIELD_LABELS[field.field] || field.field, localValue: field.local, upstreamValue: field.upstream, })) }) }, [upstreamConflicts]) const totalModels = upstreamConflicts.length const totalFields = conflictRows.length const normalizedSearch = search.trim().toLowerCase() const { matchingModelNames, visibleRowIds } = useMemo(() => { if (!normalizedSearch) { return { matchingModelNames: null, visibleRowIds: null } } const modelMatches = new Set() upstreamConflicts.forEach((conflict) => { const modelMatch = conflict.model_name ?.toLowerCase() .includes(normalizedSearch) if (modelMatch) { modelMatches.add(conflict.model_name) return } const fieldMatch = conflict.fields?.some((field) => { const label = FIELD_LABELS[field.field] || field.field return ( label.toLowerCase().includes(normalizedSearch) || field.field.toLowerCase().includes(normalizedSearch) ) }) if (fieldMatch) { modelMatches.add(conflict.model_name) } }) const rowIdSet = new Set() conflictRows.forEach((row) => { if ( modelMatches.has(row.modelName) || row.fieldLabel.toLowerCase().includes(normalizedSearch) || row.fieldKey.toLowerCase().includes(normalizedSearch) ) { rowIdSet.add(row.id) } }) return { matchingModelNames: modelMatches, visibleRowIds: rowIdSet } }, [normalizedSearch, upstreamConflicts, conflictRows]) const columns = useMemo[]>(() => { const modelColumn: ColumnDef = { accessorKey: 'modelName', header: 'Model', cell: ({ row }) => (
{isMobile ? ( row.toggleSelected(!!value)} aria-label='Select row' /> ) : null}

{row.original.modelName}

{row.original.fieldKey} {isMobile ? ( ) : null}
), size: 220, } const diffColumn: ColumnDef = { id: 'actions', header: 'Diff', enableSorting: false, enableHiding: false, cell: ({ row }) => (
                {formatValue(row.original.localValue)}
              
                {formatValue(row.original.upstreamValue)}
              
), size: isMobile ? 50 : 140, } if (isMobile) { return [modelColumn, diffColumn] } const selectionColumn: ColumnDef = { id: 'select', header: ({ table }) => ( table.toggleAllPageRowsSelected(!!value)} aria-label='Select all' /> ), cell: ({ row }) => ( row.toggleSelected(!!value)} aria-label='Select row' /> ), enableSorting: false, enableHiding: false, size: 40, } return [ selectionColumn, modelColumn, { accessorKey: 'fieldLabel', header: 'Field', cell: ({ row }) => ( ), enableSorting: false, size: 160, }, { accessorKey: 'localValue', header: 'Local Value', cell: ({ row }) => , }, { accessorKey: 'upstreamValue', header: 'Upstream Value', cell: ({ row }) => , }, ] }, [isMobile]) const table = useReactTable({ data: conflictRows, columns, state: { rowSelection, }, enableRowSelection: true, onRowSelectionChange: setRowSelection, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.id, }) const totalSelectedFields = table.getSelectedRowModel().rows.length const hasSelection = totalSelectedFields > 0 const allRows = table.getRowModel().rows const filteredRows = visibleRowIds ? allRows.filter((row) => visibleRowIds.has(row.id)) : allRows const totalFilteredFields = filteredRows.length const totalPages = totalFilteredFields === 0 ? 1 : Math.ceil(totalFilteredFields / pageSize) useEffect(() => { setPageIndex((prev) => Math.min(prev, Math.max(0, totalPages - 1))) }, [totalPages]) const pageStart = pageIndex * pageSize const paginatedRows = filteredRows.slice(pageStart, pageStart + pageSize) const displayStart = totalFilteredFields === 0 ? 0 : pageStart + 1 const displayEnd = totalFilteredFields === 0 ? 0 : Math.min(pageStart + pageSize, totalFilteredFields) const currentPageDisplay = totalFilteredFields === 0 ? 0 : pageIndex + 1 const totalPagesDisplay = totalFilteredFields === 0 ? 0 : totalPages const visibleModelCount = matchingModelNames?.size ?? totalModels const visibleFieldCount = totalFilteredFields const hasConflicts = totalFields > 0 const showSearchEmptyState = hasConflicts && totalFilteredFields === 0 const clearSelections = useCallback(() => { setRowSelection({}) }, []) const handleApplyOverwrite = async () => { const selectedRows = table.getSelectedRowModel().rows const groupedSelections = selectedRows.reduce>>( (acc, row) => { const key = row.original.modelName if (!acc[key]) { acc[key] = new Set() } acc[key].add(row.original.fieldKey) return acc }, {} ) const payload: SyncOverwritePayload[] = Object.entries(groupedSelections) .map(([modelName, fields]) => ({ model_name: modelName, fields: Array.from(fields), })) .filter((item) => item.fields.length > 0) if (payload.length === 0) { toast.warning(t('Select at least one field to overwrite.')) return } setIsSubmitting(true) try { const response = await applyUpstreamOverwrite({ overwrite: payload, locale: syncWizardOptions.locale, source: syncWizardOptions.source, }) if (response.success) { toast.success(t('Selected conflicts were overwritten successfully.')) queryClient.invalidateQueries({ queryKey: modelsQueryKeys.lists() }) queryClient.invalidateQueries({ queryKey: vendorsQueryKeys.lists() }) setUpstreamConflicts([]) onOpenChange(false) } else { toast.error(response.message || t('Failed to apply overwrite.')) } } catch (error: unknown) { toast.error((error as Error)?.message || t('Failed to apply overwrite.')) } finally { setIsSubmitting(false) } } return ( { if (!nextOpen) { setUpstreamConflicts([]) } onOpenChange(nextOpen) }} > { if (isMobile) { event.preventDefault() } }} >
{t('Resolve Conflicts')} {t( 'Select the fields you want to overwrite with upstream data. Unselected fields keep their local values.' )} {!hasConflicts ? (
{t('No conflict entries available.')}
) : (
{visibleModelCount} {t('model')} {visibleModelCount === 1 ? '' : 's'} {t('with conflicts')}
{visibleFieldCount} {t('field')} {visibleFieldCount === 1 ? '' : 's'} {t('showing •')}{' '} {totalSelectedFields} {t('selected')}
{ setSearch(event.target.value) setPageIndex(0) }} placeholder={t('Search models or fields...')} className='pl-9' aria-label={t('Search conflicting models or fields')} />
{showSearchEmptyState ? (
{t('No conflicts match your search.')}
) : (
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => ( {header.isPlaceholder ? null : flexRender( header.column.columnDef.header, header.getContext() )} ))} ))} {paginatedRows.map((row) => ( {row.getVisibleCells().map((cell) => ( {flexRender( cell.column.columnDef.cell, cell.getContext() )} ))} ))}
{t('Showing')} {displayStart}-{displayEnd} {t('of')}{' '} {visibleFieldCount} {t('field')} {visibleFieldCount === 1 ? '' : 's'}
{t('Rows per page')}
{t('Page {{current}} of {{total}}', { current: currentPageDisplay, total: totalPagesDisplay, })}
)}
)}
{t( 'Only selected fields will be overwritten. You can re-run the sync wizard if new conflicts appear.' )}
) }