411 lines
17 KiB
TypeScript
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>
|
|
);
|
|
};
|