diff --git a/packages/ui/docs/product-pacbot.md b/packages/ui/docs/product-pacbot.md index b417f04a..8374388e 100644 --- a/packages/ui/docs/product-pacbot.md +++ b/packages/ui/docs/product-pacbot.md @@ -19,7 +19,10 @@ Going beyond simple contact extraction, PAC-BOT provides structured, multi-dimen As part of its ongoing product roadmap, [Company Name] confirmed that PAC-BOT will soon evolve from a lead discovery engine into an automated global sales deployment. Upcoming features will enable the system to automatically generate hyper-tailored, localized outreach emails for any niche, in any language—allowing businesses to drop a pin anywhere in the world and instantly dispatch the perfect customized pitch. -- [ ] gridsearch progress | pause | resume | settings : presets ( lang , overview, nearby, discover ) +### Todos + +- [x] gridsearch progress | pause | resume +- [ ] settings : presets ( lang , overview, nearby, discover ) - [ ] share => noFilters | columns | filters - [ ] types => partial / fuzzy match | post filters => import contacts - [ ] report => email diff --git a/packages/ui/src/modules/places/client-gridsearch.ts b/packages/ui/src/modules/places/client-gridsearch.ts index 1a6ca13b..7919773f 100644 --- a/packages/ui/src/modules/places/client-gridsearch.ts +++ b/packages/ui/src/modules/places/client-gridsearch.ts @@ -130,6 +130,16 @@ export const retryPlacesGridSearchJob = async (id: string): Promise => { }); }; +export const postPlacesGridSearchControl = async ( + id: string, + action: 'pause' | 'resume' | 'cancel', +): Promise<{ ok: boolean }> => { + return apiClient<{ ok: boolean }>(`/api/places/gridsearch/${id}/control`, { + method: 'POST', + body: JSON.stringify({ action }), + }); +}; + export const expandPlacesGridSearch = async ( parentId: string, areas: { gid: string; name: string; level: number; raw?: any }[], diff --git a/packages/ui/src/modules/places/components/map-layers/LiveSearchLayers.tsx b/packages/ui/src/modules/places/components/map-layers/LiveSearchLayers.tsx index 1f9984a7..47c9300f 100644 --- a/packages/ui/src/modules/places/components/map-layers/LiveSearchLayers.tsx +++ b/packages/ui/src/modules/places/components/map-layers/LiveSearchLayers.tsx @@ -1,6 +1,27 @@ import React, { useEffect } from 'react'; import maplibregl from 'maplibre-gl'; +/** Active = amber (in progress); finished = emerald (done). */ +function liveAreaFillColorExpr(isDark: boolean): maplibregl.ExpressionSpecification { + return [ + 'match', + ['get', 'liveAreaState'], + 'finished', + isDark ? 'rgba(52, 211, 153, 0.26)' : 'rgba(16, 185, 129, 0.3)', + isDark ? 'rgba(250, 204, 21, 0.22)' : 'rgba(234, 179, 8, 0.2)', + ]; +} + +function liveAreaLineColorExpr(isDark: boolean): maplibregl.ExpressionSpecification { + return [ + 'match', + ['get', 'liveAreaState'], + 'finished', + isDark ? '#34d399' : '#059669', + isDark ? '#fbbf24' : '#ca8a04', + ]; +} + export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkStyle }: { map: maplibregl.Map, liveAreas: any[], liveRadii: any[], liveNodes: any[], isDarkStyle: boolean }) { useEffect(() => { if (!map) return; @@ -16,7 +37,7 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS type: 'fill', source: 'live-areas', paint: { - 'fill-color': '#eab308', + 'fill-color': liveAreaFillColorExpr(isDarkStyle), 'fill-opacity': 0.15 } }); @@ -25,7 +46,7 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS type: 'line', source: 'live-areas', paint: { - 'line-color': '#eab308', + 'line-color': liveAreaLineColorExpr(isDarkStyle), 'line-width': 2, 'line-dasharray': [2, 2] } @@ -64,6 +85,17 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS }; }, [map]); + useEffect(() => { + if (!map || !map.getStyle()) return; + if (!map.getLayer('live-areas-fill')) return; + try { + map.setPaintProperty('live-areas-fill', 'fill-color', liveAreaFillColorExpr(isDarkStyle)); + map.setPaintProperty('live-areas-line', 'line-color', liveAreaLineColorExpr(isDarkStyle)); + } catch { + /* style swap race */ + } + }, [map, isDarkStyle]); + useEffect(() => { if (!map || !map.getStyle() || !map.getSource('live-areas')) return; @@ -73,7 +105,10 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS if (geom) { return { type: 'Feature', - properties: { name: a.name }, + properties: { + name: a.name, + liveAreaState: a.liveAreaState === 'finished' ? 'finished' : 'active', + }, geometry: geom }; } diff --git a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx index 09638b6f..d5ae34c0 100644 --- a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx +++ b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useRef } from 'react'; import { useSearchParams } from 'react-router-dom'; -import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2, Share2, Image as ImageIcon, Palette, PanelLeftClose, PanelLeftOpen, Merge } from 'lucide-react'; +import { LayoutGrid, List, Map as MapIcon, PieChart, FileText, Terminal, PlusCircle, Loader2, Share2, Image as ImageIcon, Palette, PanelLeftClose, PanelLeftOpen, Merge, Pause, Play, Square } from 'lucide-react'; import { CompetitorsGridView } from '../PlacesGridView'; import { PlacesMapView } from '../PlacesMapView'; @@ -8,7 +8,8 @@ import { PlacesThumbView } from '../PlacesThumbView'; import { PlacesMetaView } from '../PlacesMetaView'; import { PlacesReportView } from './PlacesReportView'; import { useRestoredSearch } from './RestoredSearchContext'; -import { expandPlacesGridSearch } from '../client-gridsearch'; +import { expandPlacesGridSearch, postPlacesGridSearchControl } from '../client-gridsearch'; +import { useGridSearchStream, computeGridSearchOverallPercent } from './GridSearchStreamContext'; import { POSTER_THEMES } from '../utils/poster-themes'; import { type PlaceFull } from '@polymech/shared'; @@ -65,6 +66,33 @@ import { UserPlus } from 'lucide-react'; export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes, updateExcludedTypes, liveAreas, liveRadii, liveNodes, liveScanner, stats, streaming, statusMessage, sseLogs, onExpandSubmitted, onMergeSubmitted, isOwner = false, isPublic, onTogglePublic, isSidebarOpen, onToggleSidebar }: GridSearchResultsProps) => { + const { controlPaused, stats: streamStats, statusMessage: streamStatus } = useGridSearchStream(); + const overallPct = streaming ? computeGridSearchOverallPercent(streamStats) : null; + const phaseLabel = + streamStats.phase === 'enriching' + ? translate('Enrichment') + : streamStats.phase === 'searching' || streamStats.phase === 'grid' + ? translate('Grid search') + : streamStats.phase === 'complete' + ? translate('Done') + : translate('Starting…'); + const [runControlBusy, setRunControlBusy] = useState(false); + const controlInFlight = useRef(false); + + const sendRunControl = useCallback(async (action: 'pause' | 'resume' | 'cancel') => { + if (!streaming || controlInFlight.current) return; + controlInFlight.current = true; + setRunControlBusy(true); + try { + await postPlacesGridSearchControl(jobId, action); + } catch (e) { + console.error('Grid search control failed:', e); + } finally { + controlInFlight.current = false; + setRunControlBusy(false); + } + }, [jobId, streaming]); + const [showMergeDialog, setShowMergeDialog] = useState(false); const [showImportToContactsDialog, setShowImportToContactsDialog] = useState(false); @@ -280,7 +308,44 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes return (
-
+ {streaming && ( +
+
+ + {phaseLabel} + {streamStats.totalWaypoints > 0 && streamStats.phase !== 'enriching' && ( + + {' '} + · {streamStats.completedWaypoints}/{streamStats.totalWaypoints} {translate('cells')} + + )} + {streamStats.phase === 'enriching' && streamStats.enrichTotal > 0 && ( + + {' '} + · {streamStats.totalLocationsEnriched}/{streamStats.enrichTotal} + + )} + + {overallPct != null && ( + {overallPct}% + )} +
+
+
+
+ {streamStatus && ( +

+ {streamStatus} +

+ )} +
+ )} +
{/* Left: sidebar toggle + expand button */}
{onToggleSidebar && ( @@ -292,6 +357,36 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes {isSidebarOpen ? : } )} + {streaming && ( +
+ + +
+ )} {isOwner && freshRegions.length > 0 && (
- + ; } + if (status === 'cancelled') { + return ; + } return ; }; @@ -48,23 +52,30 @@ const SearchRow = ({ isChild, onSelect, onDelete, + storedProgressPct, + onStopActive, }: { search: GridSearchSummary; isSelected: boolean; isChild?: boolean; onSelect: () => void; onDelete: () => void; -}) => ( + storedProgressPct: number | null; + onStopActive?: (e: React.MouseEvent) => void; +}) => { + const isActiveJob = search.status === 'searching' || search.status === 'enriching'; + return (
-
+
+
{isChild ? ( ) : null} @@ -112,6 +123,19 @@ const SearchRow = ({
+ {isActiveJob && onStopActive && ( + + )} {isSelected && }
+
+ {isActiveJob && ( +
+
+
+
+ {storedProgressPct != null && ( + {storedProgressPct}% + )} +
+ )}
); +}; export const OngoingSearches = ({ onSelectJob, selectedJobId, onSearchDeleted }: { onSelectJob?: (jobId: string) => void, selectedJobId?: string | null, onSearchDeleted?: (jobId: string) => void }) => { const [pastSearches, setPastSearches] = useState([]); + const [, bumpProgressRead] = useState(0); + + const hasActiveInList = useMemo(() => { + const active = (s: GridSearchSummary) => s.status === 'searching' || s.status === 'enriching'; + return pastSearches.some(s => active(s) || s.children?.some(active)); + }, [pastSearches]); + + useEffect(() => { + if (!hasActiveInList) return; + const t = setInterval(() => bumpProgressRead(n => n + 1), 1500); + return () => clearInterval(t); + }, [hasActiveInList]); const loadPast = async () => { try { @@ -149,6 +202,16 @@ export const OngoingSearches = ({ onSelectJob, selectedJobId, onSearchDeleted }: return () => clearInterval(interval); }, []); + const handleStopSearch = async (e: React.MouseEvent, id: string) => { + e.stopPropagation(); + try { + await postPlacesGridSearchControl(id, 'cancel'); + await loadPast(); + } catch (err) { + console.error('Stop search failed:', err); + } + }; + const handleDelete = async (id: string, parent?: string) => { if (confirm('Are you sure you want to delete this search?')) { try { @@ -180,6 +243,12 @@ export const OngoingSearches = ({ onSelectJob, selectedJobId, onSearchDeleted }: isSelected={selectedJobId === search.id} onSelect={() => onSelectJob && onSelectJob(search.id)} onDelete={() => handleDelete(search.id)} + storedProgressPct={ + search.status === 'searching' || search.status === 'enriching' + ? readStoredGridSearchProgress(search.id)?.pct ?? null + : null + } + onStopActive={(e) => handleStopSearch(e, search.id)} /> {search.children && search.children.length > 0 && search.children.map(child => ( onSelectJob && onSelectJob(child.id)} onDelete={() => handleDelete(child.id, search.id)} + storedProgressPct={ + child.status === 'searching' || child.status === 'enriching' + ? readStoredGridSearchProgress(child.id)?.pct ?? null + : null + } + onStopActive={(e) => handleStopSearch(e, child.id)} /> ))} diff --git a/packages/ui/src/modules/places/gridsearch/RestoredSearchContext.tsx b/packages/ui/src/modules/places/gridsearch/RestoredSearchContext.tsx index 1402b6f3..6c105743 100644 --- a/packages/ui/src/modules/places/gridsearch/RestoredSearchContext.tsx +++ b/packages/ui/src/modules/places/gridsearch/RestoredSearchContext.tsx @@ -66,7 +66,7 @@ export function RestoredSearchProvider({ const status = state?.run?.status || ''; const isLocked = status === 'searching' || status === 'enriching'; - const isComplete = status === 'complete'; + const isComplete = status === 'complete' || status === 'cancelled'; return (