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:
authentik Default Admin 2026-02-11 20:34:56 +00:00
parent 20f7c76cdf
commit ca5a2712b6
16 changed files with 1636 additions and 1 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 &quot;{productName}&quot;
</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 &quot;{sp.store_name}&quot; ({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>
);
}

View 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>
);
}

View file

@ -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() {

View 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,
}

View 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 }

View 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,
}

View 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 }

View file

@ -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" }
);
}

View file

@ -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
};

View file

@ -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;
}

View file

@ -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
View 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,
)

View file

@ -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