lollipop match 2/2

This commit is contained in:
lovebird 2026-04-15 18:07:48 +02:00
parent 9024b71e6c
commit 6529f9d49a
7 changed files with 554 additions and 105 deletions

View File

@ -31,7 +31,9 @@ This document captures the current codebase touchpoints and a proposed architect
- **On**: full `competitors` list + **seeded MUI column filters** on `types` via `seedExcludeTypeFilters={excludedTypes}` (`PlacesGridView` / `useGridColumns` custom operator `excludesAnyOf`).
- **Saved excludes** are loaded/stored via **`/api/me/secrets`** as `gridsearch_exclude_types` (see `client-gridsearch.ts` and `PlacesLibrary.postPlacesGridSearchExpand` reading `getUserSecrets`).
**Implication:** “AI Filters” should **compose** with this: same DataGrid, extra **dynamic columns** whose `field` names match **target fields** stored under `meta`.
**Target UX:** Introduce a **`gridFiltersOpen`** (name TBD) collapsible **below** the main toolbar row—same structural pattern as **`aiContextOpen`** + violet panel (see ~`{aiContextOpen && (…textarea…)}`). **Grid filters** becomes the parent affordance: expanded panel holds **type-exclude / MUI hint copy** and the **AI filter editor** (not a duplicate top-level “AI” entry). Today the **Sparkles** control sits beside view modes; plan is to **merge** grid-related AI into this **Grid filters** section.
**Implication:** “AI Filters” **compose** with existing grid behavior: same DataGrid, extra **dynamic columns** whose `field` names match **target fields** stored under `meta`.
### 2.2 MUI grid columns (`useGridColumns.tsx`, `PlacesGridView.tsx`)
@ -41,8 +43,8 @@ This document captures the current codebase touchpoints and a proposed architect
### 2.3 Place LLM today (`server/src/products/places/llm.ts`, `place-info.md`)
- `GET` handler resolves **`runId`** / manual **`context`**, builds `${grid_search_context}`, runs kbot completion, returns JSON including **`potentialCustomer`** (15) when context exists.
- **Planned product change:** drop `potentialCustomer` from this response and prompt; scoring moves to **per-filter** outputs stored in **`meta`**, not the summary tabs single score.
- `GET` handler resolves **`runId`** / manual **`context`**, builds `${grid_search_context}`, runs kbot completion, returns summary JSON for the place detail tab.
- **Product direction:** campaign fit moves to **per-filter** outputs in **`meta.llm_grid_filters`**, not a dedicated **`potentialCustomer`** field on the summary response (removed from prompt/UI per §7).
### 2.4 `places.meta` (`server/src/products/places/db-places.ts`, `places.ts`)
@ -118,6 +120,8 @@ meta.llm_grid_filters = {
## 4. Server API sketch
**Home for handlers:** implement in **`server/src/products/places/llm.ts`** (re-export or register from `PlacesProduct` like existing LLM routes). Keeps kbot tasks, Zod `format`, and prompt path helpers in one place.
| Endpoint | Purpose |
|----------|---------|
| `POST /api/places/llm-filters/run` | Body: `{ placeIds: string[], filterIds: string[], jobId?: string }`. Enqueues work, returns `{ runId }`. |
@ -203,18 +207,22 @@ This avoids proliferating files while keeping **consistent** privacy rules and *
---
## 6. Frontend refactor (reduce `GridSearchResults.tsx` size)
## 6. Frontend (after server): Grid Filters panel + components
**Pattern:** Reuse the **Context** editor UX: a **toolbar button** toggles open state; when open, a **full-width panel** under the toolbar (bordered card, compact labels) holds forms. Apply the same for **AI filters** inside the **Grid filters** region—**not** a separate floating section next to view modes.
Suggested new files under `src/modules/places/gridsearch/`:
| Module | Responsibility |
|--------|------------------|
| `GridFiltersPanel.tsx` (or split) | Collapsible content for **Grid filters**: type-exclude explanation, **AI filter list** (add/remove/enable), prompts, target field, run actions. Consumed when `gridFiltersOpen` (or merged state with `applySavedExcludesInGrid`). |
| `aiGridFiltersState.ts` | Load/save defs from secrets; enabled set per `jobId`; localStorage helpers if used. |
| `useAiGridColumns.ts` | Build `GridColDef[]` from enabled defs + `valueGetter` from `meta.llm_grid_filters`. |
| `AiGridFiltersToolbar.tsx` | Add/remove/enable filters, “Run on selection / run on all” actions. |
| `PlacesGridView.tsx` (or wrapper) | Accept **optional** `extraColumns: GridColDef[]` merged after static columns. |
`GridSearchResults.tsx` keeps: layout, view mode, wiring **props** into `CompetitorsGridView`.
`GridSearchResults.tsx` stays layout-only: view modes, **`GridFiltersPanel`** slot, **`CompetitorsGridView`** props.
**Phase order:** build **Grid Filters** collapsible + editor **after** endpoints exist (mock with static data if needed); add **MUI dynamic columns** once `meta.llm_grid_filters` flows from the server.
---
@ -232,17 +240,16 @@ Suggested new files under `src/modules/places/gridsearch/`:
- **MUI single-filter constraint:** adding many AI columns may interact badly with **community** grid limits; may need to **lift** filter encoding (documented in `useGridColumns`) when AI columns need filtering simultaneously with types.
- **Meta size:** cap string length per cell; strip `raw` in list views if large.
- **Cost / abuse:** rate limits per user; optional confirmation before “run on all.”
yeah, we take care of those but good point, we may disable certain columns for public shared searches
- **Public / shared grid searches:** hide or disable **AI filter** columns and editor affordances for viewers when `is_public` and `!isOwner` (same spirit as hiding sensitive tools); only owner runs LLM fills that write `meta`.
---
## 9. Suggested implementation phases
## 9. Suggested implementation phases (matches § order at top)
1. **Schema & types:** secrets shape + `meta.llm_grid_filters` TypeScript interfaces; **`AiGridFilterResultSchema`** (Zod) + **`zodResponseFormat`** wired into kbot `run()` tasks (pm-pics + shared if exported).
2. **Server:** single-place single-filter endpoint (debug) using **`format`** → validate with **`.parse()`** → batch + SSE → `updatePlaceMeta` merge.
3. **Client:** secrets editor + dynamic columns + run button; subscribe SSE and patch row state / refetch place.
4. **Cleanup:** remove `potentialCustomer` from place-info pipeline; align docs : done
1. **Server (`llm.ts` + routes):** Zod **`AiGridFilterResultSchema`** + **`zodResponseFormat`**; single-place single-filter handler (debug); **`POST …/run`** + **`GET …/stream`** (or EventBus bridge); **`updatePlaceMeta`** merge with **`promptHash`** checks.
2. **Client — Grid Filters UI:** **`GridFiltersPanel`** (or equivalent); filter editor **like Context** (collapsible under **Grid filters**); wire secrets **`PATCH /api/me/secrets`** for definitions; run actions calling batch API + SSE.
3. **Client — MUI:** **`useAiGridColumns`** + **`CompetitorsGridView`** `extraColumns`; row refresh on SSE / refetch.
4. **Product cleanup:** `potentialCustomer` removal from place-info pipeline — **done** where noted in §1 / §7.
---
@ -253,7 +260,8 @@ yeah, we take care of those but good point, we may disable certain columns for p
| Grid toggle & toolbar | `src/modules/places/gridsearch/GridSearchResults.tsx` |
| Column defs | `src/modules/places/useGridColumns.tsx`, `PlacesGridView.tsx` |
| Secrets (exclude types) | `src/modules/places/client-gridsearch.ts`, `server/.../places/places.ts` |
| Place LLM | `server/src/products/places/llm.ts`, `server/data/products/places/llm/place-info.md` |
| Place LLM & **new grid-filter LLM** | `server/src/products/places/llm.ts` (handlers + kbot tasks), `server/data/products/places/llm/place-info.md`, `grid-filter.md` (TBD) |
| Grid Filters UI / Context pattern | `src/modules/places/gridsearch/GridSearchResults.tsx` (toolbar; collapse under Grid filters per §6) |
| SSE | `server/src/products/places/index.ts` (`handleGetPlacesGridSearchStream`), `server/src/products/places/routes.ts` |
| Meta persistence | `server/src/products/places/db-places.ts` (`updatePlaceMeta`) |
| Shared place type | `packages/ui/shared/src/competitors/schemas.ts` (`PlaceSchemaFull`) |
@ -263,4 +271,4 @@ yeah, we take care of those but good point, we may disable certain columns for p
---
*Draft for review; no implementation committed in this step.*
*Living plan — implementation follows server (`llm.ts`) → Grid Filters panel + editor → MUI columns.*

View File

@ -15,15 +15,12 @@ export const LocationDetailView = React.memo(({
onClose,
livePhotos,
gridSearchRunId,
potentialCustomerContext,
}: {
competitor: PlaceFull;
onClose?: () => void;
livePhotos?: any;
/** When set (e.g. grid search job id), server loads grid_search_runs.request for campaign fit scoring */
/** When set (e.g. grid search job id), server loads grid_search_runs.request for place summary context */
gridSearchRunId?: string | null;
/** When set, overrides grid run JSON for Potential customer scoring (user “Context” in grid search UI) */
potentialCustomerContext?: string | null;
}) => {
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
@ -61,7 +58,6 @@ export const LocationDetailView = React.memo(({
fetchPlaceLlmSummary(competitor.place_id, {
lang,
runId: gridSearchRunId || undefined,
context: potentialCustomerContext?.trim() || undefined,
})
.then((data) => {
if (!cancelled) setSummaryData(data);
@ -75,7 +71,7 @@ export const LocationDetailView = React.memo(({
return () => {
cancelled = true;
};
}, [activeTab, competitor.place_id, gridSearchRunId, potentialCustomerContext]);
}, [activeTab, competitor.place_id, gridSearchRunId]);
// Prefer prop-injected, then dynamically fetched, then DB-cached
const photoSource = livePhotos || fetchedPhotos || competitor.raw_data?.google_media;

View File

@ -47,6 +47,8 @@ function rowHasLeadEmail(row: any): boolean {
export type CompetitorsGridViewHandle = {
/** Row IDs (place ids) that pass the current grid filter model and quick filter. */
getFilteredPlaceIds: () => string[];
/** Currently selected row IDs (checkbox selection). */
getSelectedPlaceIds: () => string[];
};
interface PlacesGridViewProps {
@ -63,6 +65,8 @@ interface PlacesGridViewProps {
seedExcludeTypeFilters?: string[];
/** When false, filter/sort/pagination changes are not written to the URL (avoids collisions with multiple `types` filters). */
persistFiltersInUrl?: boolean;
/** Appended after base columns (e.g. AI grid filters). */
extraColumns?: GridColDef[];
}
const CustomToolbar = ({ selectedCount }: { selectedCount: number }) => {
@ -97,6 +101,7 @@ export const CompetitorsGridView = forwardRef<CompetitorsGridViewHandle, PlacesG
preset = 'full',
seedExcludeTypeFilters,
persistFiltersInUrl = true,
extraColumns,
}, ref) {
const muiTheme = useMuiTheme();
const [searchParams, setSearchParams] = useSearchParams();
@ -197,7 +202,8 @@ export const CompetitorsGridView = forwardRef<CompetitorsGridViewHandle, PlacesG
.filter((id) => filteredRowsLookup[id] !== false)
.map((id) => String(id));
},
}), []);
getSelectedPlaceIds: () => [...selectedRows],
}), [selectedRows]);
// Sync local highlighted state with global selectedPlaceId
useEffect(() => {
@ -209,20 +215,66 @@ export const CompetitorsGridView = forwardRef<CompetitorsGridViewHandle, PlacesG
// Removed local sidebar panel state (moved to GridSearchResults)
// Get Columns Definition
const columns = useGridColumns({
const baseColumns = useGridColumns({
settings,
updateExcludedTypes
});
// Sync Visibility Model when preset changes
const columns = React.useMemo(
() => [...baseColumns, ...(extraColumns || [])],
[baseColumns, extraColumns],
);
/** Apply min/full preset only when `preset` actually changes — not on remount (keeps URL column visibility / order). */
const presetVisibilityBootstrappedRef = useRef(false);
const lastPresetRef = useRef<GridPreset | null>(null);
useEffect(() => {
if (!presetVisibilityBootstrappedRef.current) {
presetVisibilityBootstrappedRef.current = true;
lastPresetRef.current = preset;
return;
}
if (lastPresetRef.current === preset) return;
lastPresetRef.current = preset;
const presetModel = getPresetVisibilityModel(preset);
setColumnVisibilityModel(prev => ({
...prev,
...presetModel
}));
setColumnVisibilityModel((prev) => ({ ...prev, ...presetModel }));
}, [preset]);
/** Keep filter model in sync when switching “saved excludes as grid filters” without remounting (preserves columns). */
const filterSyncKey = React.useMemo(() => searchParams.toString(), [searchParams]);
useEffect(() => {
if (seedExcludeTypeFilters?.length) {
const list = seedExcludeTypeFilters.map((t) => String(t).trim()).filter(Boolean);
setFilterModel({
items: list.length
? [
{
field: 'types',
operator: GRID_TYPES_FILTER_EXCLUDES_ANY_OF,
value: JSON.stringify(list),
id: 'seed-excludes-types',
},
]
: [],
logicOperator: GridLogicOperator.And,
});
return;
}
if (!persistFiltersInUrl) {
setFilterModel({ items: [], logicOperator: GridLogicOperator.And });
return;
}
const fromUrl = paramsToFilterModel(searchParams);
let items = [...(fromUrl.items || [])];
if (items.length === 0 && !searchParams.has('nofilter')) {
const shouldHideEmptyEmails = !isPublic || isOwner;
if (shouldHideEmptyEmails) {
items.push({ field: 'email', operator: 'isNotEmpty', id: 'default-email' });
}
}
setFilterModel({ items, logicOperator: GridLogicOperator.And });
}, [seedExcludeTypeFilters, persistFiltersInUrl, filterSyncKey, isPublic, isOwner, searchParams]);
// Update URL when filter model changes
const handleFilterModelChange = (newFilterModel: GridFilterModel) => {
setFilterModel(newFilterModel);

View File

@ -248,6 +248,52 @@ export const saveGridSearchExcludeTypes = async (types: string[]): Promise<void>
}
};
/** User-defined AI grid filters (stored in user_secrets.settings.places_ai_grid_filters). */
export interface PlacesAiGridFilterDef {
id: string;
label: string;
prompt: string;
targetField: string;
enabled?: boolean;
/** How to coerce LLM output for the grid (`auto` = infer numeric strings + optional LLM `value_type`). */
valueType?: 'auto' | 'number' | 'string';
}
export const getPlacesAiGridFilters = async (): Promise<PlacesAiGridFilterDef[]> => {
try {
const res = await apiClient<any>('/api/me/secrets');
const raw = res?.places_ai_grid_filters;
return Array.isArray(raw) ? raw : [];
} catch {
return [];
}
};
export const savePlacesAiGridFilters = async (filters: PlacesAiGridFilterDef[]): Promise<void> => {
await apiClient<any>('/api/me/secrets', {
method: 'PUT',
body: JSON.stringify({ places_ai_grid_filters: filters }),
});
};
export const postLlmFiltersRun = async (body: {
placeIds: string[];
filterIds: string[];
lang?: string;
}): Promise<{ runId: string }> => {
const res = await apiClient<{ data: { runId: string } }>('/api/places/llm-filters/run', {
method: 'POST',
body: JSON.stringify(body),
});
return res.data;
};
export const getLlmFiltersStreamUrl = (runId: string, token?: string | null): string => {
const q = new URLSearchParams({ runId });
if (token) q.set('token', token);
return `${serverUrl}/api/places/llm-filters/stream?${q.toString()}`;
};
export const getPlacesTypes = async (): Promise<string[]> => {
try {
const res = await apiClient<{ data: string[] }>('/api/places/types');

View File

@ -0,0 +1,286 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import { getAuthToken } from '@/lib/db';
import {
fetchCompetitorById,
getPlacesAiGridFilters,
postLlmFiltersRun,
getLlmFiltersStreamUrl,
savePlacesAiGridFilters,
type PlacesAiGridFilterDef,
} from '../client-gridsearch';
import type { CompetitorsGridViewHandle } from '../PlacesGridView';
import { T, translate } from '@/i18n';
function newFilter(): PlacesAiGridFilterDef {
return {
id: crypto.randomUUID(),
label: '',
prompt: '',
targetField: `ai_${crypto.randomUUID().slice(0, 8)}`,
enabled: true,
valueType: 'auto',
};
}
type GridFiltersPanelProps = {
gridExportRef: React.RefObject<CompetitorsGridViewHandle | null>;
getPlaceIdsAll: () => string[];
onPlaceLlmUpdated: (placeId: string) => void;
/** When false (e.g. public viewer), hide run + editor. */
canEdit: boolean;
/** After definitions are saved to secrets, parent can refresh column defs. */
onFiltersPersisted?: () => void;
};
export const GridFiltersPanel: React.FC<GridFiltersPanelProps> = ({
gridExportRef,
getPlaceIdsAll,
onPlaceLlmUpdated,
canEdit,
onFiltersPersisted,
}) => {
const [defs, setDefs] = useState<PlacesAiGridFilterDef[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [running, setRunning] = useState(false);
const [runStatus, setRunStatus] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const d = await getPlacesAiGridFilters();
if (!cancelled) setDefs(d);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const persistDefs = useCallback(
async (next: PlacesAiGridFilterDef[]) => {
setSaving(true);
try {
await savePlacesAiGridFilters(next);
setDefs(next);
onFiltersPersisted?.();
} catch (e) {
console.error(e);
setRunStatus(String((e as Error)?.message || e));
} finally {
setSaving(false);
}
},
[onFiltersPersisted],
);
const addFilter = () => {
void persistDefs([...defs, newFilter()]);
};
const removeFilter = (id: string) => {
void persistDefs(defs.filter((d) => d.id !== id));
};
const patchFilter = (id: string, patch: Partial<PlacesAiGridFilterDef>) => {
setDefs((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)));
};
const saveDraft = () => {
void persistDefs(defs);
};
const enabledFilterIds = defs.filter((d) => d.enabled !== false).map((d) => d.id);
const runBatch = async (placeIds: string[]) => {
if (!canEdit || placeIds.length === 0 || enabledFilterIds.length === 0) return;
setRunning(true);
setRunStatus(null);
let es: EventSource | null = null;
try {
const { runId } = await postLlmFiltersRun({
placeIds,
filterIds: enabledFilterIds,
lang: typeof navigator !== 'undefined' ? navigator.language?.slice(0, 2) : undefined,
});
const token = await getAuthToken();
const url = getLlmFiltersStreamUrl(runId, token);
es = new EventSource(url);
es.addEventListener('message', (ev: MessageEvent) => {
try {
const p = JSON.parse(ev.data) as {
kind?: string;
placeId?: string;
filterId?: string;
error?: string;
};
if (p.kind === 'cell' && p.placeId) {
onPlaceLlmUpdated(p.placeId);
}
if (p.kind === 'error') {
setRunStatus(p.error || 'Error');
}
} catch {
/* ignore */
}
});
es.addEventListener('complete', () => {
setRunStatus(translate('AI filters run finished'));
es?.close();
es = null;
setRunning(false);
});
es.onerror = () => {
setRunning(false);
es?.close();
es = null;
};
} catch (e) {
console.error(e);
setRunStatus(String((e as Error)?.message || e));
setRunning(false);
es?.close();
}
};
const handleRunAll = () => {
void runBatch(getPlaceIdsAll());
};
const handleRunSelected = () => {
const api = gridExportRef.current;
const selected = api?.getSelectedPlaceIds?.() ?? [];
const ids = selected.length > 0 ? selected : getPlaceIdsAll();
void runBatch(ids);
};
if (!canEdit) {
return (
<p className="text-xs text-amber-800 dark:text-amber-200">
<T>AI grid filters are available to the search owner.</T>
</p>
);
}
if (loading) {
return (
<div className="flex items-center gap-2 text-xs text-gray-500">
<Loader2 className="h-4 w-4 animate-spin" />
<T>Loading filters</T>
</div>
);
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={running || saving}
onClick={addFilter}
className="text-xs px-2 py-1 rounded-md bg-amber-100 dark:bg-amber-900/40 text-amber-900 dark:text-amber-100 border border-amber-200 dark:border-amber-800"
>
+ <T>Add AI filter</T>
</button>
<button
type="button"
disabled={running || saving}
onClick={saveDraft}
className="text-xs px-2 py-1 rounded-md border border-gray-300 dark:border-gray-600"
>
<T>Save filter definitions</T>
</button>
<button
type="button"
disabled={running || enabledFilterIds.length === 0}
onClick={handleRunSelected}
className="text-xs px-2 py-1 rounded-md bg-violet-600 text-white disabled:opacity-50"
>
{running ? <Loader2 className="h-3 w-3 animate-spin inline" /> : null}{' '}
<T>Run AI filters (selection or all)</T>
</button>
<button
type="button"
disabled={running || enabledFilterIds.length === 0}
onClick={handleRunAll}
className="text-xs px-2 py-1 rounded-md border border-violet-300 dark:border-violet-700 text-violet-800 dark:text-violet-200"
>
<T>Run on all rows</T>
</button>
</div>
{runStatus && <p className="text-[11px] text-gray-600 dark:text-gray-400">{runStatus}</p>}
<div className="space-y-2 max-h-[240px] overflow-y-auto pr-1">
{defs.map((d) => (
<div
key={d.id}
className="rounded-md border border-amber-200/80 dark:border-amber-900/50 bg-white/80 dark:bg-gray-900/40 p-2 space-y-1.5"
>
<div className="flex items-center gap-2 flex-wrap">
<label className="flex items-center gap-1 text-[10px]">
<input
type="checkbox"
checked={d.enabled !== false}
onChange={(e) => patchFilter(d.id, { enabled: e.target.checked })}
/>
<T>On</T>
</label>
<input
className="text-xs flex-1 min-w-[120px] border rounded px-1 py-0.5 dark:bg-gray-900"
value={d.label}
onChange={(e) => patchFilter(d.id, { label: e.target.value })}
placeholder={translate('Label')}
/>
<input
className="text-xs w-36 font-mono border rounded px-1 py-0.5 dark:bg-gray-900"
value={d.targetField}
onChange={(e) =>
patchFilter(d.id, {
targetField: e.target.value.replace(/[^a-z0-9_]/gi, '_').toLowerCase(),
})
}
placeholder="ai_field"
/>
<select
className="text-[10px] border rounded px-1 py-0.5 dark:bg-gray-900 max-w-[7rem]"
value={d.valueType ?? 'auto'}
title={translate('Cell value type (for sorting / filters)')}
onChange={(e) =>
patchFilter(d.id, {
valueType: e.target.value as 'auto' | 'number' | 'string',
})
}
>
<option value="auto">{translate('Auto')}</option>
<option value="number">{translate('Number')}</option>
<option value="string">{translate('String')}</option>
</select>
<button
type="button"
className="text-[10px] text-red-600 dark:text-red-400"
onClick={() => removeFilter(d.id)}
>
<T>Remove</T>
</button>
</div>
<textarea
className="w-full text-xs rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 px-2 py-1"
rows={2}
value={d.prompt}
onChange={(e) => patchFilter(d.id, { prompt: e.target.value })}
placeholder={translate('Instructions for the model (what to score or extract)')}
/>
</div>
))}
</div>
</div>
);
};

View File

@ -1,6 +1,6 @@
import React, { useState, useCallback, useRef, useEffect } 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, Pause, Play, Square, SlidersHorizontal, Sparkles } 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, SlidersHorizontal } from 'lucide-react';
import { CompetitorsGridView, type CompetitorsGridViewHandle } from '../PlacesGridView';
import { PlacesMapView, type MapFeatures } from '../PlacesMapView';
@ -61,7 +61,9 @@ const MOCK_SETTINGS = {
import { MergeDialog } from './MergeDialog';
import { ImportContactsDialog } from '../../contacts/ImportContactsDialog';
import { exportGridSearchToContacts } from '../client-gridsearch';
import { exportGridSearchToContacts, fetchCompetitorById, getPlacesAiGridFilters } from '../client-gridsearch';
import { GridFiltersPanel } from './GridFiltersPanel';
import { useAiGridColumns } from './useAiGridColumns';
import { UserPlus } from 'lucide-react';
/** URL/localStorage toggle for map layer visibility; same storage prefix as `useGridSearchState`. */
@ -107,14 +109,25 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
const [showMergeDialog, setShowMergeDialog] = useState(false);
const [showImportToContactsDialog, setShowImportToContactsDialog] = useState(false);
/** Merged onto stream competitors (e.g. llm_grid_filters after AI filter run). */
const [placeUiPatch, setPlaceUiPatch] = useState<Record<string, Record<string, unknown>>>({});
const patchedCompetitors = React.useMemo(() => {
return competitors.map((c) => {
const id = String((c as any).place_id || (c as any).placeId || (c as any).id);
const p = placeUiPatch[id];
return p ? ({ ...c, ...p } as PlaceFull) : c;
});
}, [competitors, placeUiPatch]);
const filteredCompetitors = React.useMemo(() => {
if (!excludedTypes || excludedTypes.length === 0) return competitors;
if (!excludedTypes || excludedTypes.length === 0) return patchedCompetitors;
const excludedSet = new Set(excludedTypes.map(t => t.toLowerCase()));
return competitors.filter(c => {
return patchedCompetitors.filter(c => {
const types = (c as any).types || [];
return !types.some((t: string) => excludedSet.has(t.toLowerCase()));
});
}, [competitors, excludedTypes]);
}, [patchedCompetitors, excludedTypes]);
const [searchParams, setSearchParams] = useSearchParams();
@ -147,39 +160,18 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
/** When true, list view shows all rows and applies saved excludes as MUI column filters (editable in the filter panel). When false, rows are pre-filtered (previous behavior). */
const [applySavedExcludesInGrid, setApplySavedExcludesInGrid] = useState(false);
/** Bumps on every Grid filters toggle so `CompetitorsGridView` remounts and drops in-memory filter state (types filter must not survive turning the mode off). */
const [gridFilterRemountKey, setGridFilterRemountKey] = useState(0);
const gridExportRef = useRef<CompetitorsGridViewHandle | null>(null);
const pcContextStorageKey = `gridsearch_pc_context_${jobId}`;
const [aiContextOpen, setAiContextOpen] = useState(false);
/** Committed value: localStorage + passed to place LLM (Potential customer). Updated on textarea blur only. */
const [potentialCustomerContext, setPotentialCustomerContext] = useState('');
/** Draft while typing; does not persist or affect scoring until blur. */
const [pcContextDraft, setPcContextDraft] = useState('');
const [gridFiltersOpen, setGridFiltersOpen] = useState(false);
const [aiFilterDefs, setAiFilterDefs] = useState<import('../client-gridsearch').PlacesAiGridFilterDef[]>([]);
const aiGridColumns = useAiGridColumns(aiFilterDefs);
useEffect(() => {
try {
const stored = localStorage.getItem(pcContextStorageKey) ?? '';
setPotentialCustomerContext(stored);
setPcContextDraft(stored);
} catch {
setPotentialCustomerContext('');
setPcContextDraft('');
}
}, [pcContextStorageKey]);
if (!gridFiltersOpen || !isOwner) return;
getPlacesAiGridFilters().then(setAiFilterDefs).catch(() => { /* ignore */ });
}, [gridFiltersOpen, isOwner, jobId]);
const commitPcContext = useCallback(() => {
setPotentialCustomerContext(pcContextDraft);
try {
localStorage.setItem(pcContextStorageKey, pcContextDraft);
} catch { /* quota */ }
}, [pcContextDraft, pcContextStorageKey]);
const toggleApplySavedExcludesInGrid = useCallback(() => {
setApplySavedExcludesInGrid((v) => !v);
setGridFilterRemountKey((k) => k + 1);
// Clear filter query params on both on and off: off avoids rehydrating e.g. `filter_types_excludesAnyOf` from the URL into the prefilter grid; on matches previous behavior.
const applySavedExcludesUrlClear = useCallback(() => {
setSearchParams((prev) => {
const p = new URLSearchParams(prev);
Array.from(p.keys()).forEach((k) => {
@ -190,13 +182,21 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
}, { replace: true });
}, [setSearchParams]);
const handleApplySavedExcludesInGridToggle = useCallback(
(next: boolean) => {
setApplySavedExcludesInGrid(next);
applySavedExcludesUrlClear();
},
[applySavedExcludesUrlClear],
);
const getRowsMatchingCurrentFilters = useCallback(() => {
if (applySavedExcludesInGrid && viewMode === 'grid' && gridExportRef.current) {
const ids = new Set(gridExportRef.current.getFilteredPlaceIds());
return competitors.filter((c) => ids.has(String((c as any).place_id || (c as any).placeId || (c as any).id)));
return patchedCompetitors.filter((c) => ids.has(String((c as any).place_id || (c as any).placeId || (c as any).id)));
}
return filteredCompetitors;
}, [applySavedExcludesInGrid, viewMode, competitors, filteredCompetitors]);
}, [applySavedExcludesInGrid, viewMode, patchedCompetitors, filteredCompetitors]);
const { state: restoredState } = useRestoredSearch();
const restoredGadmAreas = restoredState?.run?.request?.guided?.areas?.map((a: any) => ({
@ -315,8 +315,26 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
const activeCompetitor = React.useMemo(() => {
if (!selectedPlaceId) return null;
return competitors.find(c => String(c.place_id || (c as any).placeId || (c as any).id) === selectedPlaceId);
}, [selectedPlaceId, competitors]);
return patchedCompetitors.find(c => String(c.place_id || (c as any).placeId || (c as any).id) === selectedPlaceId);
}, [selectedPlaceId, patchedCompetitors]);
const getAllPlaceIds = useCallback(() => {
return patchedCompetitors
.map((c) => String((c as any).place_id || (c as any).placeId || (c as any).id))
.filter(Boolean);
}, [patchedCompetitors]);
const handlePlaceLlmUpdated = useCallback(async (placeId: string) => {
try {
const d = await fetchCompetitorById(placeId);
setPlaceUiPatch((prev) => ({
...prev,
[placeId]: { llm_grid_filters: (d as any).llm_grid_filters } as Record<string, unknown>,
}));
} catch (e) {
console.error(e);
}
}, []);
const handleSelectPlace = useCallback((id: string | null, behavior: 'select' | 'open' | 'toggle' = 'select') => {
const isSame = id === selectedPlaceId;
@ -597,34 +615,20 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
)}
</div>
<button
type="button"
onClick={() => setAiContextOpen((o) => !o)}
className={`flex items-center gap-1 p-1.5 rounded-md transition-all shrink-0 ${
aiContextOpen
? 'bg-violet-50 text-violet-700 dark:bg-violet-900/40 dark:text-violet-200 ring-1 ring-violet-300 dark:ring-violet-700'
: 'text-gray-400 hover:text-violet-600 dark:hover:text-violet-300 hover:bg-gray-50 dark:hover:bg-gray-800'
}`}
title={translate('AI: edit context for Potential customer scoring')}
>
<Sparkles className="h-4 w-4" />
<span className="hidden sm:inline text-xs font-semibold tracking-tight">AI</span>
</button>
{isOwner && (
<>
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700 mx-1" />
<button
type="button"
onClick={toggleApplySavedExcludesInGrid}
onClick={() => setGridFiltersOpen((o) => !o)}
className={`flex items-center gap-1 p-1.5 rounded-md transition-all ${
applySavedExcludesInGrid
gridFiltersOpen || applySavedExcludesInGrid
? 'bg-amber-50 text-amber-800 dark:bg-amber-950/50 dark:text-amber-200'
: 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
}`}
title={translate(
'When on, saved type excludes apply as DataGrid filters — remove a rule to show that type. Import to contacts uses the rows you see in List view.',
'Grid filters: type excludes, AI context, and AI column filters',
)}
>
<SlidersHorizontal className="h-4 w-4" />
@ -727,28 +731,42 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
</div>
</div>
{aiContextOpen && (
<div className="rounded-lg border border-violet-200 dark:border-violet-800/60 bg-violet-50/40 dark:bg-violet-950/20 px-3 py-2 space-y-1.5">
<label className="block text-xs font-medium text-violet-900 dark:text-violet-200">
<T>Context</T>
<span className="font-normal text-violet-600/90 dark:text-violet-300/80 ml-1">
(<T>AI Context for Potential Customer Matching</T>)
{isOwner && gridFiltersOpen && (
<div className="rounded-lg border border-amber-200 dark:border-amber-800/60 bg-amber-50/30 dark:bg-amber-950/15 px-3 py-2 space-y-3">
<label className="flex items-start gap-2 text-xs text-amber-950 dark:text-amber-100 cursor-pointer">
<input
type="checkbox"
className="mt-0.5 rounded border-amber-400"
checked={applySavedExcludesInGrid}
onChange={(e) => handleApplySavedExcludesInGridToggle(e.target.checked)}
/>
<span>
<span className="font-medium">
<T>Apply saved type excludes as DataGrid filters</T>
</span>
<span className="block font-normal text-amber-800/90 dark:text-amber-200/90 mt-0.5">
<T>
When on, saved type excludes apply as DataGrid filters remove a rule to show that
type. Import to contacts uses the rows you see in List view.
</T>
</span>
</span>
</label>
<textarea
value={pcContextDraft}
onChange={(e) => setPcContextDraft(e.target.value)}
onBlur={commitPcContext}
rows={4}
maxLength={8000}
placeholder={translate(
'e.g. We want B2B marketing agencies; prioritize firms offering SEO and paid ads…',
)}
className="w-full text-sm rounded-md border border-violet-200 dark:border-violet-800 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 px-2 py-1.5 placeholder:text-gray-400 focus:ring-2 focus:ring-violet-400/50 focus:border-violet-400 outline-none resize-y min-h-[88px]"
/>
<p className="text-[10px] text-violet-700/80 dark:text-violet-300/70">
{pcContextDraft.length}/8000 · <T>Saved per search in this browser</T>
</p>
<div className="border-t border-amber-200/80 dark:border-amber-800/40 pt-2">
<p className="text-[11px] font-medium text-amber-900 dark:text-amber-100 mb-2">
<T>AI grid filters</T>
</p>
<GridFiltersPanel
gridExportRef={gridExportRef}
getPlaceIdsAll={getAllPlaceIds}
onPlaceLlmUpdated={handlePlaceLlmUpdated}
canEdit={!!isOwner}
onFiltersPersisted={() => {
getPlacesAiGridFilters().then(setAiFilterDefs).catch(() => { /* ignore */ });
}}
/>
</div>
</div>
)}
</div>
@ -757,9 +775,9 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
<div id="gridsearch-scroll-area" className="flex-1 min-h-0 flex flex-col overflow-auto bg-white dark:bg-gray-900 rounded-b-2xl">
{viewMode === 'grid' && (
<CompetitorsGridView
key={`grid-filters-${applySavedExcludesInGrid ? 'on' : 'off'}-${gridFilterRemountKey}`}
key={`gridsearch-grid-${jobId}`}
ref={gridExportRef}
competitors={applySavedExcludesInGrid ? competitors : filteredCompetitors}
competitors={applySavedExcludesInGrid ? patchedCompetitors : filteredCompetitors}
loading={false}
settings={settings}
updateExcludedTypes={updateExcludedTypes || (async () => { })}
@ -770,6 +788,7 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
preset={(isPublic && !isOwner) || showDetails ? 'min' : 'full'}
seedExcludeTypeFilters={applySavedExcludesInGrid ? excludedTypes : undefined}
persistFiltersInUrl={!applySavedExcludesInGrid}
extraColumns={isOwner ? aiGridColumns : undefined}
/>
)}
@ -869,7 +888,6 @@ export const GridSearchResults = React.memo(({ jobId, competitors, excludedTypes
competitor={activeCompetitor}
onClose={() => setShowDetails(false)}
gridSearchRunId={jobId}
potentialCustomerContext={potentialCustomerContext.trim() || undefined}
/>
</div>
</div>

View File

@ -0,0 +1,43 @@
import React from 'react';
import type { GridColDef } from '@mui/x-data-grid';
import { translate } from '@/i18n';
import type { PlacesAiGridFilterDef } from '../client-gridsearch';
/** Column title: optional display label, else the technical field id (not the old default "New filter"). */
function aiFilterHeaderName(def: PlacesAiGridFilterDef): string {
const t = def.label?.trim() ?? '';
if (!t || t === translate('New filter')) {
return def.targetField;
}
return t;
}
/** Dynamic MUI columns for `meta.llm_grid_filters` entries; `field` matches `def.targetField`. */
export function useAiGridColumns(defs: PlacesAiGridFilterDef[]): GridColDef[] {
const enabled = React.useMemo(() => defs.filter((d) => d.enabled !== false && d.targetField), [defs]);
return React.useMemo(
() =>
enabled.map((def) => ({
field: def.targetField,
headerName: aiFilterHeaderName(def),
...(def.valueType === 'number'
? { type: 'number' as const }
: def.valueType === 'string'
? { type: 'string' as const }
: {}),
width: 140,
sortable: true,
valueGetter: (_v: unknown, row: Record<string, unknown>) => {
const bucket = row.llm_grid_filters as Record<string, { value?: string | number }> | undefined;
const cell = bucket?.[def.id];
if (cell?.value === undefined || cell?.value === null) return '';
return cell.value;
},
renderCell: (params: { value?: unknown }) => (
<span className="text-xs text-gray-800 dark:text-gray-100 tabular-nums">{String(params.value ?? '')}</span>
),
})),
[enabled],
);
}