lollipop match 2/2
This commit is contained in:
parent
9024b71e6c
commit
6529f9d49a
@ -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`** (1–5) 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 tab’s 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.*
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
|
||||
286
packages/ui/src/modules/places/gridsearch/GridFiltersPanel.tsx
Normal file
286
packages/ui/src/modules/places/gridsearch/GridFiltersPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
|
||||
@ -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],
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user