BuyingAgent/frontend/src/pages/WizardPage.tsx
Lars d784248e28
Some checks failed
Deploy Development / deploy (push) Successful in 55s
Test Suite / pytest-backend (push) Failing after 30s
Test Suite / smoke-dev (push) Successful in 0s
Test Suite / frontend-checks (push) Successful in 26s
Schliesse das Notebook-Source-Mapping, damit Wizard und Taxonomie ohne leere Kategorie starten.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-04 14:17:56 +02:00

198 lines
7.8 KiB
TypeScript

import { useMutation, useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { api, type AvailableCriterion } from "../api";
import { CriteriaControl, pickSource } from "../components/CriteriaControl";
import { EmptyState, ErrorState, LoadingState } from "../components/State";
type Draft = Record<string, { operator: string; value: unknown }>;
export function WizardPage() {
const navigate = useNavigate();
const categories = useQuery({ queryKey: ["product-categories"], queryFn: api.productCategories });
const [name, setName] = useState("");
const [categoryId, setCategoryId] = useState("");
const [market, setMarket] = useState("DE");
const [currency, setCurrency] = useState("EUR");
const [budget, setBudget] = useState("");
const [sourceOverride, setSourceOverride] = useState<string | null>(null);
const [selected, setSelected] = useState<string[]>([]);
const [draft, setDraft] = useState<Draft>({});
const sources = useQuery({
queryKey: ["category-sources", categoryId],
queryFn: () => api.categorySources(categoryId),
enabled: Boolean(categoryId),
});
const source = sourceOverride || pickSource(sources.data?.items || []);
const criteria = useQuery({
queryKey: ["available-criteria", categoryId, source],
queryFn: () => api.availableCriteria(categoryId, source!),
enabled: Boolean(categoryId && source),
});
const create = useMutation({
mutationFn: async () => {
if (!source || !sources.data) throw new Error("Keine Source verfügbar");
const chosen = (criteria.data?.criteria || []).filter((item) => selected.includes(item.criterion_definition_id));
const project = await api.createProject({
name,
product_category_id: categoryId,
market,
currency,
target_budget: budget || null,
criteria: {
items: chosen.map((item) => ({
criterion_definition_id: item.criterion_definition_id,
operator: draft[item.criterion_definition_id]?.operator || item.operators[0] || ">=",
value: coerce(item, draft[item.criterion_definition_id]?.value),
enabled_for_discovery: true,
})),
},
});
const sourceRow = sources.data.items.find((item) => item.source === source) || sources.data.items[0];
await api.createSearch(project.id, {
source_id: sourceRow.source,
source_category_id: sourceRow.source_category_id,
});
return project;
},
onSuccess: (project) => navigate(`/projects/${project.id}`),
});
const sourceItems = sources.data?.items || [];
const sourceRow = sourceItems.find((item) => item.source === source) || sourceItems[0];
const needsPicker = sourceItems.filter((item) => item.status === "ACTIVE").length > 1;
const visible = useMemo(() => criteria.data?.criteria || [], [criteria.data]);
if (categories.isLoading) return <LoadingState />;
if (categories.isError) return <ErrorState message={(categories.error as Error).message} />;
return (
<section className="grid">
<h1>Neues Projekt</h1>
<div className="card grid">
<label>
Projektname
<input value={name} onChange={(event) => setName(event.target.value)} />
</label>
<label>
Produktkategorie
<select
value={categoryId}
onChange={(event) => {
setCategoryId(event.target.value);
setSourceOverride(null);
setSelected([]);
}}
>
<option value="">Bitte wählen</option>
{(categories.data?.items || []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</label>
<div className="row">
<label>
Markt
<input value={market} onChange={(event) => setMarket(event.target.value)} />
</label>
<label>
Währung
<input value={currency} onChange={(event) => setCurrency(event.target.value)} />
</label>
<label>
Zielbudget optional
<input value={budget} onChange={(event) => setBudget(event.target.value)} />
</label>
</div>
</div>
{categoryId && sources.isLoading ? <LoadingState /> : null}
{categoryId && !sources.isLoading && sourceItems.length === 0 ? (
<EmptyState>Keine Source für diese Produktkategorie verfügbar.</EmptyState>
) : null}
{sourceRow?.taxonomy_status === "NOT_LEARNED" && criteria.isLoading ? (
<EmptyState>Kriterien für diese Produktkategorie werden gerade vorbereitet.</EmptyState>
) : null}
{sourceRow?.taxonomy_status === "LEARNING" ? <LoadingState /> : null}
{sourceRow?.taxonomy_status === "BLOCKED" ? (
<EmptyState>Quelle derzeit nicht automatisiert abfragbar.</EmptyState>
) : null}
{sourceRow?.taxonomy_status === "BROKEN" ? (
<EmptyState>Die Quelle für diese Kategorie ist derzeit unbrauchbar.</EmptyState>
) : null}
{needsPicker ? (
<label>
Quelle
<select value={source || ""} onChange={(event) => setSourceOverride(event.target.value)}>
{sourceItems.map((item) => (
<option key={item.source} value={item.source}>
{item.display_name}
</option>
))}
</select>
</label>
) : source ? (
<p className="muted">Quelle: {source}</p>
) : null}
{criteria.isError ? <ErrorState message={(criteria.error as Error).message} onRetry={() => criteria.refetch()} /> : null}
{criteria.isLoading ? <LoadingState /> : null}
{visible.length > 0 ? (
<div className="card grid">
{visible.map((item) => {
const on = selected.includes(item.criterion_definition_id);
return (
<div key={item.criterion_definition_id}>
<label>
<input
type="checkbox"
checked={on}
onChange={(event) => {
setSelected((current) =>
event.target.checked
? [...current, item.criterion_definition_id]
: current.filter((id) => id !== item.criterion_definition_id),
);
setDraft((current) => ({
...current,
[item.criterion_definition_id]: current[item.criterion_definition_id] || {
operator: item.operators[0] || ">=",
value: "",
},
}));
}}
/>{" "}
{item.label}
</label>
{on ? (
<CriteriaControl
criterion={item}
operator={draft[item.criterion_definition_id]?.operator || item.operators[0] || ">="}
value={draft[item.criterion_definition_id]?.value}
onChange={(next) => setDraft((current) => ({ ...current, [item.criterion_definition_id]: next }))}
/>
) : null}
</div>
);
})}
</div>
) : null}
{create.isError ? <ErrorState message={(create.error as Error).message} /> : null}
<button className="button" disabled={!name || !categoryId || selected.length === 0 || create.isPending} onClick={() => create.mutate()}>
Projekt speichern
</button>
</section>
);
}
function coerce(criterion: AvailableCriterion, value: unknown) {
if (value == null || value === "") return value;
if ((criterion.filter_type || "").startsWith("NUMERIC")) return Number(value);
return value;
}