mono/packages/ui/src/modules/places/CompetitorsGridView.tsx
2026-03-25 20:50:22 +01:00

411 lines
17 KiB
TypeScript

import React, { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { DataGrid, type GridColDef, type GridFilterModel, type GridPaginationModel, type GridSortModel, type GridColumnVisibilityModel } from '@mui/x-data-grid';
import { type CompetitorFull } from '@polymech/shared';
import { EmailFinderToolbar } from './EmailFinderToolbar';
import { useCompetitorActions } from './useCompetitorActions';
import { ThemeProvider as MuiThemeProvider } from '@mui/material/styles';
import { useMuiTheme } from '@/hooks/useMuiTheme';
import { type CompetitorSettings } from './useCompetitorSettings';
// Extracted utils and components
import {
filterModelToParams,
paramsToFilterModel,
sortModelToParams,
paramsToSortModel,
visibilityModelToParams,
paramsToVisibilityModel,
paramsToColumnOrder
} from './gridUtils';
import { GridProgress } from './GridProgress';
import { useGridColumns } from './useGridColumns';
// import { useLocationEnrichment } from './hooks/useEnrichment';
interface CompetitorsGridViewProps {
competitors: CompetitorFull[];
loading: boolean;
settings: CompetitorSettings;
updateExcludedTypes: (types: string[]) => Promise<void>;
onOpenSettings: () => void;
showExcluded: boolean;
setShowExcluded: (show: boolean) => void;
enrich: (ids: string[], enrichers: string[]) => Promise<void>;
isEnriching: boolean;
enrichmentStatus: Map<string, string>;
}
export const CompetitorsGridView: React.FC<CompetitorsGridViewProps> = ({ competitors, loading, settings, updateExcludedTypes, onOpenSettings, showExcluded, setShowExcluded, enrich, isEnriching, enrichmentStatus }) => {
const muiTheme = useMuiTheme();
const [searchParams, setSearchParams] = useSearchParams();
// Initialize state from URL params
const [filterModel, setFilterModel] = useState<GridFilterModel>(() => paramsToFilterModel(searchParams));
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>(() => {
const page = parseInt(searchParams.get('page') || '0', 10);
const pageSize = parseInt(searchParams.get('pageSize') || '100', 10);
return { page, pageSize };
});
const [sortModel, setSortModel] = useState<GridSortModel>(() => paramsToSortModel(searchParams));
const [columnVisibilityModel, setColumnVisibilityModel] = useState<GridColumnVisibilityModel>(() => paramsToVisibilityModel(searchParams));
// State for showing excluded competitors - MOVED TO PARENT
// const [showExcluded, setShowExcluded] = useState(false);
// Filter competitors based on excluded types - MOVED TO PARENT
const filteredCompetitors = competitors;
console.log('filteredCompetitors', filteredCompetitors)
/*
const filteredCompetitors = React.useMemo(() => {
if (showExcluded) return competitors;
const excluded = settings.excluded_types || [];
if (excluded.length === 0) return competitors;
return competitors.filter(competitor => {
const types = competitor.types || [];
return !types.some(type => excluded.includes(type));
});
}, [competitors, showExcluded, settings.excluded_types]);
*/
// Column Widths state
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => {
try {
const saved = localStorage.getItem('competitorsColumnWidths');
return saved ? JSON.parse(saved) : {};
} catch (e) {
console.error('Failed to load column widths', e);
return {};
}
});
const handleColumnResize = React.useCallback((params: any) => {
const { field, width } = params.colDef;
setColumnWidths(prev => {
const next = { ...prev, [field]: width };
localStorage.setItem('competitorsColumnWidths', JSON.stringify(next));
return next;
});
}, []);
// Selection state
const [selectedRows, setSelectedRows] = useState<string[]>([]);
// Use custom hook for competitor actions
const {
emailLoadingStates,
emailData,
emailErrors,
isProcessing,
progress,
findEmails,
handleFindEmails,
handleCancel,
cancelRow,
handleExportThunderbird
} = useCompetitorActions({
competitors,
selectedRows,
excludedTypes: settings.excluded_types || [],
columnVisibilityModel
});
// const { enrich, isEnriching } = useLocationEnrichment();
// Handlers for "All Results" fallback
const handleFetchMeta = React.useCallback(() => {
const ids = selectedRows.length > 0 ? selectedRows : filteredCompetitors.map(c => String(c.place_id));
if (ids.length === 0) return;
enrich(ids, ['meta']);
}, [selectedRows, filteredCompetitors, enrich]);
const handleFetchEmail = React.useCallback(() => {
const ids = selectedRows.length > 0 ? selectedRows : filteredCompetitors.map(c => String(c.place_id));
if (ids.length === 0) return;
findEmails(ids);
}, [selectedRows, filteredCompetitors, findEmails]);
// Get Columns Definition
const columns = useGridColumns({
emailLoadingStates,
emailData,
emailErrors,
findEmails,
cancelRow,
settings,
updateExcludedTypes,
enrichmentStatus
});
// Update URL when filter model changes
const handleFilterModelChange = (newFilterModel: GridFilterModel) => {
setFilterModel(newFilterModel);
setSearchParams(prev => {
const newParams = new URLSearchParams(prev);
// Remove all existing filter params
Array.from(newParams.keys()).forEach(key => {
if (key.startsWith('filter_')) {
newParams.delete(key);
}
});
// Add new filter params
const filterParams = filterModelToParams(newFilterModel);
Object.entries(filterParams).forEach(([key, value]) => {
newParams.set(key, value);
});
return newParams;
}, { replace: true });
};
// Update URL when pagination changes
const handlePaginationModelChange = (newPaginationModel: GridPaginationModel) => {
setPaginationModel(newPaginationModel);
setSearchParams(prev => {
const newParams = new URLSearchParams(prev);
if (newPaginationModel.page !== 0) {
newParams.set('page', newPaginationModel.page.toString());
} else {
newParams.delete('page');
}
if (newPaginationModel.pageSize !== 10) {
newParams.set('pageSize', newPaginationModel.pageSize.toString());
} else {
newParams.delete('pageSize');
}
return newParams;
}, { replace: true });
};
// Update URL when sort model changes
const handleSortModelChange = (newSortModel: GridSortModel) => {
setSortModel(newSortModel);
setSearchParams(prev => {
const newParams = new URLSearchParams(prev);
const sortParams = sortModelToParams(newSortModel);
// Remove existing sort param
newParams.delete('sort');
// Add new sort param if exists
if (sortParams.sort) {
newParams.set('sort', sortParams.sort);
}
return newParams;
}, { replace: true });
};
// Update URL when column visibility changes
const handleColumnVisibilityModelChange = (newModel: GridColumnVisibilityModel) => {
setColumnVisibilityModel(newModel);
setSearchParams(prev => {
const newParams = new URLSearchParams(prev);
const visibilityParams = visibilityModelToParams(newModel);
// Remove existing hidden param
newParams.delete('hidden');
// Add new hidden param if exists (including empty string)
if (visibilityParams.hidden !== undefined) {
newParams.set('hidden', visibilityParams.hidden);
}
return newParams;
}, { replace: true });
};
const handleColumnOrderChange = (params: any) => {
// params.column is the column that moved
// params.targetIndex is the new index
// params.oldIndex is the old index
// We need to calculate the new full order
const currentOrder = orderedColumns.map(c => c.field);
const { field } = params.column;
const { targetIndex, oldIndex } = params;
const newOrder = [...currentOrder];
newOrder.splice(oldIndex, 1);
newOrder.splice(targetIndex, 0, field);
setSearchParams(prev => {
const newParams = new URLSearchParams(prev);
newParams.set('order', newOrder.join(','));
return newParams;
}, { replace: true });
};
// Apply column order from URL
const orderedColumns = React.useMemo(() => {
// Apply saved widths
const columnsWithWidths = columns.map(col => {
const savedWidth = columnWidths[col.field];
if (savedWidth) {
// If we have a saved width, we must remove/override flex to let width take effect
const { flex, ...rest } = col;
return {
...rest,
width: savedWidth,
};
}
return col;
});
const order = paramsToColumnOrder(searchParams);
if (order.length === 0) return columnsWithWidths;
const columnMap = new Map(columnsWithWidths.map(col => [col.field, col]));
const ordered: GridColDef[] = [];
// Add columns in specified order
order.forEach(field => {
const col = columnMap.get(field);
if (col) {
ordered.push(col);
columnMap.delete(field);
}
});
// Add remaining columns
columnMap.forEach(col => ordered.push(col));
return ordered;
}, [searchParams, columns, columnWidths]);
return (
<div className="space-y-4" style={{ width: '100%' }}>
<GridProgress isProcessing={isProcessing} progress={progress || null} />
<div className="overflow-hidden" style={{ height: 600, width: '100%' }}>
<MuiThemeProvider theme={muiTheme}>
<DataGrid
rows={filteredCompetitors}
columns={orderedColumns}
getRowId={(row) => row.place_id}
loading={loading}
filterModel={filterModel}
onFilterModelChange={handleFilterModelChange}
paginationModel={paginationModel}
onPaginationModelChange={handlePaginationModelChange}
sortModel={sortModel}
onSortModelChange={handleSortModelChange}
columnVisibilityModel={columnVisibilityModel}
onColumnVisibilityModelChange={handleColumnVisibilityModelChange}
onColumnWidthChange={handleColumnResize}
onColumnOrderChange={handleColumnOrderChange}
pageSizeOptions={[20, 50, 100, 250]}
checkboxSelection
showToolbar
disableRowSelectionOnClick
rowSelectionModel={{ type: 'include', ids: new Set(selectedRows) } as any}
onRowSelectionModelChange={(newSelection) => {
// Handle Array, Object with ids, Set, or generic Iterable
let ids: any[] = [];
if (Array.isArray(newSelection)) {
ids = newSelection;
} else if (newSelection && typeof newSelection === 'object') {
if ('ids' in newSelection) {
ids = Array.from((newSelection as any).ids);
} else if ((newSelection as any) instanceof Set) {
ids = Array.from(newSelection as any);
} else {
// Try generic iterable if safe
try {
ids = Array.from(newSelection as any);
} catch (e) {
console.warn('Could not parse selection model:', newSelection);
}
}
}
setSelectedRows(ids.map(id => String(id)));
}}
slots={{
toolbar: EmailFinderToolbar as any,
}}
slotProps={{
toolbar: {
selectedCount: selectedRows.length,
onFindEmails: handleFindEmails,
isProcessing,
progress,
onCancel: handleCancel,
onExportThunderbird: handleExportThunderbird,
showExcluded,
onToggleShowExcluded: setShowExcluded,
onOpenSettings,
onFetchMeta: handleFetchMeta,
onFetchEmail: handleFetchEmail,
isEnriching
} as any,
}}
className="border-0 rounded-none h-full"
sx={{
border: 0,
bgcolor: 'transparent',
color: 'hsl(var(--foreground))',
'& .MuiDataGrid-columnHeaders': {
borderColor: 'hsl(var(--border))',
},
'& .MuiDataGrid-cell': {
borderColor: 'hsl(var(--border))',
'&:focus, &:focus-within': { outline: 'none' },
},
'& .MuiDataGrid-columnHeader': {
'&:focus, &:focus-within': { outline: 'none' },
},
'& .MuiDataGrid-row.Mui-selected': {
backgroundColor: 'hsl(var(--primary) / 0.05)',
'&:hover': {
backgroundColor: 'hsl(var(--primary) / 0.1)',
},
},
'& .MuiDataGrid-footerContainer': {
borderColor: 'hsl(var(--border))',
},
'& .MuiTablePagination-root': {
color: 'hsl(var(--muted-foreground))',
},
'& .MuiDataGrid-overlay': {
bgcolor: 'transparent',
},
'& .MuiCheckbox-root': {
color: 'hsl(var(--muted-foreground))',
'&.Mui-checked': {
color: 'hsl(var(--primary))',
},
},
'& .MuiDataGrid-columnSeparator': {
color: 'hsl(var(--border))',
},
'& .MuiDataGrid-menuIcon button, & .MuiDataGrid-iconButtonContainer button': {
color: 'hsl(var(--muted-foreground))',
},
'& .MuiDataGrid-virtualScroller': {
overflowX: 'auto',
},
'& .MuiDataGrid-toolbarContainer': {
color: 'hsl(var(--foreground))',
'& .MuiButtonBase-root': {
color: 'hsl(var(--muted-foreground))',
},
},
}}
/>
</MuiThemeProvider>
</div>
</div>
);
};