198 lines
7.8 KiB
TypeScript
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;
|
|
}
|