Add Product Admin page with merge, edit, and unlink
- Backend: 5 new admin endpoints (unmatched listing, store-products, product update, merge, unlink) with Pydantic schemas - Frontend: Admin page with searchable table, checkbox multi-select for merging, inline product editing dialog, expandable store products panel with unlink support, and floating merge bar - Install shadcn dialog, checkbox, label, and alert-dialog components - Add Product Admin link to sidebar navigation
This commit is contained in:
parent
20f7c76cdf
commit
ca5a2712b6
16 changed files with 1636 additions and 1 deletions
16
frontend/src/app/admin/page.tsx
Normal file
16
frontend/src/app/admin/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { UnmatchedTable } from "@/components/admin/unmatched-table";
|
||||
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Product Admin"
|
||||
subtitle="Manage product matching and metadata. Merge singletons, edit products, and unlink store products."
|
||||
/>
|
||||
<UnmatchedTable />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
frontend/src/components/admin/merge-dialog.tsx
Normal file
134
frontend/src/components/admin/merge-dialog.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { mergeProducts } from "@/lib/api";
|
||||
import type { AdminProductOut } from "@/lib/types";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface MergeDialogProps {
|
||||
products: AdminProductOut[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onMerged: () => void;
|
||||
}
|
||||
|
||||
export function MergeDialog({
|
||||
products,
|
||||
open,
|
||||
onOpenChange,
|
||||
onMerged,
|
||||
}: MergeDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [targetId, setTargetId] = useState<string>(String(products[0]?.id ?? ""));
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
mergeProducts({
|
||||
product_ids: products.map((p) => p.id),
|
||||
target_id: Number(targetId),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-unmatched"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
onOpenChange(false);
|
||||
onMerged();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Merge {products.length} Products</DialogTitle>
|
||||
<DialogDescription>
|
||||
All store products will be moved to the target product. The other
|
||||
products will be deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Target product (will be kept)</Label>
|
||||
<Select value={targetId} onValueChange={setTargetId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{products.map((p) => (
|
||||
<SelectItem key={p.id} value={String(p.id)}>
|
||||
#{p.id} — {p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Products to merge</Label>
|
||||
<div className="rounded-md border divide-y max-h-60 overflow-y-auto">
|
||||
{products.map((p) => {
|
||||
const sp = p.store_products[0];
|
||||
const isTarget = String(p.id) === targetId;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-center justify-between px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{isTarget && <Badge>Target</Badge>}
|
||||
<span className={isTarget ? "font-medium" : ""}>{p.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{sp && (
|
||||
<>
|
||||
<Badge variant="outline">{sp.store.name}</Badge>
|
||||
<span className="text-muted-foreground">
|
||||
{formatCurrency(sp.promo_price ?? sp.latest_price)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mutation.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Merge failed. Please try again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => mutation.mutate()} disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Merging..." : "Merge"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
161
frontend/src/components/admin/product-edit-dialog.tsx
Normal file
161
frontend/src/components/admin/product-edit-dialog.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { updateProduct } from "@/lib/api";
|
||||
import type { AdminProductOut, ProductUpdateIn } from "@/lib/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface ProductEditDialogProps {
|
||||
product: AdminProductOut;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ProductEditDialog({
|
||||
product,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ProductEditDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: product.name,
|
||||
brand: product.brand ?? "",
|
||||
ean: product.ean ?? "",
|
||||
unit: product.unit ?? "",
|
||||
unit_size: product.unit_size?.toString() ?? "",
|
||||
image_url: product.image_url ?? "",
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ProductUpdateIn) => updateProduct(product.id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-unmatched"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["products"] });
|
||||
onOpenChange(false);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const data: ProductUpdateIn = {};
|
||||
if (form.name !== product.name) data.name = form.name;
|
||||
if (form.brand !== (product.brand ?? ""))
|
||||
data.brand = form.brand || null;
|
||||
if (form.ean !== (product.ean ?? "")) data.ean = form.ean || null;
|
||||
if (form.unit !== (product.unit ?? "")) data.unit = form.unit || null;
|
||||
if (form.unit_size !== (product.unit_size?.toString() ?? ""))
|
||||
data.unit_size = form.unit_size ? Number(form.unit_size) : null;
|
||||
if (form.image_url !== (product.image_url ?? ""))
|
||||
data.image_url = form.image_url || null;
|
||||
|
||||
if (Object.keys(data).length === 0) {
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
mutation.mutate(data);
|
||||
}
|
||||
|
||||
function handleChange(field: string, value: string) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Product</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update product metadata. Only changed fields will be saved.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={form.name}
|
||||
onChange={(e) => handleChange("name", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="brand">Brand</Label>
|
||||
<Input
|
||||
id="brand"
|
||||
value={form.brand}
|
||||
onChange={(e) => handleChange("brand", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ean">EAN</Label>
|
||||
<Input
|
||||
id="ean"
|
||||
value={form.ean}
|
||||
onChange={(e) => handleChange("ean", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="unit">Unit</Label>
|
||||
<Input
|
||||
id="unit"
|
||||
value={form.unit}
|
||||
onChange={(e) => handleChange("unit", e.target.value)}
|
||||
placeholder="e.g. L, kg, ml"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="unit_size">Unit Size</Label>
|
||||
<Input
|
||||
id="unit_size"
|
||||
type="number"
|
||||
step="any"
|
||||
value={form.unit_size}
|
||||
onChange={(e) => handleChange("unit_size", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="image_url">Image URL</Label>
|
||||
<Input
|
||||
id="image_url"
|
||||
value={form.image_url}
|
||||
onChange={(e) => handleChange("image_url", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{mutation.isError && (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to update product. Please try again.
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
127
frontend/src/components/admin/store-products-panel.tsx
Normal file
127
frontend/src/components/admin/store-products-panel.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchAdminStoreProducts, unlinkStoreProduct } from "@/lib/api";
|
||||
import { queryKeys, staleTimes } from "@/lib/query-keys";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Unlink } from "lucide-react";
|
||||
|
||||
interface StoreProductsPanelProps {
|
||||
productId: number;
|
||||
productName: string;
|
||||
}
|
||||
|
||||
export function StoreProductsPanel({
|
||||
productId,
|
||||
productName,
|
||||
}: StoreProductsPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: storeProducts, isLoading } = useQuery({
|
||||
queryKey: queryKeys.adminStoreProducts(productId),
|
||||
queryFn: () => fetchAdminStoreProducts(productId),
|
||||
staleTime: staleTimes.adminStoreProducts,
|
||||
});
|
||||
|
||||
const unlinkMutation = useMutation({
|
||||
mutationFn: (storeProductId: number) =>
|
||||
unlinkStoreProduct(productId, storeProductId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-store-products"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-unmatched"] });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-20" />;
|
||||
}
|
||||
|
||||
if (!storeProducts || storeProducts.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
No store products found.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const canUnlink = storeProducts.length > 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Store products for "{productName}"
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
{storeProducts.map((sp) => (
|
||||
<div
|
||||
key={sp.id}
|
||||
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Badge variant="outline">{sp.store.name}</Badge>
|
||||
<span className="truncate">{sp.store_name}</span>
|
||||
{sp.store_sku && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
SKU: {sp.store_sku}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="font-medium">
|
||||
{formatCurrency(sp.promo_price ?? sp.latest_price)}
|
||||
</span>
|
||||
{!sp.is_active && (
|
||||
<Badge variant="secondary">Inactive</Badge>
|
||||
)}
|
||||
{canUnlink && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={unlinkMutation.isPending}
|
||||
>
|
||||
<Unlink className="h-3.5 w-3.5 mr-1" />
|
||||
Unlink
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unlink store product?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will remove "{sp.store_name}" ({sp.store.name})
|
||||
from this product and create a new standalone product for it.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => unlinkMutation.mutate(sp.id)}
|
||||
>
|
||||
Unlink
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
279
frontend/src/components/admin/unmatched-table.tsx
Normal file
279
frontend/src/components/admin/unmatched-table.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchUnmatched } from "@/lib/api";
|
||||
import { queryKeys, staleTimes } from "@/lib/query-keys";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import type { AdminProductOut } from "@/lib/types";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ChevronDown, ChevronRight, Pencil, Merge } from "lucide-react";
|
||||
import { StoreProductsPanel } from "./store-products-panel";
|
||||
import { ProductEditDialog } from "./product-edit-dialog";
|
||||
import { MergeDialog } from "./merge-dialog";
|
||||
|
||||
export function UnmatchedTable() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [editProduct, setEditProduct] = useState<AdminProductOut | null>(null);
|
||||
const [mergeOpen, setMergeOpen] = useState(false);
|
||||
const debouncedSearch = useDebounce(search);
|
||||
const limit = 30;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.adminUnmatched({
|
||||
search: debouncedSearch || undefined,
|
||||
page,
|
||||
}),
|
||||
queryFn: () =>
|
||||
fetchUnmatched({
|
||||
search: debouncedSearch || undefined,
|
||||
page,
|
||||
limit,
|
||||
}),
|
||||
staleTime: staleTimes.adminUnmatched,
|
||||
});
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
function toggleSelect(id: number) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (items.every((p) => selected.has(p.id))) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
items.forEach((p) => next.delete(p.id));
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
items.forEach((p) => next.add(p.id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProducts = items.filter((p) => selected.has(p.id));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search */}
|
||||
<Input
|
||||
placeholder="Search products..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-96" />
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted-foreground py-8 text-center">
|
||||
{debouncedSearch
|
||||
? "No unmatched products found for this search."
|
||||
: "No unmatched singleton products found."}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={
|
||||
items.length > 0 &&
|
||||
items.every((p) => selected.has(p.id))
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-10" />
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Brand</TableHead>
|
||||
<TableHead>EAN</TableHead>
|
||||
<TableHead>Unit</TableHead>
|
||||
<TableHead>Store</TableHead>
|
||||
<TableHead className="text-right">Price</TableHead>
|
||||
<TableHead className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((product) => {
|
||||
const sp = product.store_products[0];
|
||||
const isExpanded = expandedId === product.id;
|
||||
return (
|
||||
<TableRow key={product.id} className="group" data-expanded={isExpanded || undefined}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selected.has(product.id)}
|
||||
onCheckedChange={() => toggleSelect(product.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() =>
|
||||
setExpandedId(isExpanded ? null : product.id)
|
||||
}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium max-w-[200px] truncate">
|
||||
{product.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{product.brand ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">
|
||||
{product.ean ?? "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{product.unit && product.unit_size
|
||||
? `${product.unit_size} ${product.unit}`
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{sp ? (
|
||||
<Badge variant="outline">{sp.store.name}</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{sp
|
||||
? formatCurrency(sp.promo_price ?? sp.latest_price)
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => setEditProduct(product)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Expanded panel */}
|
||||
{expandedId && (
|
||||
<div className="rounded-md border p-4 bg-muted/30">
|
||||
<StoreProductsPanel
|
||||
productId={expandedId}
|
||||
productName={
|
||||
items.find((p) => p.id === expandedId)?.name ?? ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{total} unmatched product{total !== 1 && "s"}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="flex items-center px-2 text-muted-foreground">
|
||||
{page} / {totalPages || 1}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Floating merge bar */}
|
||||
{selected.size >= 2 && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50">
|
||||
<Button
|
||||
size="lg"
|
||||
className="shadow-lg gap-2"
|
||||
onClick={() => setMergeOpen(true)}
|
||||
>
|
||||
<Merge className="h-4 w-4" />
|
||||
Merge {selected.size} products
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit dialog */}
|
||||
{editProduct && (
|
||||
<ProductEditDialog
|
||||
product={editProduct}
|
||||
open={!!editProduct}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditProduct(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Merge dialog */}
|
||||
{mergeOpen && selectedProducts.length >= 2 && (
|
||||
<MergeDialog
|
||||
products={selectedProducts}
|
||||
open={mergeOpen}
|
||||
onOpenChange={setMergeOpen}
|
||||
onMerged={() => setSelected(new Set())}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Swords,
|
||||
TrendingUp,
|
||||
ShoppingCart,
|
||||
Settings,
|
||||
Menu,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
|
@ -19,6 +20,7 @@ const NAV_ITEMS = [
|
|||
{ href: "/price-battle", icon: <Swords className="h-4 w-4" />, label: "Price Battle" },
|
||||
{ href: "/product-history", icon: <TrendingUp className="h-4 w-4" />, label: "Product History" },
|
||||
{ href: "/basket-compare", icon: <ShoppingCart className="h-4 w-4" />, label: "Basket Compare" },
|
||||
{ href: "/admin", icon: <Settings className="h-4 w-4" />, label: "Product Admin" },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
|
|
|
|||
196
frontend/src/components/ui/alert-dialog.tsx
Normal file
196
frontend/src/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content> & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"text-lg font-semibold sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Action
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
32
frontend/src/components/ui/checkbox.tsx
Normal file
32
frontend/src/components/ui/checkbox.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
158
frontend/src/components/ui/dialog.tsx
Normal file
158
frontend/src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
24
frontend/src/components/ui/label.tsx
Normal file
24
frontend/src/components/ui/label.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
|
|
@ -1,14 +1,21 @@
|
|||
import type {
|
||||
AdminProductListOut,
|
||||
AdminProductOut,
|
||||
AdminStoreProductOut,
|
||||
BasketCompareOut,
|
||||
BasketIn,
|
||||
BattleOut,
|
||||
CategoryOut,
|
||||
ComparisonOut,
|
||||
MergeProductsIn,
|
||||
MergeProductsOut,
|
||||
PriceHistoryOut,
|
||||
ProductListOut,
|
||||
ProductUpdateIn,
|
||||
SearchPriceResult,
|
||||
StatsOut,
|
||||
StoreOut,
|
||||
UnlinkOut,
|
||||
} from "./types";
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
|
|
@ -87,3 +94,48 @@ export function compareBasket(basket: BasketIn) {
|
|||
body: JSON.stringify(basket),
|
||||
});
|
||||
}
|
||||
|
||||
// ──────────────────────────── Admin ─────────────────────────────────────────
|
||||
|
||||
export function fetchUnmatched(params: {
|
||||
search?: string;
|
||||
store_id?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const sp = new URLSearchParams();
|
||||
if (params.search) sp.set("search", params.search);
|
||||
if (params.store_id) sp.set("store_id", String(params.store_id));
|
||||
if (params.page) sp.set("page", String(params.page));
|
||||
if (params.limit) sp.set("limit", String(params.limit));
|
||||
return fetchApi<AdminProductListOut>(`/api/admin/unmatched?${sp.toString()}`);
|
||||
}
|
||||
|
||||
export function fetchAdminStoreProducts(productId: number) {
|
||||
return fetchApi<AdminStoreProductOut[]>(
|
||||
`/api/admin/products/${productId}/store-products`
|
||||
);
|
||||
}
|
||||
|
||||
export function updateProduct(productId: number, data: ProductUpdateIn) {
|
||||
return fetchApi<AdminProductOut>(`/api/admin/products/${productId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function mergeProducts(data: MergeProductsIn) {
|
||||
return fetchApi<MergeProductsOut>("/api/admin/products/merge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
export function unlinkStoreProduct(productId: number, storeProductId: number) {
|
||||
return fetchApi<UnlinkOut>(
|
||||
`/api/admin/products/${productId}/unlink/${storeProductId}`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ export const queryKeys = {
|
|||
priceHistory: (productId: number, days?: number) =>
|
||||
["price-history", productId, days] as const,
|
||||
comparison: (productId: number) => ["comparison", productId] as const,
|
||||
adminUnmatched: (params: {
|
||||
search?: string;
|
||||
store_id?: number;
|
||||
page?: number;
|
||||
}) => ["admin-unmatched", params] as const,
|
||||
adminStoreProducts: (productId: number) =>
|
||||
["admin-store-products", productId] as const,
|
||||
};
|
||||
|
||||
// Stale time config (mirrors Streamlit TTLs)
|
||||
|
|
@ -26,4 +33,6 @@ export const staleTimes = {
|
|||
comparison: 1 * 60 * 1000, // 1 min
|
||||
stores: 5 * 60 * 1000, // 5 min
|
||||
categories: 5 * 60 * 1000, // 5 min
|
||||
adminUnmatched: 30 * 1000, // 30 sec
|
||||
adminStoreProducts: 30 * 1000, // 30 sec
|
||||
};
|
||||
|
|
|
|||
|
|
@ -134,3 +134,60 @@ export interface SearchPriceResult {
|
|||
image_url: string | null;
|
||||
product_url: string | null;
|
||||
}
|
||||
|
||||
// ──────────────────────────── Admin ─────────────────────────────────────────
|
||||
|
||||
export interface AdminStoreProductOut {
|
||||
id: number;
|
||||
store: StoreOut;
|
||||
store_sku: string | null;
|
||||
store_name: string;
|
||||
store_url: string | null;
|
||||
is_active: boolean;
|
||||
latest_price: number | null;
|
||||
promo_price: number | null;
|
||||
}
|
||||
|
||||
export interface AdminProductOut {
|
||||
id: number;
|
||||
name: string;
|
||||
brand: string | null;
|
||||
ean: string | null;
|
||||
category: CategoryOut | null;
|
||||
unit: string | null;
|
||||
unit_size: number | null;
|
||||
image_url: string | null;
|
||||
store_product_count: number;
|
||||
store_products: AdminStoreProductOut[];
|
||||
}
|
||||
|
||||
export interface AdminProductListOut {
|
||||
items: AdminProductOut[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ProductUpdateIn {
|
||||
name?: string;
|
||||
brand?: string | null;
|
||||
ean?: string | null;
|
||||
unit?: string | null;
|
||||
unit_size?: number | null;
|
||||
image_url?: string | null;
|
||||
category_id?: number | null;
|
||||
}
|
||||
|
||||
export interface MergeProductsIn {
|
||||
product_ids: number[];
|
||||
target_id?: number;
|
||||
}
|
||||
|
||||
export interface MergeProductsOut {
|
||||
kept_product_id: number;
|
||||
merged_product_ids: number[];
|
||||
store_products_moved: number;
|
||||
}
|
||||
|
||||
export interface UnlinkOut {
|
||||
new_product_id: number;
|
||||
store_product_id: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from fastapi import FastAPI
|
|||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from src.api.routers import baskets, comparison, prices, products
|
||||
from src.api.routers import admin, baskets, comparison, prices, products
|
||||
|
||||
app = FastAPI(
|
||||
title="SmartCart API",
|
||||
|
|
@ -30,6 +30,7 @@ app.include_router(products.router)
|
|||
app.include_router(prices.router)
|
||||
app.include_router(comparison.router)
|
||||
app.include_router(baskets.router)
|
||||
app.include_router(admin.router)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
329
src/api/routers/admin.py
Normal file
329
src/api/routers/admin.py
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
"""Admin endpoints for product management: merge, edit, unlink."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from src.api.schemas import (
|
||||
AdminProductListOut,
|
||||
AdminProductOut,
|
||||
AdminStoreProductOut,
|
||||
MergeProductsIn,
|
||||
MergeProductsOut,
|
||||
ProductUpdateIn,
|
||||
UnlinkOut,
|
||||
)
|
||||
from src.core.database import get_session
|
||||
from src.core.models import Category, PriceRecord, Product, StoreProduct
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _build_admin_store_product(sp: StoreProduct) -> AdminStoreProductOut:
|
||||
"""Convert a StoreProduct ORM object (with eager-loaded relations) to schema."""
|
||||
latest = None
|
||||
promo = None
|
||||
if sp.price_records:
|
||||
rec = max(sp.price_records, key=lambda r: r.scraped_at)
|
||||
latest = rec.price
|
||||
promo = rec.promo_price
|
||||
return AdminStoreProductOut(
|
||||
id=sp.id,
|
||||
store=sp.store,
|
||||
store_sku=sp.store_sku,
|
||||
store_name=sp.store_name,
|
||||
store_url=sp.store_url,
|
||||
is_active=sp.is_active,
|
||||
latest_price=latest,
|
||||
promo_price=promo,
|
||||
)
|
||||
|
||||
|
||||
def _build_admin_product(
|
||||
product: Product, *, include_store_products: bool = True
|
||||
) -> AdminProductOut:
|
||||
sps = (
|
||||
[_build_admin_store_product(sp) for sp in product.store_products]
|
||||
if include_store_products
|
||||
else []
|
||||
)
|
||||
return AdminProductOut(
|
||||
id=product.id,
|
||||
name=product.name,
|
||||
brand=product.brand,
|
||||
ean=product.ean,
|
||||
category=product.category,
|
||||
unit=product.unit,
|
||||
unit_size=product.unit_size,
|
||||
image_url=product.image_url,
|
||||
store_product_count=len(product.store_products),
|
||||
store_products=sps,
|
||||
)
|
||||
|
||||
|
||||
# ── 1. GET /api/admin/unmatched ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/unmatched", response_model=AdminProductListOut)
|
||||
async def list_unmatched(
|
||||
search: str | None = Query(None),
|
||||
store_id: int | None = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(30, ge=1, le=100),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List singleton products (exactly 1 StoreProduct)."""
|
||||
# Subquery: product_ids with exactly 1 store_product
|
||||
singleton_sq = (
|
||||
select(StoreProduct.product_id)
|
||||
.group_by(StoreProduct.product_id)
|
||||
.having(func.count() == 1)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(Product)
|
||||
.join(singleton_sq, Product.id == singleton_sq.c.product_id)
|
||||
.options(
|
||||
selectinload(Product.category),
|
||||
selectinload(Product.store_products).selectinload(StoreProduct.store),
|
||||
selectinload(Product.store_products).selectinload(
|
||||
StoreProduct.price_records
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if search:
|
||||
stmt = stmt.where(Product.name.ilike(f"%{search}%"))
|
||||
|
||||
if store_id is not None:
|
||||
stmt = stmt.join(
|
||||
StoreProduct, StoreProduct.product_id == Product.id
|
||||
).where(StoreProduct.store_id == store_id)
|
||||
|
||||
# Total count
|
||||
count_stmt = select(func.count()).select_from(
|
||||
select(Product.id)
|
||||
.join(singleton_sq, Product.id == singleton_sq.c.product_id)
|
||||
.where(Product.name.ilike(f"%{search}%") if search else True)
|
||||
.subquery()
|
||||
)
|
||||
total = (await session.execute(count_stmt)).scalar_one()
|
||||
|
||||
# Pagination
|
||||
offset = (page - 1) * limit
|
||||
stmt = stmt.order_by(Product.name).offset(offset).limit(limit)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
products = list(result.scalars().unique().all())
|
||||
|
||||
return AdminProductListOut(
|
||||
items=[_build_admin_product(p) for p in products],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
# ── 2. GET /api/admin/products/{id}/store-products ───────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/products/{product_id}/store-products",
|
||||
response_model=list[AdminStoreProductOut],
|
||||
)
|
||||
async def list_store_products(
|
||||
product_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""List all StoreProducts for a given Product, with latest price."""
|
||||
stmt = (
|
||||
select(StoreProduct)
|
||||
.where(StoreProduct.product_id == product_id)
|
||||
.options(
|
||||
selectinload(StoreProduct.store),
|
||||
selectinload(StoreProduct.price_records),
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
sps = list(result.scalars().all())
|
||||
if not sps:
|
||||
raise HTTPException(404, "Product not found or has no store products")
|
||||
return [_build_admin_store_product(sp) for sp in sps]
|
||||
|
||||
|
||||
# ── 3. PATCH /api/admin/products/{id} ───────────────────────────────────────
|
||||
|
||||
|
||||
@router.patch("/products/{product_id}", response_model=AdminProductOut)
|
||||
async def update_product(
|
||||
product_id: int,
|
||||
body: ProductUpdateIn,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Update product metadata (PATCH semantics — only set fields are applied)."""
|
||||
stmt = (
|
||||
select(Product)
|
||||
.where(Product.id == product_id)
|
||||
.options(
|
||||
selectinload(Product.category),
|
||||
selectinload(Product.store_products).selectinload(StoreProduct.store),
|
||||
selectinload(Product.store_products).selectinload(
|
||||
StoreProduct.price_records
|
||||
),
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
product = result.scalar_one_or_none()
|
||||
if product is None:
|
||||
raise HTTPException(404, "Product not found")
|
||||
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
|
||||
# If category_id changed, validate it exists
|
||||
if "category_id" in update_data and update_data["category_id"] is not None:
|
||||
cat = await session.get(Category, update_data["category_id"])
|
||||
if cat is None:
|
||||
raise HTTPException(400, "Category not found")
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(product, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(product)
|
||||
|
||||
# Re-load relations after commit
|
||||
stmt2 = (
|
||||
select(Product)
|
||||
.where(Product.id == product_id)
|
||||
.options(
|
||||
selectinload(Product.category),
|
||||
selectinload(Product.store_products).selectinload(StoreProduct.store),
|
||||
selectinload(Product.store_products).selectinload(
|
||||
StoreProduct.price_records
|
||||
),
|
||||
)
|
||||
)
|
||||
result2 = await session.execute(stmt2)
|
||||
product = result2.scalar_one()
|
||||
|
||||
return _build_admin_product(product)
|
||||
|
||||
|
||||
# ── 4. POST /api/admin/products/merge ────────────────────────────────────────
|
||||
|
||||
|
||||
@router.post("/products/merge", response_model=MergeProductsOut)
|
||||
async def merge_products(
|
||||
body: MergeProductsIn,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Merge N products into 1. Re-points StoreProducts, enriches metadata, deletes losers."""
|
||||
if len(body.product_ids) < 2:
|
||||
raise HTTPException(400, "Need at least 2 product IDs to merge")
|
||||
|
||||
# Load all products
|
||||
stmt = (
|
||||
select(Product)
|
||||
.where(Product.id.in_(body.product_ids))
|
||||
.options(selectinload(Product.store_products))
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
products = list(result.scalars().unique().all())
|
||||
|
||||
found_ids = {p.id for p in products}
|
||||
missing = set(body.product_ids) - found_ids
|
||||
if missing:
|
||||
raise HTTPException(404, f"Products not found: {sorted(missing)}")
|
||||
|
||||
# Determine target
|
||||
if body.target_id is not None:
|
||||
if body.target_id not in found_ids:
|
||||
raise HTTPException(400, "target_id must be one of product_ids")
|
||||
target = next(p for p in products if p.id == body.target_id)
|
||||
else:
|
||||
# Pick the one with most store products
|
||||
target = max(products, key=lambda p: len(p.store_products))
|
||||
|
||||
losers = [p for p in products if p.id != target.id]
|
||||
|
||||
# Re-point store products from losers to target
|
||||
moved = 0
|
||||
for loser in losers:
|
||||
for sp in loser.store_products:
|
||||
sp.product_id = target.id
|
||||
moved += 1
|
||||
|
||||
# Enrich target metadata from losers
|
||||
for loser in losers:
|
||||
if not target.ean and loser.ean:
|
||||
target.ean = loser.ean
|
||||
if not target.brand and loser.brand:
|
||||
target.brand = loser.brand
|
||||
if not target.unit and loser.unit:
|
||||
target.unit = loser.unit
|
||||
if not target.unit_size and loser.unit_size:
|
||||
target.unit_size = loser.unit_size
|
||||
if not target.image_url and loser.image_url:
|
||||
target.image_url = loser.image_url
|
||||
if not target.category_id and loser.category_id:
|
||||
target.category_id = loser.category_id
|
||||
|
||||
await session.flush()
|
||||
|
||||
# Delete loser products
|
||||
loser_ids = [l.id for l in losers]
|
||||
await session.execute(delete(Product).where(Product.id.in_(loser_ids)))
|
||||
await session.commit()
|
||||
|
||||
return MergeProductsOut(
|
||||
kept_product_id=target.id,
|
||||
merged_product_ids=loser_ids,
|
||||
store_products_moved=moved,
|
||||
)
|
||||
|
||||
|
||||
# ── 5. POST /api/admin/products/{id}/unlink/{store_product_id} ──────────────
|
||||
|
||||
|
||||
@router.post(
|
||||
"/products/{product_id}/unlink/{store_product_id}",
|
||||
response_model=UnlinkOut,
|
||||
)
|
||||
async def unlink_store_product(
|
||||
product_id: int,
|
||||
store_product_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Unlink a StoreProduct from its Product, creating a new singleton Product."""
|
||||
# Load the store product
|
||||
sp = await session.get(StoreProduct, store_product_id)
|
||||
if sp is None or sp.product_id != product_id:
|
||||
raise HTTPException(404, "StoreProduct not found for this product")
|
||||
|
||||
# Count siblings
|
||||
count_stmt = select(func.count()).where(
|
||||
StoreProduct.product_id == product_id
|
||||
)
|
||||
sibling_count = (await session.execute(count_stmt)).scalar_one()
|
||||
|
||||
if sibling_count <= 1:
|
||||
raise HTTPException(400, "Cannot unlink the last StoreProduct from a product")
|
||||
|
||||
# Create a new singleton Product
|
||||
new_product = Product(name=sp.store_name)
|
||||
session.add(new_product)
|
||||
await session.flush() # get new_product.id
|
||||
|
||||
# Re-point the store product
|
||||
sp.product_id = new_product.id
|
||||
await session.commit()
|
||||
|
||||
return UnlinkOut(
|
||||
new_product_id=new_product.id,
|
||||
store_product_id=sp.id,
|
||||
)
|
||||
|
|
@ -142,3 +142,61 @@ class StatsOut(BaseModel):
|
|||
total_price_records: int
|
||||
last_scrape: datetime | None = None
|
||||
avg_prices_by_store: list[AvgPriceByStore]
|
||||
|
||||
|
||||
# ──────────────────────────── Admin ──────────────────────────────────────────
|
||||
|
||||
|
||||
class AdminStoreProductOut(BaseModel):
|
||||
id: int
|
||||
store: StoreOut
|
||||
store_sku: str | None = None
|
||||
store_name: str
|
||||
store_url: str | None = None
|
||||
is_active: bool
|
||||
latest_price: Decimal | None = None
|
||||
promo_price: Decimal | None = None
|
||||
|
||||
|
||||
class AdminProductOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
brand: str | None = None
|
||||
ean: str | None = None
|
||||
category: CategoryOut | None = None
|
||||
unit: str | None = None
|
||||
unit_size: Decimal | None = None
|
||||
image_url: str | None = None
|
||||
store_product_count: int
|
||||
store_products: list[AdminStoreProductOut] = []
|
||||
|
||||
|
||||
class AdminProductListOut(BaseModel):
|
||||
items: list[AdminProductOut]
|
||||
total: int
|
||||
|
||||
|
||||
class ProductUpdateIn(BaseModel):
|
||||
name: str | None = None
|
||||
brand: str | None = None
|
||||
ean: str | None = None
|
||||
unit: str | None = None
|
||||
unit_size: Decimal | None = None
|
||||
image_url: str | None = None
|
||||
category_id: int | None = None
|
||||
|
||||
|
||||
class MergeProductsIn(BaseModel):
|
||||
product_ids: list[int]
|
||||
target_id: int | None = None
|
||||
|
||||
|
||||
class MergeProductsOut(BaseModel):
|
||||
kept_product_id: int
|
||||
merged_product_ids: list[int]
|
||||
store_products_moved: int
|
||||
|
||||
|
||||
class UnlinkOut(BaseModel):
|
||||
new_product_id: int
|
||||
store_product_id: int
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue