Add HTS lookup page
This commit is contained in:
170
inventory-server/src/routes/hts-lookup.js
Normal file
170
inventory-server/src/routes/hts-lookup.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// GET /api/hts-lookup?search=term
|
||||||
|
// Finds matching products and groups them by harmonized tariff code
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const searchTerm = typeof req.query.search === 'string' ? req.query.search.trim() : '';
|
||||||
|
|
||||||
|
if (!searchTerm) {
|
||||||
|
return res.status(400).json({ error: 'Search term is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
const likeTerm = `%${searchTerm}%`;
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`
|
||||||
|
WITH matched_products AS (
|
||||||
|
SELECT
|
||||||
|
pid,
|
||||||
|
title,
|
||||||
|
sku,
|
||||||
|
barcode,
|
||||||
|
brand,
|
||||||
|
vendor,
|
||||||
|
harmonized_tariff_code,
|
||||||
|
NULLIF(
|
||||||
|
LOWER(
|
||||||
|
REGEXP_REPLACE(
|
||||||
|
COALESCE(NULLIF(TRIM(harmonized_tariff_code), ''), ''),
|
||||||
|
'[^0-9A-Za-z]',
|
||||||
|
'',
|
||||||
|
'g'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
''
|
||||||
|
) AS normalized_code
|
||||||
|
FROM products
|
||||||
|
WHERE visible = TRUE
|
||||||
|
AND (
|
||||||
|
title ILIKE $1
|
||||||
|
OR sku ILIKE $1
|
||||||
|
OR barcode ILIKE $1
|
||||||
|
OR vendor ILIKE $1
|
||||||
|
OR brand ILIKE $1
|
||||||
|
OR vendor_reference ILIKE $1
|
||||||
|
OR harmonized_tariff_code ILIKE $1
|
||||||
|
)
|
||||||
|
),
|
||||||
|
grouped AS (
|
||||||
|
SELECT
|
||||||
|
COALESCE(NULLIF(TRIM(harmonized_tariff_code), ''), 'Unspecified') AS harmonized_tariff_code,
|
||||||
|
normalized_code,
|
||||||
|
COUNT(*)::INT AS product_count,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'pid', pid,
|
||||||
|
'title', title,
|
||||||
|
'sku', sku,
|
||||||
|
'barcode', barcode,
|
||||||
|
'brand', brand,
|
||||||
|
'vendor', vendor
|
||||||
|
)
|
||||||
|
ORDER BY title
|
||||||
|
) AS products
|
||||||
|
FROM matched_products
|
||||||
|
GROUP BY
|
||||||
|
COALESCE(NULLIF(TRIM(harmonized_tariff_code), ''), 'Unspecified'),
|
||||||
|
normalized_code
|
||||||
|
),
|
||||||
|
hts_lookup AS (
|
||||||
|
SELECT
|
||||||
|
h."HTS Number" AS hts_number,
|
||||||
|
h."Indent" AS indent,
|
||||||
|
h."Description" AS description,
|
||||||
|
h."Unit of Quantity" AS unit_of_quantity,
|
||||||
|
h."General Rate of Duty" AS general_rate_of_duty,
|
||||||
|
h."Special Rate of Duty" AS special_rate_of_duty,
|
||||||
|
h."Column 2 Rate of Duty" AS column2_rate_of_duty,
|
||||||
|
h."Quota Quantity" AS quota_quantity,
|
||||||
|
h."Additional Duties" AS additional_duties,
|
||||||
|
NULLIF(
|
||||||
|
LOWER(
|
||||||
|
REGEXP_REPLACE(
|
||||||
|
COALESCE(h."HTS Number", ''),
|
||||||
|
'[^0-9A-Za-z]',
|
||||||
|
'',
|
||||||
|
'g'
|
||||||
|
)
|
||||||
|
),
|
||||||
|
''
|
||||||
|
) AS normalized_hts_number
|
||||||
|
FROM htsdata h
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
g.harmonized_tariff_code,
|
||||||
|
g.product_count,
|
||||||
|
g.products,
|
||||||
|
hts.hts_details
|
||||||
|
FROM grouped g
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'hts_number', h.hts_number,
|
||||||
|
'indent', h.indent,
|
||||||
|
'description', h.description,
|
||||||
|
'unit_of_quantity', h.unit_of_quantity,
|
||||||
|
'general_rate_of_duty', h.general_rate_of_duty,
|
||||||
|
'special_rate_of_duty', h.special_rate_of_duty,
|
||||||
|
'column2_rate_of_duty', h.column2_rate_of_duty,
|
||||||
|
'quota_quantity', h.quota_quantity,
|
||||||
|
'additional_duties', h.additional_duties
|
||||||
|
)
|
||||||
|
ORDER BY LENGTH(COALESCE(h.normalized_hts_number, '')) ASC NULLS LAST,
|
||||||
|
NULLIF(h.indent, '')::INT NULLS LAST
|
||||||
|
) AS hts_details
|
||||||
|
FROM hts_lookup h
|
||||||
|
WHERE COALESCE(g.normalized_code, '') <> ''
|
||||||
|
AND COALESCE(h.normalized_hts_number, '') <> ''
|
||||||
|
AND (
|
||||||
|
g.normalized_code LIKE h.normalized_hts_number || '%'
|
||||||
|
OR h.normalized_hts_number LIKE g.normalized_code || '%'
|
||||||
|
)
|
||||||
|
) hts ON TRUE
|
||||||
|
ORDER BY g.product_count DESC, g.harmonized_tariff_code ASC
|
||||||
|
`,
|
||||||
|
[likeTerm]
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalMatches = rows.reduce((sum, row) => sum + (parseInt(row.product_count, 10) || 0), 0);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
search: searchTerm,
|
||||||
|
total: totalMatches,
|
||||||
|
results: rows.map(row => ({
|
||||||
|
harmonized_tariff_code: row.harmonized_tariff_code,
|
||||||
|
product_count: parseInt(row.product_count, 10) || 0,
|
||||||
|
hts_details: Array.isArray(row.hts_details)
|
||||||
|
? row.hts_details.map(detail => ({
|
||||||
|
hts_number: detail.hts_number,
|
||||||
|
indent: detail.indent,
|
||||||
|
description: detail.description,
|
||||||
|
unit_of_quantity: detail.unit_of_quantity,
|
||||||
|
general_rate_of_duty: detail.general_rate_of_duty,
|
||||||
|
special_rate_of_duty: detail.special_rate_of_duty,
|
||||||
|
column2_rate_of_duty: detail.column2_rate_of_duty,
|
||||||
|
quota_quantity: detail.quota_quantity,
|
||||||
|
additional_duties: detail.additional_duties
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
products: Array.isArray(row.products)
|
||||||
|
? row.products.map(product => ({
|
||||||
|
pid: product.pid,
|
||||||
|
title: product.title,
|
||||||
|
sku: product.sku,
|
||||||
|
barcode: product.barcode,
|
||||||
|
brand: product.brand,
|
||||||
|
vendor: product.vendor
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error performing HTS lookup:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to lookup HTS codes' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -21,6 +21,7 @@ const reusableImagesRouter = require('./routes/reusable-images');
|
|||||||
const categoriesAggregateRouter = require('./routes/categoriesAggregate');
|
const categoriesAggregateRouter = require('./routes/categoriesAggregate');
|
||||||
const vendorsAggregateRouter = require('./routes/vendorsAggregate');
|
const vendorsAggregateRouter = require('./routes/vendorsAggregate');
|
||||||
const brandsAggregateRouter = require('./routes/brandsAggregate');
|
const brandsAggregateRouter = require('./routes/brandsAggregate');
|
||||||
|
const htsLookupRouter = require('./routes/hts-lookup');
|
||||||
|
|
||||||
// Get the absolute path to the .env file
|
// Get the absolute path to the .env file
|
||||||
const envPath = '/var/www/html/inventory/.env';
|
const envPath = '/var/www/html/inventory/.env';
|
||||||
@@ -126,6 +127,7 @@ async function startServer() {
|
|||||||
app.use('/api/templates', templatesRouter);
|
app.use('/api/templates', templatesRouter);
|
||||||
app.use('/api/ai-prompts', aiPromptsRouter);
|
app.use('/api/ai-prompts', aiPromptsRouter);
|
||||||
app.use('/api/reusable-images', reusableImagesRouter);
|
app.use('/api/reusable-images', reusableImagesRouter);
|
||||||
|
app.use('/api/hts-lookup', htsLookupRouter);
|
||||||
|
|
||||||
// Basic health check route
|
// Basic health check route
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const Products = lazy(() => import('./pages/Products').then(module => ({ default
|
|||||||
const Analytics = lazy(() => import('./pages/Analytics').then(module => ({ default: module.Analytics })));
|
const Analytics = lazy(() => import('./pages/Analytics').then(module => ({ default: module.Analytics })));
|
||||||
const Forecasting = lazy(() => import('./pages/Forecasting'));
|
const Forecasting = lazy(() => import('./pages/Forecasting'));
|
||||||
const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator'));
|
const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator'));
|
||||||
|
const HtsLookup = lazy(() => import('./pages/HtsLookup'));
|
||||||
const Vendors = lazy(() => import('./pages/Vendors'));
|
const Vendors = lazy(() => import('./pages/Vendors'));
|
||||||
const Categories = lazy(() => import('./pages/Categories'));
|
const Categories = lazy(() => import('./pages/Categories'));
|
||||||
const Brands = lazy(() => import('./pages/Brands'));
|
const Brands = lazy(() => import('./pages/Brands'));
|
||||||
@@ -161,6 +162,13 @@ function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Protected>
|
</Protected>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/hts-lookup" element={
|
||||||
|
<Protected page="hts_lookup">
|
||||||
|
<Suspense fallback={<PageLoading />}>
|
||||||
|
<HtsLookup />
|
||||||
|
</Suspense>
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
<Route path="/forecasting" element={
|
<Route path="/forecasting" element={
|
||||||
<Protected page="forecasting">
|
<Protected page="forecasting">
|
||||||
<Suspense fallback={<PageLoading />}>
|
<Suspense fallback={<PageLoading />}>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const PAGES = [
|
|||||||
{ path: "/purchase-orders", permission: "access:purchase_orders" },
|
{ path: "/purchase-orders", permission: "access:purchase_orders" },
|
||||||
{ path: "/analytics", permission: "access:analytics" },
|
{ path: "/analytics", permission: "access:analytics" },
|
||||||
{ path: "/discount-simulator", permission: "access:discount_simulator" },
|
{ path: "/discount-simulator", permission: "access:discount_simulator" },
|
||||||
|
{ path: "/hts-lookup", permission: "access:hts_lookup" },
|
||||||
{ path: "/forecasting", permission: "access:forecasting" },
|
{ path: "/forecasting", permission: "access:forecasting" },
|
||||||
{ path: "/import", permission: "access:import" },
|
{ path: "/import", permission: "access:import" },
|
||||||
{ path: "/settings", permission: "access:settings" },
|
{ path: "/settings", permission: "access:settings" },
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ Admin users automatically have all permissions.
|
|||||||
| `access:purchase_orders` | Access to Purchase Orders page |
|
| `access:purchase_orders` | Access to Purchase Orders page |
|
||||||
| `access:analytics` | Access to Analytics page |
|
| `access:analytics` | Access to Analytics page |
|
||||||
| `access:discount_simulator` | Access to Discount Simulator page |
|
| `access:discount_simulator` | Access to Discount Simulator page |
|
||||||
|
| `access:hts_lookup` | Access to HTS Lookup page |
|
||||||
| `access:forecasting` | Access to Forecasting page |
|
| `access:forecasting` | Access to Forecasting page |
|
||||||
| `access:import` | Access to Import page |
|
| `access:import` | Access to Import page |
|
||||||
| `access:settings` | Access to Settings page |
|
| `access:settings` | Access to Settings page |
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
MessageCircle,
|
MessageCircle,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Percent,
|
Percent,
|
||||||
|
FileSearch,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IconCrystalBall } from "@tabler/icons-react";
|
import { IconCrystalBall } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
@@ -94,6 +95,12 @@ const toolsItems = [
|
|||||||
url: "/discount-simulator",
|
url: "/discount-simulator",
|
||||||
permission: "access:discount_simulator"
|
permission: "access:discount_simulator"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "HTS Lookup",
|
||||||
|
icon: FileSearch,
|
||||||
|
url: "/hts-lookup",
|
||||||
|
permission: "access:hts_lookup"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Forecasting",
|
title: "Forecasting",
|
||||||
icon: IconCrystalBall,
|
icon: IconCrystalBall,
|
||||||
|
|||||||
340
inventory/src/pages/HtsLookup.tsx
Normal file
340
inventory/src/pages/HtsLookup.tsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState, type FormEvent, type MouseEvent } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Search, Loader2, PackageOpen, Copy, Check } from "lucide-react";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type HtsProduct = {
|
||||||
|
pid: number;
|
||||||
|
title: string;
|
||||||
|
sku: string;
|
||||||
|
brand?: string | null;
|
||||||
|
vendor?: string | null;
|
||||||
|
barcode?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HtsDetail = {
|
||||||
|
hts_number: string | null;
|
||||||
|
indent?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
unit_of_quantity?: string | null;
|
||||||
|
general_rate_of_duty?: string | null;
|
||||||
|
special_rate_of_duty?: string | null;
|
||||||
|
column2_rate_of_duty?: string | null;
|
||||||
|
quota_quantity?: string | null;
|
||||||
|
additional_duties?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HtsGroup = {
|
||||||
|
harmonized_tariff_code: string;
|
||||||
|
product_count: number;
|
||||||
|
products: HtsProduct[];
|
||||||
|
hts_details?: HtsDetail[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HtsLookupResponse = {
|
||||||
|
search: string;
|
||||||
|
total: number;
|
||||||
|
results: HtsGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HtsLookup() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [submittedTerm, setSubmittedTerm] = useState("");
|
||||||
|
const [copiedCode, setCopiedCode] = useState<string | null>(null);
|
||||||
|
const copyTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
isFetching,
|
||||||
|
isFetched,
|
||||||
|
refetch,
|
||||||
|
} = useQuery<HtsLookupResponse>({
|
||||||
|
queryKey: ["hts-lookup", submittedTerm],
|
||||||
|
enabled: false,
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams({ search: submittedTerm });
|
||||||
|
const response = await fetch(`/api/hts-lookup?${params.toString()}`);
|
||||||
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = typeof payload.error === "string" ? payload.error : "Failed to fetch HTS data";
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as HtsLookupResponse;
|
||||||
|
},
|
||||||
|
staleTime: 2 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (submittedTerm) {
|
||||||
|
void refetch();
|
||||||
|
}
|
||||||
|
}, [submittedTerm, refetch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (copyTimerRef.current) {
|
||||||
|
window.clearTimeout(copyTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
toast({
|
||||||
|
title: "Search failed",
|
||||||
|
description: error.message,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [error, toast]);
|
||||||
|
|
||||||
|
const totalMatches = data?.total ?? 0;
|
||||||
|
const groupedResults = useMemo(() => data?.results ?? [], [data]);
|
||||||
|
|
||||||
|
const handleCopyClick = async (event: MouseEvent<HTMLButtonElement>, code: string) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const valueToCopy = code === "Unspecified" ? "" : code;
|
||||||
|
|
||||||
|
if (!navigator?.clipboard) {
|
||||||
|
toast({
|
||||||
|
title: "Clipboard unavailable",
|
||||||
|
description: "Your browser did not allow copying the code.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(valueToCopy);
|
||||||
|
if (copyTimerRef.current) {
|
||||||
|
window.clearTimeout(copyTimerRef.current);
|
||||||
|
}
|
||||||
|
setCopiedCode(code);
|
||||||
|
copyTimerRef.current = window.setTimeout(() => setCopiedCode(null), 1200);
|
||||||
|
toast({
|
||||||
|
title: "Copied HTS code",
|
||||||
|
description: valueToCopy || "Empty code copied",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
toast({
|
||||||
|
title: "Copy failed",
|
||||||
|
description: err instanceof Error ? err.message : "Unable to copy code",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (event?: FormEvent) => {
|
||||||
|
event?.preventDefault();
|
||||||
|
const trimmed = searchTerm.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
toast({
|
||||||
|
title: "Enter a search term",
|
||||||
|
description: "Search by title, SKU, vendor, barcode, or HTS code.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === submittedTerm) {
|
||||||
|
void refetch();
|
||||||
|
} else {
|
||||||
|
setSubmittedTerm(trimmed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSummary = () => {
|
||||||
|
if (!isFetched || !data) return null;
|
||||||
|
|
||||||
|
if (!groupedResults.length) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="py-8 text-center text-muted-foreground space-y-3">
|
||||||
|
<PackageOpen className="mx-auto h-10 w-10" />
|
||||||
|
<div>No products found for "{data.search}".</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Search term</CardDescription>
|
||||||
|
<CardTitle className="text-lg break-all">{data.search}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Matched products</CardDescription>
|
||||||
|
<CardTitle className="text-3xl">{totalMatches}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardDescription>Unique HTS codes</CardDescription>
|
||||||
|
<CardTitle className="text-3xl">{groupedResults.length}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold">HTS Lookup</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Search products</CardTitle>
|
||||||
|
<CardDescription>Search by product title, item number, company name, UPC, or HTS code.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSearch} className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||||
|
<Input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className="md:max-w-xl"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="submit" disabled={isFetching}>
|
||||||
|
{isFetching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||||
|
<span className="ml-2">Search</span>
|
||||||
|
</Button>
|
||||||
|
{isFetched && (
|
||||||
|
<Button type="button" variant="outline" onClick={() => setSearchTerm("")} disabled={isFetching}>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{renderSummary()}
|
||||||
|
|
||||||
|
{groupedResults.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>HTS codes by frequency</CardTitle>
|
||||||
|
<CardDescription>Most-used codes appear first. Expand a code to see the matching products.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
{groupedResults.map((group) => {
|
||||||
|
const percentage = totalMatches > 0 ? Math.round((group.product_count / totalMatches) * 100) : 0;
|
||||||
|
const codeLabel = group.harmonized_tariff_code === "Unspecified" ? "Not set" : group.harmonized_tariff_code;
|
||||||
|
const htsDetails = group.hts_details || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AccordionItem key={group.harmonized_tariff_code} value={group.harmonized_tariff_code}>
|
||||||
|
<AccordionTrigger className="hover:no-underline">
|
||||||
|
<div className="flex flex-1 items-center justify-between gap-4">
|
||||||
|
<div className="flex-1 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant={copiedCode === group.harmonized_tariff_code ? "secondary" : "ghost"}
|
||||||
|
aria-label={`Copy HTS code ${codeLabel}`}
|
||||||
|
onClick={(event) => handleCopyClick(event, group.harmonized_tariff_code)}
|
||||||
|
className="mr-1"
|
||||||
|
>
|
||||||
|
{copiedCode === group.harmonized_tariff_code ? (
|
||||||
|
<Check className="h-4 w-4 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Badge variant={group.harmonized_tariff_code === "Unspecified" ? "secondary" : "outline"}>
|
||||||
|
{codeLabel}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{group.product_count} product{group.product_count === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{htsDetails.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
|
{htsDetails.map((detail, idx) => (
|
||||||
|
<div
|
||||||
|
key={`${detail.hts_number || "unknown"}-${idx}`}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
style={{ paddingLeft: Math.max(0, (Number(detail.indent) || 0) * 10) }}
|
||||||
|
>
|
||||||
|
<Badge variant="outline" className="bg-muted/60">
|
||||||
|
{detail.hts_number || "—"}
|
||||||
|
</Badge>
|
||||||
|
<span className="leading-tight">{detail.description || "No description"}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-muted-foreground italic">No HTS reference found</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">{percentage}% of matches</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[45%]">Product</TableHead>
|
||||||
|
<TableHead>SKU</TableHead>
|
||||||
|
<TableHead>Brand</TableHead>
|
||||||
|
<TableHead>Vendor</TableHead>
|
||||||
|
<TableHead>Barcode</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{group.products.map((product) => (
|
||||||
|
<TableRow key={product.pid}>
|
||||||
|
<TableCell>
|
||||||
|
<a
|
||||||
|
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="font-medium text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{product.title}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-nowrap">{product.sku}</TableCell>
|
||||||
|
<TableCell>{product.brand || "—"}</TableCell>
|
||||||
|
<TableCell>{product.vendor || "—"}</TableCell>
|
||||||
|
<TableCell className={cn("text-muted-foreground", !product.barcode && "italic")}>
|
||||||
|
{product.barcode || "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Accordion>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user