feat: implement filter presets and view state persistence across dashboard, run details, product list, and stalker explorer
- Added functionality to save, update, and apply filter presets for various views. - Introduced local storage management for persisting view states across sessions. - Enhanced dashboard, run details, product list, and stalker explorer components to utilize saved filter presets. - Updated UI to include controls for managing filter presets.
This commit is contained in:
349
src/server.ts
349
src/server.ts
@@ -3,10 +3,7 @@ import * as XLSX from "xlsx";
|
||||
import { normalizeAsin } from "./asin.ts";
|
||||
import { db, client } from "./db/index.ts";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
analysisRevisions,
|
||||
productDistributorResearch,
|
||||
} from "./db/schema.ts";
|
||||
import { analysisRevisions, productDistributorResearch } from "./db/schema.ts";
|
||||
import { insertObservation, refreshRunStats } from "./db/persistence.ts";
|
||||
import { config } from "./config.ts";
|
||||
import {
|
||||
@@ -46,7 +43,10 @@ async function pgGet<T extends Record<string, unknown>>(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<T | null> {
|
||||
const rows = await client.unsafe<T[]>(toPostgresSql(query), params as never[]);
|
||||
const rows = await client.unsafe<T[]>(
|
||||
toPostgresSql(query),
|
||||
params as never[],
|
||||
);
|
||||
return (rows[0] as T) ?? null;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,10 @@ async function pgAll<T extends Record<string, unknown>>(
|
||||
query: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<T[]> {
|
||||
return client.unsafe<T[]>(toPostgresSql(query), params as never[]) as unknown as T[];
|
||||
return client.unsafe<T[]>(
|
||||
toPostgresSql(query),
|
||||
params as never[],
|
||||
) as unknown as T[];
|
||||
}
|
||||
|
||||
async function pgRun(query: string, params: unknown[] = []): Promise<number> {
|
||||
@@ -127,7 +130,10 @@ function safeSort(
|
||||
}
|
||||
|
||||
function splitRawUpcValues(input: string): string[] {
|
||||
return input.split(/[\s,;|]+/).map((v) => v.trim()).filter(Boolean);
|
||||
return input
|
||||
.split(/[\s,;|]+/)
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function collectUpcs(value: unknown, target: string[]): void {
|
||||
@@ -288,7 +294,13 @@ async function getRuns(filters: URLSearchParams) {
|
||||
[...params, pageSize, offset],
|
||||
);
|
||||
const total = Number(totalRow?.total ?? 0);
|
||||
return { items, page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) };
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
};
|
||||
}
|
||||
|
||||
async function getRun(runId: number) {
|
||||
@@ -412,7 +424,11 @@ const ITEM_SORTS: Record<string, string> = {
|
||||
async function getRunItems(runId: number, filters: URLSearchParams) {
|
||||
const { page, pageSize, offset } = pageInput(filters);
|
||||
const { where, params } = itemFilters(filters, runId);
|
||||
const orderBy = safeSort(filters.get("sort"), ITEM_SORTS, "monthly_sold DESC NULLS LAST, asin ASC");
|
||||
const orderBy = safeSort(
|
||||
filters.get("sort"),
|
||||
ITEM_SORTS,
|
||||
"monthly_sold DESC NULLS LAST, asin ASC",
|
||||
);
|
||||
const totalRow = await pgGet<{ total: string }>(
|
||||
`SELECT COUNT(*) AS total FROM (${ITEM_ROWS}) item_rows ${where}`,
|
||||
params,
|
||||
@@ -422,29 +438,60 @@ async function getRunItems(runId: number, filters: URLSearchParams) {
|
||||
[...params, pageSize, offset],
|
||||
);
|
||||
const total = Number(totalRow?.total ?? 0);
|
||||
return { items, page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) };
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
};
|
||||
}
|
||||
|
||||
async function exportRunItems(runId: number, filters: URLSearchParams) {
|
||||
const { where, params } = itemFilters(filters, runId);
|
||||
const orderBy = safeSort(filters.get("sort"), ITEM_SORTS, "monthly_sold DESC NULLS LAST, asin ASC");
|
||||
const orderBy = safeSort(
|
||||
filters.get("sort"),
|
||||
ITEM_SORTS,
|
||||
"monthly_sold DESC NULLS LAST, asin ASC",
|
||||
);
|
||||
const rows = await pgAll<Record<string, unknown>>(
|
||||
`SELECT * FROM (${ITEM_ROWS}) item_rows ${where} ORDER BY ${orderBy}`,
|
||||
params,
|
||||
);
|
||||
const headers = [
|
||||
"run_id", "asin", "product_name", "brand", "category", "unit_cost",
|
||||
"current_price", "avg_price_90d", "sales_rank_avg_90d", "seller_count",
|
||||
"amazon_is_seller", "amazon_buybox_share_pct_90d", "monthly_sold",
|
||||
"sellability_status", "verdict", "confidence", "reasoning", "fetched_at",
|
||||
"run_id",
|
||||
"asin",
|
||||
"product_name",
|
||||
"brand",
|
||||
"category",
|
||||
"unit_cost",
|
||||
"current_price",
|
||||
"avg_price_90d",
|
||||
"sales_rank_avg_90d",
|
||||
"seller_count",
|
||||
"amazon_is_seller",
|
||||
"amazon_buybox_share_pct_90d",
|
||||
"monthly_sold",
|
||||
"sellability_status",
|
||||
"verdict",
|
||||
"confidence",
|
||||
"reasoning",
|
||||
"fetched_at",
|
||||
];
|
||||
return [headers.join(","), ...rows.map((row) => headers.map((h) => escapeCsvValue(row[h])).join(","))].join("\n");
|
||||
return [
|
||||
headers.join(","),
|
||||
...rows.map((row) => headers.map((h) => escapeCsvValue(row[h])).join(",")),
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function getProducts(filters: URLSearchParams) {
|
||||
const { page, pageSize, offset } = pageInput(filters);
|
||||
const { where, params } = itemFilters(filters);
|
||||
const orderBy = safeSort(filters.get("sort"), ITEM_SORTS, "fetched_at DESC NULLS LAST, asin ASC");
|
||||
const orderBy = safeSort(
|
||||
filters.get("sort"),
|
||||
ITEM_SORTS,
|
||||
"fetched_at DESC NULLS LAST, asin ASC",
|
||||
);
|
||||
const base = `
|
||||
SELECT product.asin, product.asin AS product_asin,
|
||||
latest.item_id, latest.run_id AS "runId", latest.process_type AS "processType",
|
||||
@@ -466,23 +513,28 @@ async function getProducts(filters: URLSearchParams) {
|
||||
LIMIT 1
|
||||
) latest ON TRUE`;
|
||||
const total = Number(
|
||||
(await pgGet<{ total: string }>(
|
||||
(
|
||||
await pgGet<{ total: string }>(
|
||||
`SELECT COUNT(*) AS total FROM (${base}) products ${where}`,
|
||||
params,
|
||||
))?.total ?? 0,
|
||||
)
|
||||
)?.total ?? 0,
|
||||
);
|
||||
const items = await pgAll(
|
||||
`SELECT * FROM (${base}) products ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, offset],
|
||||
);
|
||||
return { items, page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)) };
|
||||
return {
|
||||
items,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
};
|
||||
}
|
||||
|
||||
async function getProduct(asin: string) {
|
||||
const product = await pgGet(
|
||||
`SELECT * FROM products WHERE asin = ?`,
|
||||
[asin],
|
||||
);
|
||||
const product = await pgGet(`SELECT * FROM products WHERE asin = ?`, [asin]);
|
||||
if (!product) return null;
|
||||
const observations = await pgAll(
|
||||
`SELECT observation.*, run.type AS run_type
|
||||
@@ -511,7 +563,9 @@ async function getProduct(asin: string) {
|
||||
const distributorResearch = distributorResearchRows.map((row) => {
|
||||
const distributors = (() => {
|
||||
try {
|
||||
return normalizeDistributorCandidates(JSON.parse(String(row.distributors_json ?? "[]")));
|
||||
return normalizeDistributorCandidates(
|
||||
JSON.parse(String(row.distributors_json ?? "[]")),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -519,7 +573,8 @@ async function getProduct(asin: string) {
|
||||
return {
|
||||
id: Number(row.id),
|
||||
run_item_id: row.run_item_id == null ? null : Number(row.run_item_id),
|
||||
inventory_item_id: row.inventory_item_id == null ? null : Number(row.inventory_item_id),
|
||||
inventory_item_id:
|
||||
row.inventory_item_id == null ? null : Number(row.inventory_item_id),
|
||||
provider: String(row.provider ?? ""),
|
||||
model: String(row.model ?? ""),
|
||||
status: String(row.status ?? ""),
|
||||
@@ -543,7 +598,10 @@ async function findLatestRunItemIdByAsin(asin: string): Promise<number | null> {
|
||||
return row?.id == null ? null : Number(row.id);
|
||||
}
|
||||
|
||||
async function reanalyzeStalkerProductByAsin(asin: string, useClaude = USE_CLAUDE) {
|
||||
async function reanalyzeStalkerProductByAsin(
|
||||
asin: string,
|
||||
useClaude = USE_CLAUDE,
|
||||
) {
|
||||
const runItemId = await findLatestRunItemIdByAsin(asin);
|
||||
if (runItemId == null) {
|
||||
throw new Error("Stalker product item not found");
|
||||
@@ -579,7 +637,9 @@ async function reanalyzeRunItem(itemId: number, useClaude = USE_CLAUDE) {
|
||||
);
|
||||
if (!row) throw new Error("Run item not found");
|
||||
if (row.type === "supplier_upc") {
|
||||
throw new Error("Supplier scoring revisions are produced by the supplier pipeline");
|
||||
throw new Error(
|
||||
"Supplier scoring revisions are produced by the supplier pipeline",
|
||||
);
|
||||
}
|
||||
const record: ProductRecord = {
|
||||
asin: row.asin,
|
||||
@@ -620,15 +680,18 @@ async function reanalyzeRunItem(itemId: number, useClaude = USE_CLAUDE) {
|
||||
spApi: spApi as SpApiData,
|
||||
fetchedAt: new Date().toISOString(),
|
||||
};
|
||||
const verdict =
|
||||
(await analyzeProducts([enriched], { useClaude }))[0] ?? {
|
||||
const verdict = (await analyzeProducts([enriched], { useClaude }))[0] ?? {
|
||||
asin: row.asin,
|
||||
verdict: "SKIP" as const,
|
||||
confidence: 0,
|
||||
reasoning: "LLM analysis returned no verdict",
|
||||
};
|
||||
const result: AnalysisResult = { product: enriched, verdict };
|
||||
const observationId = await insertObservation(row.run_id, result, "reanalysis");
|
||||
const observationId = await insertObservation(
|
||||
row.run_id,
|
||||
result,
|
||||
"reanalysis",
|
||||
);
|
||||
await db.insert(analysisRevisions).values({
|
||||
runItemId: itemId,
|
||||
observationId,
|
||||
@@ -639,7 +702,12 @@ async function reanalyzeRunItem(itemId: number, useClaude = USE_CLAUDE) {
|
||||
analyzedAt: new Date(enriched.fetchedAt),
|
||||
});
|
||||
await refreshRunStats(row.run_id);
|
||||
return { itemId, runId: row.run_id, asin: row.asin, fetchedAt: enriched.fetchedAt };
|
||||
return {
|
||||
itemId,
|
||||
runId: row.run_id,
|
||||
asin: row.asin,
|
||||
fetchedAt: enriched.fetchedAt,
|
||||
};
|
||||
}
|
||||
|
||||
type DistributorCandidate = {
|
||||
@@ -658,10 +726,15 @@ function clampDistributorConfidence(value: unknown): number {
|
||||
return Math.max(0, Math.min(100, Math.round(parsed)));
|
||||
}
|
||||
|
||||
function normalizeDistributorCandidates(payload: unknown): DistributorCandidate[] {
|
||||
function normalizeDistributorCandidates(
|
||||
payload: unknown,
|
||||
): DistributorCandidate[] {
|
||||
if (!Array.isArray(payload)) return [];
|
||||
return payload
|
||||
.filter((item): item is Record<string, unknown> => item != null && typeof item === "object")
|
||||
.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
item != null && typeof item === "object",
|
||||
)
|
||||
.map((item) => ({
|
||||
name: String(item.name ?? "").trim(),
|
||||
website: String(item.website ?? "").trim(),
|
||||
@@ -669,7 +742,9 @@ function normalizeDistributorCandidates(payload: unknown): DistributorCandidate[
|
||||
confidence: clampDistributorConfidence(item.confidence),
|
||||
reputation: String(item.reputation ?? "").trim(),
|
||||
contactInfo: String(item.contact_info ?? item.contactInfo ?? "").trim(),
|
||||
outreachDraft: String(item.outreach_draft ?? item.outreachDraft ?? "").trim(),
|
||||
outreachDraft: String(
|
||||
item.outreach_draft ?? item.outreachDraft ?? "",
|
||||
).trim(),
|
||||
}))
|
||||
.filter((item) => item.name.length > 0 && item.website.length > 0)
|
||||
.slice(0, 10);
|
||||
@@ -678,7 +753,7 @@ function normalizeDistributorCandidates(payload: unknown): DistributorCandidate[
|
||||
function extractJsonArrayFromText(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
const candidate = fence ? fence[1]?.trim() ?? "" : trimmed;
|
||||
const candidate = fence ? (fence[1]?.trim() ?? "") : trimmed;
|
||||
const start = candidate.indexOf("[");
|
||||
const end = candidate.lastIndexOf("]");
|
||||
if (start >= 0 && end > start) {
|
||||
@@ -687,11 +762,14 @@ function extractJsonArrayFromText(text: string): string {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function requestClaudeDistributorCandidates(context: Record<string, unknown>) {
|
||||
async function requestClaudeDistributorCandidates(
|
||||
context: Record<string, unknown>,
|
||||
) {
|
||||
if (!config.anthropicApiKey) {
|
||||
throw new Error("Missing required env var: ANTHROPIC_API_KEY");
|
||||
}
|
||||
const model = (config.anthropicModel ?? "claude-sonnet-4-6").trim() || "claude-sonnet-4-6";
|
||||
// const model = (config.anthropicModel ?? "claude-sonnet-4-6").trim() || "claude-sonnet-4-6";
|
||||
const model = "claude-sonnet-4-6";
|
||||
const system = [
|
||||
"You are a wholesale sourcing researcher who identifies authorized U.S. distributors for Amazon products.",
|
||||
"For each candidate you research their reputation, locate real point-of-contact details, and draft a concise cold-outreach message.",
|
||||
@@ -735,13 +813,19 @@ async function requestClaudeDistributorCandidates(context: Record<string, unknow
|
||||
});
|
||||
const raw = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Claude API error ${response.status}: ${raw.slice(0, 300)}`);
|
||||
throw new Error(
|
||||
`Claude API error ${response.status}: ${raw.slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
let contentText = "";
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { content?: Array<{ type?: string; text?: string }> };
|
||||
const parsed = JSON.parse(raw) as {
|
||||
content?: Array<{ type?: string; text?: string }>;
|
||||
};
|
||||
contentText = (parsed.content ?? [])
|
||||
.filter((block) => block?.type === "text" && typeof block.text === "string")
|
||||
.filter(
|
||||
(block) => block?.type === "text" && typeof block.text === "string",
|
||||
)
|
||||
.map((block) => block.text ?? "")
|
||||
.join("\n");
|
||||
} catch {
|
||||
@@ -845,7 +929,10 @@ async function findDistributorsForStalkerProduct(runItemId: number) {
|
||||
distributorsJson: JSON.stringify(claude.candidates),
|
||||
rawResponse: claude.rawResponse,
|
||||
})
|
||||
.returning({ id: productDistributorResearch.id, createdAt: productDistributorResearch.createdAt });
|
||||
.returning({
|
||||
id: productDistributorResearch.id,
|
||||
createdAt: productDistributorResearch.createdAt,
|
||||
});
|
||||
return {
|
||||
asin: row.asin,
|
||||
runItemId: runItemId,
|
||||
@@ -894,7 +981,10 @@ function stalkerBaseWhere(filters: URLSearchParams, product = false) {
|
||||
}
|
||||
}
|
||||
if (product) {
|
||||
conditions.push("observation.can_sell = true", "observation.sellability_status = 'available'");
|
||||
conditions.push(
|
||||
"observation.can_sell = true",
|
||||
"observation.sellability_status = 'available'",
|
||||
);
|
||||
const verdict = filters.get("verdict")?.toUpperCase();
|
||||
if (verdict === "FBA" || verdict === "FBM" || verdict === "SKIP") {
|
||||
conditions.push("analysis.decision::text = ?");
|
||||
@@ -964,7 +1054,14 @@ async function getStalkerResults(filters: URLSearchParams) {
|
||||
},
|
||||
"persisted_inventory_asin_count DESC, last_seen_at DESC, seller_id ASC",
|
||||
);
|
||||
const total = Number((await pgGet<{ total: string }>(`SELECT COUNT(*) AS total FROM (${base}) rows`, params))?.total ?? 0);
|
||||
const total = Number(
|
||||
(
|
||||
await pgGet<{ total: string }>(
|
||||
`SELECT COUNT(*) AS total FROM (${base}) rows`,
|
||||
params,
|
||||
)
|
||||
)?.total ?? 0,
|
||||
);
|
||||
const items = await pgAll(
|
||||
`SELECT * FROM (${base}) rows ORDER BY ${order} LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, offset],
|
||||
@@ -982,7 +1079,10 @@ async function getStalkerResults(filters: URLSearchParams) {
|
||||
sellers: Number(summary?.sellers ?? 0),
|
||||
persistedInventoryAsins: Number(summary?.persistedInventoryAsins ?? 0),
|
||||
},
|
||||
page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1040,8 +1140,16 @@ async function stalkerProducts(filters: URLSearchParams, exportOnly = false) {
|
||||
},
|
||||
"monthly_sold DESC NULLS LAST, last_seen_at DESC, asin ASC",
|
||||
);
|
||||
if (exportOnly) return pgAll(`SELECT * FROM (${base}) products ORDER BY ${order}`, params);
|
||||
const total = Number((await pgGet<{ total: string }>(`SELECT COUNT(*) AS total FROM (${base}) products`, params))?.total ?? 0);
|
||||
if (exportOnly)
|
||||
return pgAll(`SELECT * FROM (${base}) products ORDER BY ${order}`, params);
|
||||
const total = Number(
|
||||
(
|
||||
await pgGet<{ total: string }>(
|
||||
`SELECT COUNT(*) AS total FROM (${base}) products`,
|
||||
params,
|
||||
)
|
||||
)?.total ?? 0,
|
||||
);
|
||||
const items = await pgAll(
|
||||
`SELECT * FROM (${base}) products ORDER BY ${order} LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, offset],
|
||||
@@ -1058,12 +1166,19 @@ async function stalkerProducts(filters: URLSearchParams, exportOnly = false) {
|
||||
sellers: Number(summary?.sellers ?? 0),
|
||||
products: Number(summary?.products ?? 0),
|
||||
},
|
||||
page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
};
|
||||
}
|
||||
|
||||
async function exportStalkerProducts(filters: URLSearchParams): Promise<Response> {
|
||||
const rows = (await stalkerProducts(filters, true)) as Array<Record<string, any>>;
|
||||
async function exportStalkerProducts(
|
||||
filters: URLSearchParams,
|
||||
): Promise<Response> {
|
||||
const rows = (await stalkerProducts(filters, true)) as Array<
|
||||
Record<string, any>
|
||||
>;
|
||||
const data = rows.map((row) => ({
|
||||
ASIN: row.asin,
|
||||
"Amazon URL": `https://amazon.com/dp/${row.asin}`,
|
||||
@@ -1081,7 +1196,11 @@ async function exportStalkerProducts(filters: URLSearchParams): Promise<Response
|
||||
"Run ID": row.runId,
|
||||
}));
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(data), "Sellable Products");
|
||||
XLSX.utils.book_append_sheet(
|
||||
workbook,
|
||||
XLSX.utils.json_to_sheet(data),
|
||||
"Sellable Products",
|
||||
);
|
||||
return xlsx(
|
||||
XLSX.write(workbook, { type: "array", bookType: "xlsx" }) as ArrayBuffer,
|
||||
"stalker-sellable-products.xlsx",
|
||||
@@ -1110,13 +1229,17 @@ const server = Bun.serve({
|
||||
"/stalker": index,
|
||||
"/stalker/products": index,
|
||||
"/runs/:runId": index,
|
||||
"/api/runs": async (req) => json(await getRuns(new URL(req.url).searchParams)),
|
||||
"/api/runs": async (req) =>
|
||||
json(await getRuns(new URL(req.url).searchParams)),
|
||||
"/api/runs/:runId": async (req) => {
|
||||
const runId = Number(req.params.runId);
|
||||
if (!Number.isInteger(runId)) return json({ error: "Invalid run identifier" }, 400);
|
||||
if (!Number.isInteger(runId))
|
||||
return json({ error: "Invalid run identifier" }, 400);
|
||||
if (req.method === "DELETE") {
|
||||
const deleted = await pgRun("DELETE FROM runs WHERE id = ?", [runId]);
|
||||
return deleted ? json({ deletedRun: true }) : json({ error: "Run not found" }, 404);
|
||||
return deleted
|
||||
? json({ deletedRun: true })
|
||||
: json({ error: "Run not found" }, 404);
|
||||
}
|
||||
const run = await getRun(runId);
|
||||
if (!run) return json({ error: "Run not found" }, 404);
|
||||
@@ -1134,81 +1257,121 @@ const server = Bun.serve({
|
||||
},
|
||||
"/api/runs/:runId/items": async (req) => {
|
||||
const runId = Number(req.params.runId);
|
||||
if (!Number.isInteger(runId)) return json({ error: "Invalid run identifier" }, 400);
|
||||
if (!Number.isInteger(runId))
|
||||
return json({ error: "Invalid run identifier" }, 400);
|
||||
return json(await getRunItems(runId, new URL(req.url).searchParams));
|
||||
},
|
||||
"/api/runs/:runId/export.csv": async (req) => {
|
||||
const runId = Number(req.params.runId);
|
||||
if (!Number.isInteger(runId)) return json({ error: "Invalid run identifier" }, 400);
|
||||
return csv(await exportRunItems(runId, new URL(req.url).searchParams), `run-${runId}.csv`);
|
||||
if (!Number.isInteger(runId))
|
||||
return json({ error: "Invalid run identifier" }, 400);
|
||||
return csv(
|
||||
await exportRunItems(runId, new URL(req.url).searchParams),
|
||||
`run-${runId}.csv`,
|
||||
);
|
||||
},
|
||||
"/api/run-items/:itemId/reanalyze": async (req) => {
|
||||
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
||||
if (req.method !== "POST")
|
||||
return json({ error: "Method not allowed" }, 405);
|
||||
const itemId = Number(req.params.itemId);
|
||||
if (!Number.isInteger(itemId)) return json({ error: "Invalid run item identifier" }, 400);
|
||||
if (!Number.isInteger(itemId))
|
||||
return json({ error: "Invalid run item identifier" }, 400);
|
||||
try {
|
||||
return json(await reanalyzeRunItem(itemId));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: message }, message === "Run item not found" ? 404 : 500);
|
||||
return json(
|
||||
{ error: message },
|
||||
message === "Run item not found" ? 404 : 500,
|
||||
);
|
||||
}
|
||||
},
|
||||
"/api/products": async (req) => json(await getProducts(new URL(req.url).searchParams)),
|
||||
"/api/products": async (req) =>
|
||||
json(await getProducts(new URL(req.url).searchParams)),
|
||||
"/api/products/:asin": async (req) => {
|
||||
const asin = normalizeAsin(req.params.asin);
|
||||
if (!asin) return json({ error: "Invalid ASIN" }, 400);
|
||||
const result = await getProduct(asin);
|
||||
return result ? json(result) : json({ error: "Product not found" }, 404);
|
||||
},
|
||||
"/api/stalker/results": async (req) => json(await getStalkerResults(new URL(req.url).searchParams)),
|
||||
"/api/stalker/products": async (req) => json(await stalkerProducts(new URL(req.url).searchParams)),
|
||||
"/api/stalker/products/export.xlsx": async (req) => exportStalkerProducts(new URL(req.url).searchParams),
|
||||
"/api/stalker/results": async (req) =>
|
||||
json(await getStalkerResults(new URL(req.url).searchParams)),
|
||||
"/api/stalker/products": async (req) =>
|
||||
json(await stalkerProducts(new URL(req.url).searchParams)),
|
||||
"/api/stalker/products/export.xlsx": async (req) =>
|
||||
exportStalkerProducts(new URL(req.url).searchParams),
|
||||
"/api/stalker/products/:runItemId/reanalyze": async (req) => {
|
||||
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
||||
if (req.method !== "POST")
|
||||
return json({ error: "Method not allowed" }, 405);
|
||||
const runItemId = Number(req.params.runItemId);
|
||||
if (!Number.isInteger(runItemId)) return json({ error: "Invalid run item identifier" }, 400);
|
||||
const provider = new URL(req.url).searchParams.get("provider")?.trim().toLowerCase();
|
||||
if (!Number.isInteger(runItemId))
|
||||
return json({ error: "Invalid run item identifier" }, 400);
|
||||
const provider = new URL(req.url).searchParams
|
||||
.get("provider")
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
const useClaude = provider === "claude";
|
||||
try {
|
||||
return json(await reanalyzeRunItem(runItemId, useClaude || USE_CLAUDE));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: message }, message === "Run item not found" ? 404 : 500);
|
||||
return json(
|
||||
{ error: message },
|
||||
message === "Run item not found" ? 404 : 500,
|
||||
);
|
||||
}
|
||||
},
|
||||
"/api/stalker/products/:runItemId/distributors": async (req) => {
|
||||
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
||||
if (req.method !== "POST")
|
||||
return json({ error: "Method not allowed" }, 405);
|
||||
const runItemId = Number(req.params.runItemId);
|
||||
if (!Number.isInteger(runItemId)) return json({ error: "Invalid run item identifier" }, 400);
|
||||
if (!Number.isInteger(runItemId))
|
||||
return json({ error: "Invalid run item identifier" }, 400);
|
||||
try {
|
||||
return json(await findDistributorsForStalkerProduct(runItemId));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: message }, message === "Stalker product item not found" ? 404 : 500);
|
||||
return json(
|
||||
{ error: message },
|
||||
message === "Stalker product item not found" ? 404 : 500,
|
||||
);
|
||||
}
|
||||
},
|
||||
"/api/stalker/products/by-asin/:asin/reanalyze": async (req) => {
|
||||
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
||||
if (req.method !== "POST")
|
||||
return json({ error: "Method not allowed" }, 405);
|
||||
const asin = normalizeAsin(req.params.asin);
|
||||
if (!asin) return json({ error: "Invalid ASIN" }, 400);
|
||||
const provider = new URL(req.url).searchParams.get("provider")?.trim().toLowerCase();
|
||||
const provider = new URL(req.url).searchParams
|
||||
.get("provider")
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
const useClaude = provider === "claude";
|
||||
try {
|
||||
return json(await reanalyzeStalkerProductByAsin(asin, useClaude || USE_CLAUDE));
|
||||
return json(
|
||||
await reanalyzeStalkerProductByAsin(asin, useClaude || USE_CLAUDE),
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: message }, message === "Stalker product item not found" ? 404 : 500);
|
||||
return json(
|
||||
{ error: message },
|
||||
message === "Stalker product item not found" ? 404 : 500,
|
||||
);
|
||||
}
|
||||
},
|
||||
"/api/stalker/products/by-asin/:asin/distributors": async (req) => {
|
||||
if (req.method !== "POST") return json({ error: "Method not allowed" }, 405);
|
||||
if (req.method !== "POST")
|
||||
return json({ error: "Method not allowed" }, 405);
|
||||
const asin = normalizeAsin(req.params.asin);
|
||||
if (!asin) return json({ error: "Invalid ASIN" }, 400);
|
||||
try {
|
||||
return json(await findDistributorsForStalkerProductByAsin(asin));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return json({ error: message }, message === "Stalker product item not found" ? 404 : 500);
|
||||
return json(
|
||||
{ error: message },
|
||||
message === "Stalker product item not found" ? 404 : 500,
|
||||
);
|
||||
}
|
||||
},
|
||||
"/api/stalker/purge": async (req) =>
|
||||
@@ -1220,10 +1383,15 @@ const server = Bun.serve({
|
||||
const upcs = await parseUpcsFromRequest(req);
|
||||
const error = validateUpcs(upcs);
|
||||
if (error) return json({ error }, 400);
|
||||
const items = [...(await mapUpcsToAsins(upcs)).entries()].map(([upc, asin]) => ({ upc, asin }));
|
||||
const items = [...(await mapUpcsToAsins(upcs)).entries()].map(
|
||||
([upc, asin]) => ({ upc, asin }),
|
||||
);
|
||||
return json({ requested: upcs.length, matched: items.length, items });
|
||||
} catch (error) {
|
||||
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
400,
|
||||
);
|
||||
}
|
||||
},
|
||||
"/api/upc/lookup": async (req) => {
|
||||
@@ -1232,16 +1400,31 @@ const server = Bun.serve({
|
||||
const error = validateUpcs(upcs);
|
||||
if (error) return json({ error }, 400);
|
||||
const items = [...(await lookupKeepaUpcs(upcs)).values()];
|
||||
return json({ requested: upcs.length, statusCounts: summarizeLookupStatuses(items), items });
|
||||
return json({
|
||||
requested: upcs.length,
|
||||
statusCounts: summarizeLookupStatuses(items),
|
||||
items,
|
||||
});
|
||||
} catch (error) {
|
||||
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
400,
|
||||
);
|
||||
}
|
||||
},
|
||||
"/api/process/upc-file": async (req) => {
|
||||
try {
|
||||
return json(await runUpcFileAnalysis({ ...(await parseUpcFileRequest(req)), manageResources: false }));
|
||||
return json(
|
||||
await runUpcFileAnalysis({
|
||||
...(await parseUpcFileRequest(req)),
|
||||
manageResources: false,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
400,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -300,6 +300,85 @@ function nextSort(current: SortState, field: string): SortState {
|
||||
};
|
||||
}
|
||||
|
||||
const VIEW_STATE_KEY = "asin-check.view-state.v1";
|
||||
const FILTER_PRESETS_KEY = "asin-check.filter-presets.v1";
|
||||
|
||||
type SavedFilterPreset = {
|
||||
id: string;
|
||||
name: string;
|
||||
viewKey: string;
|
||||
state: Record<string, unknown>;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
function readJsonStorage<T>(key: string, fallback: T): T {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonStorage<T>(key: string, value: T): void {
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
function readViewState(viewKey: string): Record<string, unknown> {
|
||||
const all = readJsonStorage<Record<string, Record<string, unknown>>>(VIEW_STATE_KEY, {});
|
||||
const value = all[viewKey];
|
||||
return value && typeof value === "object" ? value : {};
|
||||
}
|
||||
|
||||
function saveViewState(viewKey: string, state: Record<string, unknown>): void {
|
||||
const all = readJsonStorage<Record<string, Record<string, unknown>>>(VIEW_STATE_KEY, {});
|
||||
all[viewKey] = state;
|
||||
writeJsonStorage(VIEW_STATE_KEY, all);
|
||||
}
|
||||
|
||||
function listFilterPresets(viewKey: string): SavedFilterPreset[] {
|
||||
const all = readJsonStorage<SavedFilterPreset[]>(FILTER_PRESETS_KEY, []);
|
||||
return all.filter((preset) => preset.viewKey === viewKey);
|
||||
}
|
||||
|
||||
function saveFilterPreset(viewKey: string, name: string, state: Record<string, unknown>): SavedFilterPreset {
|
||||
const all = readJsonStorage<SavedFilterPreset[]>(FILTER_PRESETS_KEY, []);
|
||||
const normalizedName = name.trim().toLowerCase();
|
||||
const existing = all.find((preset) => preset.viewKey === viewKey && preset.name.trim().toLowerCase() === normalizedName);
|
||||
if (existing) {
|
||||
existing.state = state;
|
||||
existing.updatedAt = new Date().toISOString();
|
||||
writeJsonStorage(FILTER_PRESETS_KEY, all);
|
||||
return existing;
|
||||
}
|
||||
const created: SavedFilterPreset = {
|
||||
id: typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random()}`,
|
||||
name: name.trim(),
|
||||
viewKey,
|
||||
state,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
all.unshift(created);
|
||||
writeJsonStorage(FILTER_PRESETS_KEY, all);
|
||||
return created;
|
||||
}
|
||||
|
||||
function updateFilterPreset(presetId: string, state: Record<string, unknown>): SavedFilterPreset | null {
|
||||
const all = readJsonStorage<SavedFilterPreset[]>(FILTER_PRESETS_KEY, []);
|
||||
const existing = all.find((preset) => preset.id === presetId);
|
||||
if (!existing) return null;
|
||||
existing.state = state;
|
||||
existing.updatedAt = new Date().toISOString();
|
||||
writeJsonStorage(FILTER_PRESETS_KEY, all);
|
||||
return existing;
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
if (status === "ok" || status === "completed") return "badge badge-ok";
|
||||
if (status === "failed") return "badge badge-failed";
|
||||
@@ -367,18 +446,90 @@ function Dashboard({
|
||||
onOpenStalker: () => void;
|
||||
onOpenStalkerProducts: () => void;
|
||||
}) {
|
||||
const viewKey = "dashboard";
|
||||
const initialState = useMemo(() => readViewState(viewKey), []);
|
||||
const [runs, setRuns] = useState<RunsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [processType, setProcessType] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [sort, setSort] = useState<SortState>({ field: "timestamp", direction: "DESC" });
|
||||
const [search, setSearch] = useState(String(initialState.search ?? ""));
|
||||
const [processType, setProcessType] = useState(String(initialState.processType ?? ""));
|
||||
const [status, setStatus] = useState(String(initialState.status ?? ""));
|
||||
const [startDate, setStartDate] = useState(String(initialState.startDate ?? ""));
|
||||
const [endDate, setEndDate] = useState(String(initialState.endDate ?? ""));
|
||||
const [page, setPage] = useState(Number(initialState.page ?? 1) || 1);
|
||||
const [pageSize, setPageSize] = useState(Number(initialState.pageSize ?? 25) || 25);
|
||||
const [sort, setSort] = useState<SortState>({
|
||||
field: String(initialState.sortField ?? "timestamp"),
|
||||
direction: initialState.sortDirection === "ASC" ? "ASC" : "DESC",
|
||||
});
|
||||
const [refreshTick, setRefreshTick] = useState(0);
|
||||
const [deletingKey, setDeletingKey] = useState<string | null>(null);
|
||||
const [presetName, setPresetName] = useState("");
|
||||
const [selectedPresetId, setSelectedPresetId] = useState("");
|
||||
const [presets, setPresets] = useState<SavedFilterPreset[]>(() => listFilterPresets(viewKey));
|
||||
|
||||
function snapshotFilters(): Record<string, unknown> {
|
||||
return {
|
||||
search,
|
||||
processType,
|
||||
status,
|
||||
startDate,
|
||||
endDate,
|
||||
page,
|
||||
pageSize,
|
||||
sortField: sort.field,
|
||||
sortDirection: sort.direction,
|
||||
};
|
||||
}
|
||||
|
||||
function applyFilterState(state: Record<string, unknown>) {
|
||||
setSearch(String(state.search ?? ""));
|
||||
setProcessType(String(state.processType ?? ""));
|
||||
setStatus(String(state.status ?? ""));
|
||||
setStartDate(String(state.startDate ?? ""));
|
||||
setEndDate(String(state.endDate ?? ""));
|
||||
setPage(Number(state.page ?? 1) || 1);
|
||||
setPageSize(Number(state.pageSize ?? 25) || 25);
|
||||
setSort({
|
||||
field: String(state.sortField ?? "timestamp"),
|
||||
direction: state.sortDirection === "ASC" ? "ASC" : "DESC",
|
||||
});
|
||||
}
|
||||
|
||||
function saveCurrentPreset() {
|
||||
if (!presetName.trim()) return;
|
||||
const saved = saveFilterPreset(viewKey, presetName, snapshotFilters());
|
||||
setPresets(listFilterPresets(viewKey));
|
||||
setSelectedPresetId(saved.id);
|
||||
setPresetName(saved.name);
|
||||
}
|
||||
|
||||
function applySelectedPreset() {
|
||||
if (!selectedPresetId) return;
|
||||
const preset = presets.find((item) => item.id === selectedPresetId);
|
||||
if (!preset) return;
|
||||
applyFilterState(preset.state);
|
||||
}
|
||||
|
||||
function updateSelectedPreset() {
|
||||
if (!selectedPresetId) return;
|
||||
const updated = updateFilterPreset(selectedPresetId, snapshotFilters());
|
||||
if (!updated) return;
|
||||
setPresets(listFilterPresets(viewKey));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
saveViewState(viewKey, {
|
||||
search,
|
||||
processType,
|
||||
status,
|
||||
startDate,
|
||||
endDate,
|
||||
page,
|
||||
pageSize,
|
||||
sortField: sort.field,
|
||||
sortDirection: sort.direction,
|
||||
});
|
||||
}, [viewKey, search, processType, status, startDate, endDate, page, pageSize, sort]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!runs) return { total: 0, fba: 0, fbm: 0, skip: 0 };
|
||||
@@ -504,6 +655,14 @@ function Dashboard({
|
||||
<option value="50">50 / page</option>
|
||||
<option value="100">100 / page</option>
|
||||
</select>
|
||||
<input value={presetName} onChange={(e) => setPresetName(e.target.value)} placeholder="Preset name" />
|
||||
<select value={selectedPresetId} onChange={(e) => setSelectedPresetId(e.target.value)}>
|
||||
<option value="">Select preset</option>
|
||||
{presets.map((preset) => <option key={preset.id} value={preset.id}>{preset.name}</option>)}
|
||||
</select>
|
||||
<button onClick={saveCurrentPreset}>Save</button>
|
||||
<button disabled={!selectedPresetId} onClick={updateSelectedPreset}>Update</button>
|
||||
<button disabled={!selectedPresetId} onClick={applySelectedPreset}>Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -596,21 +755,96 @@ function RunDetails({
|
||||
runId: number;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const viewKey = `run-details:${runId}`;
|
||||
const initialState = useMemo(() => readViewState(viewKey), [viewKey]);
|
||||
const [run, setRun] = useState<RunDetail | null>(null);
|
||||
const [results, setResults] = useState<ResultsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [verdict, setVerdict] = useState("");
|
||||
const [sellabilityStatus, setSellabilityStatus] = useState("");
|
||||
const [search, setSearch] = useState(String(initialState.search ?? ""));
|
||||
const [verdict, setVerdict] = useState(String(initialState.verdict ?? ""));
|
||||
const [sellabilityStatus, setSellabilityStatus] = useState(String(initialState.sellabilityStatus ?? ""));
|
||||
const [amazonSellerFilter, setAmazonSellerFilter] =
|
||||
useState<AmazonSellerFilter>("");
|
||||
const [minConfidence, setMinConfidence] = useState("");
|
||||
const [maxConfidence, setMaxConfidence] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
||||
useState<AmazonSellerFilter>(initialState.amazonSellerFilter === "yes" || initialState.amazonSellerFilter === "no" ? initialState.amazonSellerFilter : "");
|
||||
const [minConfidence, setMinConfidence] = useState(String(initialState.minConfidence ?? ""));
|
||||
const [maxConfidence, setMaxConfidence] = useState(String(initialState.maxConfidence ?? ""));
|
||||
const [page, setPage] = useState(Number(initialState.page ?? 1) || 1);
|
||||
const [pageSize, setPageSize] = useState(Number(initialState.pageSize ?? 25) || 25);
|
||||
const [sort, setSort] = useState<SortState>({
|
||||
field: String(initialState.sortField ?? "monthly_sold"),
|
||||
direction: initialState.sortDirection === "ASC" ? "ASC" : "DESC",
|
||||
});
|
||||
const [refreshTick, setRefreshTick] = useState(0);
|
||||
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
|
||||
const [presetName, setPresetName] = useState("");
|
||||
const [selectedPresetId, setSelectedPresetId] = useState("");
|
||||
const [presets, setPresets] = useState<SavedFilterPreset[]>(() => listFilterPresets(viewKey));
|
||||
|
||||
useEffect(() => {
|
||||
saveViewState(viewKey, {
|
||||
search,
|
||||
verdict,
|
||||
sellabilityStatus,
|
||||
amazonSellerFilter,
|
||||
minConfidence,
|
||||
maxConfidence,
|
||||
page,
|
||||
pageSize,
|
||||
sortField: sort.field,
|
||||
sortDirection: sort.direction,
|
||||
});
|
||||
}, [viewKey, search, verdict, sellabilityStatus, amazonSellerFilter, minConfidence, maxConfidence, page, pageSize, sort]);
|
||||
|
||||
function snapshotFilters(): Record<string, unknown> {
|
||||
return {
|
||||
search,
|
||||
verdict,
|
||||
sellabilityStatus,
|
||||
amazonSellerFilter,
|
||||
minConfidence,
|
||||
maxConfidence,
|
||||
page,
|
||||
pageSize,
|
||||
sortField: sort.field,
|
||||
sortDirection: sort.direction,
|
||||
};
|
||||
}
|
||||
|
||||
function applyFilterState(state: Record<string, unknown>) {
|
||||
setSearch(String(state.search ?? ""));
|
||||
setVerdict(String(state.verdict ?? ""));
|
||||
setSellabilityStatus(String(state.sellabilityStatus ?? ""));
|
||||
setAmazonSellerFilter(state.amazonSellerFilter === "yes" || state.amazonSellerFilter === "no" ? state.amazonSellerFilter : "");
|
||||
setMinConfidence(String(state.minConfidence ?? ""));
|
||||
setMaxConfidence(String(state.maxConfidence ?? ""));
|
||||
setPage(Number(state.page ?? 1) || 1);
|
||||
setPageSize(Number(state.pageSize ?? 25) || 25);
|
||||
setSort({
|
||||
field: String(state.sortField ?? "monthly_sold"),
|
||||
direction: state.sortDirection === "ASC" ? "ASC" : "DESC",
|
||||
});
|
||||
}
|
||||
|
||||
function saveCurrentPreset() {
|
||||
if (!presetName.trim()) return;
|
||||
const saved = saveFilterPreset(viewKey, presetName, snapshotFilters());
|
||||
setPresets(listFilterPresets(viewKey));
|
||||
setSelectedPresetId(saved.id);
|
||||
setPresetName(saved.name);
|
||||
}
|
||||
|
||||
function applySelectedPreset() {
|
||||
if (!selectedPresetId) return;
|
||||
const preset = presets.find((item) => item.id === selectedPresetId);
|
||||
if (!preset) return;
|
||||
applyFilterState(preset.state);
|
||||
}
|
||||
|
||||
function updateSelectedPreset() {
|
||||
if (!selectedPresetId) return;
|
||||
const updated = updateFilterPreset(selectedPresetId, snapshotFilters());
|
||||
if (!updated) return;
|
||||
setPresets(listFilterPresets(viewKey));
|
||||
}
|
||||
|
||||
const anomalies = useMemo(() => {
|
||||
if (!results) return [] as ResultItem[];
|
||||
@@ -755,6 +989,14 @@ function RunDetails({
|
||||
<option value="50">50 / page</option>
|
||||
<option value="100">100 / page</option>
|
||||
</select>
|
||||
<input value={presetName} onChange={(e) => setPresetName(e.target.value)} placeholder="Preset name" />
|
||||
<select value={selectedPresetId} onChange={(e) => setSelectedPresetId(e.target.value)}>
|
||||
<option value="">Select preset</option>
|
||||
{presets.map((preset) => <option key={preset.id} value={preset.id}>{preset.name}</option>)}
|
||||
</select>
|
||||
<button onClick={saveCurrentPreset}>Save</button>
|
||||
<button disabled={!selectedPresetId} onClick={updateSelectedPreset}>Update</button>
|
||||
<button disabled={!selectedPresetId} onClick={applySelectedPreset}>Apply</button>
|
||||
</div>
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<a
|
||||
@@ -773,7 +1015,7 @@ function RunDetails({
|
||||
<div className="anomaly-list" style={{ marginTop: 8 }}>
|
||||
{anomalies.slice(0, 8).map((item) => (
|
||||
<div key={`anom-${item.asin}-${item.fetched_at}`} className="anomaly-item">
|
||||
{item.product_asin ? <a href={`/products/${item.product_asin}`}>{item.asin}</a> : item.asin}
|
||||
{item.product_asin ? <button onClick={() => onOpenProduct(item.product_asin!, item.item_id)}>{item.asin}</button> : item.asin}
|
||||
{item.verdict ? <span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span> : "-"}
|
||||
<span>{detectAnomaly(item)}</span>
|
||||
</div>
|
||||
@@ -810,7 +1052,7 @@ function RunDetails({
|
||||
) : results?.items.length ? (
|
||||
results.items.map((item) => (
|
||||
<tr key={`${item.asin}-${item.fetched_at}`}>
|
||||
<td>{item.product_asin ? <a href={`/products/${item.product_asin}`}>{item.asin}</a> : item.asin}</td>
|
||||
<td>{item.product_asin ? <button onClick={() => onOpenProduct(item.product_asin!, item.item_id)}>{item.asin}</button> : item.asin}</td>
|
||||
<td>{item.verdict ? <span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span> : "-"}</td>
|
||||
<td>{formatNumber(item.monthly_sold)}</td>
|
||||
<td>{formatNumber(item.seller_count)}</td>
|
||||
@@ -855,17 +1097,83 @@ function RunDetails({
|
||||
);
|
||||
}
|
||||
|
||||
function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () => void }) {
|
||||
function ProductList({ verdict, onBack, onOpenProduct }: { verdict: VerdictFilter; onBack: () => void; onOpenProduct: (asin: string, runItemId: number | null) => void }) {
|
||||
const viewKey = "products";
|
||||
const initialState = useMemo(() => readViewState(viewKey), []);
|
||||
const [items, setItems] = useState<ProductListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeVerdict, setActiveVerdict] = useState<VerdictFilter>(verdict);
|
||||
const [search, setSearch] = useState(String(initialState.search ?? ""));
|
||||
const [activeVerdict, setActiveVerdict] = useState<VerdictFilter>(String(initialState.activeVerdict ?? verdict) as VerdictFilter);
|
||||
const [amazonSellerFilter, setAmazonSellerFilter] =
|
||||
useState<AmazonSellerFilter>("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
||||
useState<AmazonSellerFilter>(initialState.amazonSellerFilter === "yes" || initialState.amazonSellerFilter === "no" ? initialState.amazonSellerFilter : "");
|
||||
const [page, setPage] = useState(Number(initialState.page ?? 1) || 1);
|
||||
const [pageSize, setPageSize] = useState(Number(initialState.pageSize ?? 25) || 25);
|
||||
const [sort, setSort] = useState<SortState>({
|
||||
field: String(initialState.sortField ?? "monthly_sold"),
|
||||
direction: initialState.sortDirection === "ASC" ? "ASC" : "DESC",
|
||||
});
|
||||
const [reanalyzing, setReanalyzing] = useState<Record<string, boolean>>({});
|
||||
const [presetName, setPresetName] = useState("");
|
||||
const [selectedPresetId, setSelectedPresetId] = useState("");
|
||||
const [presets, setPresets] = useState<SavedFilterPreset[]>(() => listFilterPresets(viewKey));
|
||||
|
||||
useEffect(() => {
|
||||
saveViewState(viewKey, {
|
||||
search,
|
||||
activeVerdict,
|
||||
amazonSellerFilter,
|
||||
page,
|
||||
pageSize,
|
||||
sortField: sort.field,
|
||||
sortDirection: sort.direction,
|
||||
});
|
||||
}, [viewKey, search, activeVerdict, amazonSellerFilter, page, pageSize, sort]);
|
||||
|
||||
function snapshotFilters(): Record<string, unknown> {
|
||||
return {
|
||||
search,
|
||||
activeVerdict,
|
||||
amazonSellerFilter,
|
||||
page,
|
||||
pageSize,
|
||||
sortField: sort.field,
|
||||
sortDirection: sort.direction,
|
||||
};
|
||||
}
|
||||
|
||||
function applyFilterState(state: Record<string, unknown>) {
|
||||
setSearch(String(state.search ?? ""));
|
||||
setActiveVerdict(String(state.activeVerdict ?? "") as VerdictFilter);
|
||||
setAmazonSellerFilter(state.amazonSellerFilter === "yes" || state.amazonSellerFilter === "no" ? state.amazonSellerFilter : "");
|
||||
setPage(Number(state.page ?? 1) || 1);
|
||||
setPageSize(Number(state.pageSize ?? 25) || 25);
|
||||
setSort({
|
||||
field: String(state.sortField ?? "monthly_sold"),
|
||||
direction: state.sortDirection === "ASC" ? "ASC" : "DESC",
|
||||
});
|
||||
}
|
||||
|
||||
function saveCurrentPreset() {
|
||||
if (!presetName.trim()) return;
|
||||
const saved = saveFilterPreset(viewKey, presetName, snapshotFilters());
|
||||
setPresets(listFilterPresets(viewKey));
|
||||
setSelectedPresetId(saved.id);
|
||||
setPresetName(saved.name);
|
||||
}
|
||||
|
||||
function applySelectedPreset() {
|
||||
if (!selectedPresetId) return;
|
||||
const preset = presets.find((item) => item.id === selectedPresetId);
|
||||
if (!preset) return;
|
||||
applyFilterState(preset.state);
|
||||
}
|
||||
|
||||
function updateSelectedPreset() {
|
||||
if (!selectedPresetId) return;
|
||||
const updated = updateFilterPreset(selectedPresetId, snapshotFilters());
|
||||
if (!updated) return;
|
||||
setPresets(listFilterPresets(viewKey));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setActiveVerdict(verdict);
|
||||
@@ -958,6 +1266,14 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
|
||||
<option value="50">50 / page</option>
|
||||
<option value="100">100 / page</option>
|
||||
</select>
|
||||
<input value={presetName} onChange={(e) => setPresetName(e.target.value)} placeholder="Preset name" />
|
||||
<select value={selectedPresetId} onChange={(e) => setSelectedPresetId(e.target.value)}>
|
||||
<option value="">Select preset</option>
|
||||
{presets.map((preset) => <option key={preset.id} value={preset.id}>{preset.name}</option>)}
|
||||
</select>
|
||||
<button onClick={saveCurrentPreset}>Save</button>
|
||||
<button disabled={!selectedPresetId} onClick={updateSelectedPreset}>Update</button>
|
||||
<button disabled={!selectedPresetId} onClick={applySelectedPreset}>Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
@@ -988,7 +1304,7 @@ function ProductList({ verdict, onBack }: { verdict: VerdictFilter; onBack: () =
|
||||
) : items?.items.length ? (
|
||||
items.items.map((item) => (
|
||||
<tr key={`${item.processType}-${item.runId}-${item.asin}-${item.fetched_at}`}>
|
||||
<td><a href={`/products/${item.asin}`}>{item.asin}</a></td>
|
||||
<td><button onClick={() => onOpenProduct(item.asin, item.item_id)}>{item.asin}</button></td>
|
||||
<td>{item.verdict ? <span className={verdictBadgeClass(item.verdict)}>{item.verdict}</span> : "-"}</td>
|
||||
<td>{formatNumber(item.monthly_sold)}</td>
|
||||
<td>{formatNumber(item.seller_count)}</td>
|
||||
@@ -1040,18 +1356,90 @@ function StalkerExplorer({
|
||||
onBack: () => void;
|
||||
onOpenProducts: () => void;
|
||||
}) {
|
||||
const viewKey = "stalker";
|
||||
const initialState = useMemo(() => readViewState(viewKey), []);
|
||||
const [results, setResults] = useState<StalkerResultsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [purging, setPurging] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sellerId, setSellerId] = useState("");
|
||||
const [runId, setRunId] = useState("");
|
||||
const [minRatingCount, setMinRatingCount] = useState("1");
|
||||
const [maxRatingCount, setMaxRatingCount] = useState("30");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [sort, setSort] = useState<SortState>({ field: "persisted_inventory_asin_count", direction: "DESC" });
|
||||
const [search, setSearch] = useState(String(initialState.search ?? ""));
|
||||
const [sellerId, setSellerId] = useState(String(initialState.sellerId ?? ""));
|
||||
const [runId, setRunId] = useState(String(initialState.runId ?? ""));
|
||||
const [minRatingCount, setMinRatingCount] = useState(String(initialState.minRatingCount ?? "1"));
|
||||
const [maxRatingCount, setMaxRatingCount] = useState(String(initialState.maxRatingCount ?? "30"));
|
||||
const [page, setPage] = useState(Number(initialState.page ?? 1) || 1);
|
||||
const [pageSize, setPageSize] = useState(Number(initialState.pageSize ?? 25) || 25);
|
||||
const [sort, setSort] = useState<SortState>({
|
||||
field: String(initialState.sortField ?? "persisted_inventory_asin_count"),
|
||||
direction: initialState.sortDirection === "ASC" ? "ASC" : "DESC",
|
||||
});
|
||||
const [refreshTick, setRefreshTick] = useState(0);
|
||||
const [presetName, setPresetName] = useState("");
|
||||
const [selectedPresetId, setSelectedPresetId] = useState("");
|
||||
const [presets, setPresets] = useState<SavedFilterPreset[]>(() => listFilterPresets(viewKey));
|
||||
|
||||
useEffect(() => {
|
||||
saveViewState(viewKey, {
|
||||
search,
|
||||
sellerId,
|
||||
runId,
|
||||
minRatingCount,
|
||||
maxRatingCount,
|
||||
page,
|
||||
pageSize,
|
||||
sortField: sort.field,
|
||||
sortDirection: sort.direction,
|
||||
});
|
||||
}, [viewKey, search, sellerId, runId, minRatingCount, maxRatingCount, page, pageSize, sort]);
|
||||
|
||||
function snapshotFilters(): Record<string, unknown> {
|
||||
return {
|
||||
search,
|
||||
sellerId,
|
||||
runId,
|
||||
minRatingCount,
|
||||
maxRatingCount,
|
||||
page,
|
||||
pageSize,
|
||||
sortField: sort.field,
|
||||
sortDirection: sort.direction,
|
||||
};
|
||||
}
|
||||
|
||||
function applyFilterState(state: Record<string, unknown>) {
|
||||
setSearch(String(state.search ?? ""));
|
||||
setSellerId(String(state.sellerId ?? ""));
|
||||
setRunId(String(state.runId ?? ""));
|
||||
setMinRatingCount(String(state.minRatingCount ?? "1"));
|
||||
setMaxRatingCount(String(state.maxRatingCount ?? "30"));
|
||||
setPage(Number(state.page ?? 1) || 1);
|
||||
setPageSize(Number(state.pageSize ?? 25) || 25);
|
||||
setSort({
|
||||
field: String(state.sortField ?? "persisted_inventory_asin_count"),
|
||||
direction: state.sortDirection === "ASC" ? "ASC" : "DESC",
|
||||
});
|
||||
}
|
||||
|
||||
function saveCurrentPreset() {
|
||||
if (!presetName.trim()) return;
|
||||
const saved = saveFilterPreset(viewKey, presetName, snapshotFilters());
|
||||
setPresets(listFilterPresets(viewKey));
|
||||
setSelectedPresetId(saved.id);
|
||||
setPresetName(saved.name);
|
||||
}
|
||||
|
||||
function applySelectedPreset() {
|
||||
if (!selectedPresetId) return;
|
||||
const preset = presets.find((item) => item.id === selectedPresetId);
|
||||
if (!preset) return;
|
||||
applyFilterState(preset.state);
|
||||
}
|
||||
|
||||
function updateSelectedPreset() {
|
||||
if (!selectedPresetId) return;
|
||||
const updated = updateFilterPreset(selectedPresetId, snapshotFilters());
|
||||
if (!updated) return;
|
||||
setPresets(listFilterPresets(viewKey));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -1144,6 +1532,14 @@ function StalkerExplorer({
|
||||
<option value="50">50 / page</option>
|
||||
<option value="100">100 / page</option>
|
||||
</select>
|
||||
<input value={presetName} onChange={(e) => setPresetName(e.target.value)} placeholder="Preset name" />
|
||||
<select value={selectedPresetId} onChange={(e) => setSelectedPresetId(e.target.value)}>
|
||||
<option value="">Select preset</option>
|
||||
{presets.map((preset) => <option key={preset.id} value={preset.id}>{preset.name}</option>)}
|
||||
</select>
|
||||
<button onClick={saveCurrentPreset}>Save</button>
|
||||
<button disabled={!selectedPresetId} onClick={updateSelectedPreset}>Update</button>
|
||||
<button disabled={!selectedPresetId} onClick={applySelectedPreset}>Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1220,31 +1616,148 @@ function StalkerProductsExplorer({
|
||||
onOpenSellers: () => void;
|
||||
onOpenProduct: (asin: string, runItemId: number | null) => void;
|
||||
}) {
|
||||
const viewKey = "stalker-products";
|
||||
const initialState = useMemo(() => readViewState(viewKey), []);
|
||||
const [results, setResults] = useState<StalkerProductsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [sellerId, setSellerId] = useState("");
|
||||
const [runId, setRunId] = useState("");
|
||||
const [verdict, setVerdict] = useState("");
|
||||
const [amazonIsSeller, setAmazonIsSeller] = useState("");
|
||||
const [minPrice, setMinPrice] = useState("");
|
||||
const [maxPrice, setMaxPrice] = useState("");
|
||||
const [minMonthlySold, setMinMonthlySold] = useState("");
|
||||
const [maxMonthlySold, setMaxMonthlySold] = useState("");
|
||||
const [minSalesRank, setMinSalesRank] = useState("");
|
||||
const [maxSalesRank, setMaxSalesRank] = useState("");
|
||||
const [minSellerCount, setMinSellerCount] = useState("");
|
||||
const [maxSellerCount, setMaxSellerCount] = useState("");
|
||||
const [minRatingCount, setMinRatingCount] = useState("");
|
||||
const [maxRatingCount, setMaxRatingCount] = useState("");
|
||||
const [minConfidence, setMinConfidence] = useState("");
|
||||
const [maxConfidence, setMaxConfidence] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [sort, setSort] = useState<SortState>({ field: "monthly_sold", direction: "DESC" });
|
||||
const [showSellerIdColumn, setShowSellerIdColumn] = useState(false);
|
||||
const [showSellerColumn, setShowSellerColumn] = useState(false);
|
||||
const [showCategoryColumn, setShowCategoryColumn] = useState(false);
|
||||
const [search, setSearch] = useState(String(initialState.search ?? ""));
|
||||
const [sellerId, setSellerId] = useState(String(initialState.sellerId ?? ""));
|
||||
const [runId, setRunId] = useState(String(initialState.runId ?? ""));
|
||||
const [verdict, setVerdict] = useState(String(initialState.verdict ?? ""));
|
||||
const [amazonIsSeller, setAmazonIsSeller] = useState(String(initialState.amazonIsSeller ?? ""));
|
||||
const [minPrice, setMinPrice] = useState(String(initialState.minPrice ?? ""));
|
||||
const [maxPrice, setMaxPrice] = useState(String(initialState.maxPrice ?? ""));
|
||||
const [minMonthlySold, setMinMonthlySold] = useState(String(initialState.minMonthlySold ?? ""));
|
||||
const [maxMonthlySold, setMaxMonthlySold] = useState(String(initialState.maxMonthlySold ?? ""));
|
||||
const [minSalesRank, setMinSalesRank] = useState(String(initialState.minSalesRank ?? ""));
|
||||
const [maxSalesRank, setMaxSalesRank] = useState(String(initialState.maxSalesRank ?? ""));
|
||||
const [minSellerCount, setMinSellerCount] = useState(String(initialState.minSellerCount ?? ""));
|
||||
const [maxSellerCount, setMaxSellerCount] = useState(String(initialState.maxSellerCount ?? ""));
|
||||
const [minRatingCount, setMinRatingCount] = useState(String(initialState.minRatingCount ?? ""));
|
||||
const [maxRatingCount, setMaxRatingCount] = useState(String(initialState.maxRatingCount ?? ""));
|
||||
const [minConfidence, setMinConfidence] = useState(String(initialState.minConfidence ?? ""));
|
||||
const [maxConfidence, setMaxConfidence] = useState(String(initialState.maxConfidence ?? ""));
|
||||
const [page, setPage] = useState(Number(initialState.page ?? 1) || 1);
|
||||
const [pageSize, setPageSize] = useState(Number(initialState.pageSize ?? 50) || 50);
|
||||
const [sort, setSort] = useState<SortState>({
|
||||
field: String(initialState.sortField ?? "monthly_sold"),
|
||||
direction: initialState.sortDirection === "ASC" ? "ASC" : "DESC",
|
||||
});
|
||||
const [showSellerIdColumn, setShowSellerIdColumn] = useState(Boolean(initialState.showSellerIdColumn ?? false));
|
||||
const [showSellerColumn, setShowSellerColumn] = useState(Boolean(initialState.showSellerColumn ?? false));
|
||||
const [showCategoryColumn, setShowCategoryColumn] = useState(Boolean(initialState.showCategoryColumn ?? false));
|
||||
const [presetName, setPresetName] = useState("");
|
||||
const [selectedPresetId, setSelectedPresetId] = useState("");
|
||||
const [presets, setPresets] = useState<SavedFilterPreset[]>(() => listFilterPresets(viewKey));
|
||||
|
||||
useEffect(() => {
|
||||
saveViewState(viewKey, {
|
||||
search,
|
||||
sellerId,
|
||||
runId,
|
||||
verdict,
|
||||
amazonIsSeller,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
minMonthlySold,
|
||||
maxMonthlySold,
|
||||
minSalesRank,
|
||||
maxSalesRank,
|
||||
minSellerCount,
|
||||
maxSellerCount,
|
||||
minRatingCount,
|
||||
maxRatingCount,
|
||||
minConfidence,
|
||||
maxConfidence,
|
||||
page,
|
||||
pageSize,
|
||||
sortField: sort.field,
|
||||
sortDirection: sort.direction,
|
||||
showSellerIdColumn,
|
||||
showSellerColumn,
|
||||
showCategoryColumn,
|
||||
});
|
||||
}, [viewKey, search, sellerId, runId, verdict, amazonIsSeller, minPrice, maxPrice, minMonthlySold, maxMonthlySold, minSalesRank, maxSalesRank, minSellerCount, maxSellerCount, minRatingCount, maxRatingCount, minConfidence, maxConfidence, page, pageSize, sort, showSellerIdColumn, showSellerColumn, showCategoryColumn]);
|
||||
|
||||
function snapshotFilters(): Record<string, unknown> {
|
||||
return {
|
||||
search,
|
||||
sellerId,
|
||||
runId,
|
||||
verdict,
|
||||
amazonIsSeller,
|
||||
minPrice,
|
||||
maxPrice,
|
||||
minMonthlySold,
|
||||
maxMonthlySold,
|
||||
minSalesRank,
|
||||
maxSalesRank,
|
||||
minSellerCount,
|
||||
maxSellerCount,
|
||||
minRatingCount,
|
||||
maxRatingCount,
|
||||
minConfidence,
|
||||
maxConfidence,
|
||||
page,
|
||||
pageSize,
|
||||
sortField: sort.field,
|
||||
sortDirection: sort.direction,
|
||||
showSellerIdColumn,
|
||||
showSellerColumn,
|
||||
showCategoryColumn,
|
||||
};
|
||||
}
|
||||
|
||||
function applyFilterState(state: Record<string, unknown>) {
|
||||
setSearch(String(state.search ?? ""));
|
||||
setSellerId(String(state.sellerId ?? ""));
|
||||
setRunId(String(state.runId ?? ""));
|
||||
setVerdict(String(state.verdict ?? ""));
|
||||
setAmazonIsSeller(String(state.amazonIsSeller ?? ""));
|
||||
setMinPrice(String(state.minPrice ?? ""));
|
||||
setMaxPrice(String(state.maxPrice ?? ""));
|
||||
setMinMonthlySold(String(state.minMonthlySold ?? ""));
|
||||
setMaxMonthlySold(String(state.maxMonthlySold ?? ""));
|
||||
setMinSalesRank(String(state.minSalesRank ?? ""));
|
||||
setMaxSalesRank(String(state.maxSalesRank ?? ""));
|
||||
setMinSellerCount(String(state.minSellerCount ?? ""));
|
||||
setMaxSellerCount(String(state.maxSellerCount ?? ""));
|
||||
setMinRatingCount(String(state.minRatingCount ?? ""));
|
||||
setMaxRatingCount(String(state.maxRatingCount ?? ""));
|
||||
setMinConfidence(String(state.minConfidence ?? ""));
|
||||
setMaxConfidence(String(state.maxConfidence ?? ""));
|
||||
setPage(Number(state.page ?? 1) || 1);
|
||||
setPageSize(Number(state.pageSize ?? 50) || 50);
|
||||
setSort({
|
||||
field: String(state.sortField ?? "monthly_sold"),
|
||||
direction: state.sortDirection === "ASC" ? "ASC" : "DESC",
|
||||
});
|
||||
setShowSellerIdColumn(Boolean(state.showSellerIdColumn ?? false));
|
||||
setShowSellerColumn(Boolean(state.showSellerColumn ?? false));
|
||||
setShowCategoryColumn(Boolean(state.showCategoryColumn ?? false));
|
||||
}
|
||||
|
||||
function saveCurrentPreset() {
|
||||
if (!presetName.trim()) return;
|
||||
const saved = saveFilterPreset(viewKey, presetName, snapshotFilters());
|
||||
setPresets(listFilterPresets(viewKey));
|
||||
setSelectedPresetId(saved.id);
|
||||
setPresetName(saved.name);
|
||||
}
|
||||
|
||||
function applySelectedPreset() {
|
||||
if (!selectedPresetId) return;
|
||||
const preset = presets.find((item) => item.id === selectedPresetId);
|
||||
if (!preset) return;
|
||||
applyFilterState(preset.state);
|
||||
}
|
||||
|
||||
function updateSelectedPreset() {
|
||||
if (!selectedPresetId) return;
|
||||
const updated = updateFilterPreset(selectedPresetId, snapshotFilters());
|
||||
if (!updated) return;
|
||||
setPresets(listFilterPresets(viewKey));
|
||||
}
|
||||
|
||||
function buildStalkerProductParams(includePaging: boolean): URLSearchParams {
|
||||
const params = new URLSearchParams({
|
||||
@@ -1399,6 +1912,14 @@ function StalkerProductsExplorer({
|
||||
<option value="50">50 / page</option>
|
||||
<option value="100">100 / page</option>
|
||||
</select>
|
||||
<input value={presetName} onChange={(e) => setPresetName(e.target.value)} placeholder="Preset name" />
|
||||
<select value={selectedPresetId} onChange={(e) => setSelectedPresetId(e.target.value)}>
|
||||
<option value="">Select preset</option>
|
||||
{presets.map((preset) => <option key={preset.id} value={preset.id}>{preset.name}</option>)}
|
||||
</select>
|
||||
<button onClick={saveCurrentPreset}>Save</button>
|
||||
<button disabled={!selectedPresetId} onClick={updateSelectedPreset}>Update</button>
|
||||
<button disabled={!selectedPresetId} onClick={applySelectedPreset}>Apply</button>
|
||||
<button onClick={resetFilters}>Reset filters</button>
|
||||
<a className="button-link" href={exportHref}>Export XLSX</a>
|
||||
</div>
|
||||
@@ -1676,14 +2197,15 @@ function ProductDetails({
|
||||
);
|
||||
}
|
||||
|
||||
type AppRoute =
|
||||
type NonProductRoute =
|
||||
| { kind: "dashboard" }
|
||||
| { kind: "run"; processType: ProcessType; runId: number }
|
||||
| { kind: "products"; verdict: VerdictFilter }
|
||||
| { kind: "product"; asin: string; runItemId?: number | null }
|
||||
| { kind: "stalker" }
|
||||
| { kind: "stalker-products" };
|
||||
|
||||
type AppRoute = NonProductRoute | { kind: "product"; asin: string; runItemId?: number | null; backTo?: NonProductRoute };
|
||||
|
||||
function parseRoute(pathname: string, search: string): AppRoute {
|
||||
const runMatch = pathname.match(/^\/runs\/(\d+)$/);
|
||||
if (runMatch) {
|
||||
@@ -1713,6 +2235,14 @@ function parseRoute(pathname: string, search: string): AppRoute {
|
||||
return { kind: "dashboard" };
|
||||
}
|
||||
|
||||
function routePath(route: NonProductRoute): string {
|
||||
if (route.kind === "run") return `/runs/${route.runId}`;
|
||||
if (route.kind === "products") return route.verdict ? `/products?verdict=${encodeURIComponent(route.verdict)}` : "/products";
|
||||
if (route.kind === "stalker") return "/stalker";
|
||||
if (route.kind === "stalker-products") return "/stalker/products";
|
||||
return "/";
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [route, setRoute] = useState<AppRoute>(() => parseRoute(window.location.pathname, window.location.search));
|
||||
|
||||
@@ -1749,21 +2279,31 @@ function App() {
|
||||
setRoute({ kind: "dashboard" });
|
||||
}
|
||||
|
||||
function backFromProduct() {
|
||||
if (route.kind === "product" && route.backTo) {
|
||||
history.pushState({}, "", routePath(route.backTo));
|
||||
setRoute(route.backTo);
|
||||
return;
|
||||
}
|
||||
backToDashboard();
|
||||
}
|
||||
|
||||
function openProduct(asin: string, runItemId: number | null) {
|
||||
history.pushState({}, "", `/products/${asin}`);
|
||||
setRoute({ kind: "product", asin, runItemId });
|
||||
const backTo: NonProductRoute | undefined = route.kind === "product" ? undefined : route;
|
||||
setRoute({ kind: "product", asin, runItemId, backTo });
|
||||
}
|
||||
|
||||
if (route.kind === "run") {
|
||||
return <RunDetails processType={route.processType} runId={route.runId} onBack={backToDashboard} />;
|
||||
return <RunDetails processType={route.processType} runId={route.runId} onBack={backToDashboard} onOpenProduct={openProduct} />;
|
||||
}
|
||||
|
||||
if (route.kind === "products") {
|
||||
return <ProductList verdict={route.verdict} onBack={backToDashboard} />;
|
||||
return <ProductList verdict={route.verdict} onBack={backToDashboard} onOpenProduct={openProduct} />;
|
||||
}
|
||||
|
||||
if (route.kind === "product") {
|
||||
return <ProductDetails asin={route.asin} runItemId={route.runItemId} onBack={backToDashboard} />;
|
||||
return <ProductDetails asin={route.asin} runItemId={route.runItemId} onBack={backFromProduct} />;
|
||||
}
|
||||
|
||||
if (route.kind === "stalker") {
|
||||
|
||||
Reference in New Issue
Block a user