From 2adad11dc51b07a52dd7d3ecdc50d33ee9e15c15 Mon Sep 17 00:00:00 2001 From: phoebus-84 Date: Fri, 17 Apr 2026 10:14:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(dpp):=20=E2=9C=A8=20redesign=20DPP=20tab?= =?UTF-8?q?=20to=20match=20Figma=20prototype?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add KPI counter cards (total/active/drafts/archived) with backend facets - Add status filter dropdown and expanded sort options - Redesign table with Product ID, DPP ID, Batch/Unit, Status, Created columns - Add QR code download button and More Actions overflow menu - Add pagination footer showing count of displayed DPPs - Update API types for facets, search, sort params - Wire frontend to new backend search/sort/facets/QR endpoints --- components/ProfilePageNew.tsx | 478 +++++++++++++++++++++++++++++----- lib/dpp-types.ts | 10 + lib/dpp.ts | 10 + 3 files changed, 431 insertions(+), 67 deletions(-) diff --git a/components/ProfilePageNew.tsx b/components/ProfilePageNew.tsx index aaddffe5..db13d9bf 100644 --- a/components/ProfilePageNew.tsx +++ b/components/ProfilePageNew.tsx @@ -2,7 +2,19 @@ // Copyright (C) 2022-2023 Dyne.org foundation . import { useQuery } from "@apollo/client"; -import { Add, ArrowRight, Search, SortAscending } from "@carbon/icons-react"; +import { + Add, + ArrowRight, + Calendar, + ChevronDown, + ChevronLeft, + ChevronRight, + Download, + Information, + OverflowMenuVertical, + Search, + SortAscending, +} from "@carbon/icons-react"; import { ExternalLinkIcon, LocationMarkerIcon } from "@heroicons/react/outline"; import BrUserAvatar from "components/brickroom/BrUserAvatar"; import EntityTypeIcon from "components/EntityTypeIcon"; @@ -13,7 +25,7 @@ import { useAuth } from "hooks/useAuth"; import useFilters from "hooks/useFilters"; import useLoadMore from "hooks/useLoadMore"; import useDppApi from "lib/dpp"; -import type { DppDocument, ListDppsResponse } from "lib/dpp-types"; +import type { DppDocument, DppStatus, ListDppsResponse, StatusFacets } from "lib/dpp-types"; import { FETCH_RESOURCES } from "lib/QueryAndMutation"; import { FetchInventoryQuery } from "lib/types"; import { useTranslation } from "next-i18next"; @@ -74,10 +86,10 @@ const tabCtaConfig: Record, TabCtaConfig> = { searchPlaceholder: "Search services by name, tags...", }, dpps: { - ctaTitle: "Create Digital Product Passports", + ctaTitle: "Publish DPPs for your products", ctaDescription: - "Document the lifecycle, materials, and sustainability information of your products. Help consumers and regulators access transparent product data.", - createLabel: "Create a new DPP", + "With a Digital Product Passport (DPP) each product gets a unique QR code for easy tracking and verification.", + createLabel: "Publish a New DPP", createUrl: "/dpps/new", searchPlaceholder: "Search DPPs by batch ID, status...", }, @@ -335,14 +347,17 @@ function ProfileTabContent({ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwner: boolean; ctaConfig: TabCtaConfig }) { const { t } = useTranslation("common"); - const router = useRouter(); const dppApi = useDppApi(); const [dpps, setDpps] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(""); - const [sortBy, setSortBy] = useState("latest"); + const [sortBy, setSortBy] = useState<"latest" | "oldest" | "az" | "za">("latest"); + const [statusFilter, setStatusFilter] = useState<"all" | "active" | "draft" | "archived">("all"); const [showSortMenu, setShowSortMenu] = useState(false); + const [showFilterMenu, setShowFilterMenu] = useState(false); + const [showActionsMenu, setShowActionsMenu] = useState(null); + const [facets, setFacets] = useState({ active: 0, draft: 0, archived: 0 }); useEffect(() => { let cancelled = false; @@ -353,6 +368,7 @@ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwne if (!cancelled) { setDpps(res.dpps || []); setTotal(res.total || 0); + if (res.facets) setFacets(res.facets); } }) .catch((err: Error) => { @@ -370,12 +386,49 @@ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwne }; }, [userId]); // eslint-disable-line react-hooks/exhaustive-deps + // Close menus on outside click + useEffect(() => { + const handler = () => { + setShowSortMenu(false); + setShowFilterMenu(false); + setShowActionsMenu(null); + }; + document.addEventListener("click", handler); + return () => document.removeEventListener("click", handler); + }, []); + + // KPI counters: use backend facets when available, client-side fallback + const statusCounts = useMemo(() => { + if (facets.active + facets.draft + facets.archived > 0) { + return { + total: facets.active + facets.draft + facets.archived, + ...facets, + }; + } + const counts = { total: dpps.length, active: 0, draft: 0, archived: 0 }; + for (const d of dpps) { + if (d.status === "active") counts.active++; + else if (d.status === "draft") counts.draft++; + else if (d.status === "archived") counts.archived++; + } + return counts; + }, [dpps, facets]); + const filteredDpps = useMemo(() => { let items = dpps; + + // Status filter + if (statusFilter !== "all") { + items = items.filter(d => d.status === statusFilter); + } + + // Text search if (searchQuery) { const q = searchQuery.toLowerCase(); items = items.filter( d => + d.id?.toLowerCase().includes(q) || + d.productId?.toLowerCase().includes(q) || d.batchId?.toLowerCase().includes(q) || d.status?.toLowerCase().includes(q) || d.batchType?.toLowerCase().includes(q) || @@ -383,9 +436,30 @@ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwne d.productOverview?.brandName?.value?.toLowerCase().includes(q) ); } - if (sortBy === "oldest") items = [...items].reverse(); + + // Sort + items = [...items]; + switch (sortBy) { + case "oldest": + items.reverse(); + break; + case "az": + items.sort((a, b) => { + const na = a.productOverview?.productName?.value || ""; + const nb = b.productOverview?.productName?.value || ""; + return na.localeCompare(nb); + }); + break; + case "za": + items.sort((a, b) => { + const na = a.productOverview?.productName?.value || ""; + const nb = b.productOverview?.productName?.value || ""; + return nb.localeCompare(na); + }); + break; + } return items; - }, [dpps, searchQuery, sortBy]); + }, [dpps, searchQuery, sortBy, statusFilter]); const statusColors: Record = { active: { bg: "var(--ifr-green)", text: "#fff" }, @@ -393,11 +467,36 @@ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwne archived: { bg: "var(--ifr-yellow)", text: "var(--ifr-text-primary)" }, }; + const sortLabels: Record = { + latest: "Latest", + oldest: "Oldest", + az: "A–Z", + za: "Z–A", + }; + + const filterLabels: Record = { + all: "All", + active: "Active", + draft: "Drafts", + archived: "Archived", + }; + + const handleStatusChange = async (dppId: string, newStatus: DppStatus) => { + try { + await dppApi.updateDppStatus(dppId, newStatus); + setDpps(prev => prev.map(d => (d.id === dppId ? { ...d, status: newStatus } : d))); + } catch (err) { + console.error("Failed to update DPP status:", err); + } + setShowActionsMenu(null); + }; + return (
- {/* CTA + Stats row (owner only) */} + {/* CTA + KPI Stats row (owner only) */} {isOwner && (
+ {/* CTA left */}

+ {t(ctaConfig.createLabel)} - +

+ + {/* KPI Stats right */} +
+ {( + [ + { label: "Total DPPs", value: statusCounts.total }, + { label: "Active", value: statusCounts.active }, + { label: "Drafts", value: statusCounts.draft }, + { label: "Archived", value: statusCounts.archived }, + ] as const + ).map(kpi => ( +
+ + {t(kpi.label)} + + + {kpi.value} + +
+ ))} +
)} - {/* Search & Sort toolbar */} + {/* Search & Sort & Filter toolbar */}
setSearchQuery(e.target.value)} - placeholder={t(ctaConfig.searchPlaceholder)} + placeholder={t("Search by product name, DPP ID, or project ID...")} className="flex-1 bg-transparent border-none outline-none text-ifr-text-primary" style={{ fontFamily: "var(--ifr-font-body)", fontSize: "var(--ifr-fs-base)" }} />
+ {/* Sort dropdown */}
{showSortMenu && (
- {["latest", "oldest"].map(opt => ( + {(["latest", "oldest", "az", "za"] as const).map(opt => ( + ))} +
+ )} +
+ + {/* Status filter dropdown */} +
+ + {showFilterMenu && ( +
+ {(["all", "active", "draft", "archived"] as const).map(opt => ( + ))}
@@ -523,7 +738,7 @@ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwne }} > - {t(ctaConfig.createLabel)} + {t("Create New DPP")} )} @@ -552,68 +767,65 @@ function DppsTabContent({ userId, isOwner, ctaConfig }: { userId: string; isOwne
- {t("Product")} - {t("Batch / Serial")} - {t("Type")} + {t("Product ID")} + {t("DPP ID")} + {t("Batch / Unit")} {t("Status")} {t("Created")} - {t("Action")} + {t("QR code")} +
{/* Table rows */} {filteredDpps.map(dpp => { const productName = dpp.productOverview?.productName?.value || dpp.productOverview?.brandName?.value || t("Untitled DPP"); + const productId = dpp.productId || "—"; const status = dpp.status || "draft"; const colors = statusColors[status] || statusColors.draft; const dppUrl = `/dpps/${encodeURIComponent(dpp.id)}`; + const dppDisplayId = dpp.id.length > 12 ? `${dpp.id.slice(0, 12)}…` : dpp.id; return (
router.push(dppUrl)} - onKeyDown={event => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - router.push(dppUrl); - } - }} style={{ - gridTemplateColumns: "2fr 1fr 100px 100px 140px 90px", + gridTemplateColumns: "2fr 110px 1fr 90px 140px 70px 50px", fontFamily: "var(--ifr-font-body)", fontSize: "var(--ifr-fs-base)", }} > - - - {productName} - - - {dpp.batchId || "—"} - - - {dpp.batchType === "unit" ? t("Unit") : t("Batch")} + {/* Product ID + name */} +
+ + {productId} + + + {productName} + + +
+ + {/* DPP ID */} + + {dppDisplayId} + + {/* Batch / Unit */} + {dpp.batchId || "—"} + + {/* Status */} - - {dpp.createdAt ? new Date(dpp.createdAt).toLocaleDateString() : "—"} + + {/* Created */} + + + {dpp.createdAt + ? new Date(dpp.createdAt).toLocaleDateString("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }) + : "—"} - - e.stopPropagation()} + className="inline-flex items-center justify-center bg-transparent border-none cursor-pointer hover:opacity-70 transition-opacity no-underline" + style={{ width: 32, height: 32, color: "var(--ifr-text-secondary)" }} + > + + + + {/* More actions */} +
+ + {showActionsMenu === dpp.id && ( +
+ + + {t("View")} + + + {isOwner && status === "draft" && ( + + )} + {isOwner && (status === "draft" || status === "active") && ( + + )} + {isOwner && status === "archived" && ( + + )} +
+ )} +
); })} + + {/* Pagination footer */} +
+ + {t("Showing {{count}} of {{total}} DPPs", { + count: filteredDpps.length, + total: total, + })} + +
+ + +
+
)}
diff --git a/lib/dpp-types.ts b/lib/dpp-types.ts index 3aea8ae2..005c2de2 100644 --- a/lib/dpp-types.ts +++ b/lib/dpp-types.ts @@ -207,13 +207,23 @@ export type ListDppsFilters = { productId?: string; createdBy?: string; status?: DppStatus; + q?: string; + sortBy?: "createdAt" | "name"; + sortOrder?: "asc" | "desc"; limit?: number; offset?: number; }; +export type StatusFacets = { + active: number; + draft: number; + archived: number; +}; + export type ListDppsResponse = { dpps: DppDocument[]; total: number; + facets?: StatusFacets; }; export type DppApiError = { diff --git a/lib/dpp.ts b/lib/dpp.ts index b5b2889c..577acf7e 100644 --- a/lib/dpp.ts +++ b/lib/dpp.ts @@ -133,6 +133,9 @@ const useDppApi = () => { if (filters?.productId) params.set("productId", filters.productId); if (filters?.createdBy) params.set("createdBy", filters.createdBy); if (filters?.status) params.set("status", filters.status); + if (filters?.q) params.set("q", filters.q); + if (filters?.sortBy) params.set("sortBy", filters.sortBy); + if (filters?.sortOrder) params.set("sortOrder", filters.sortOrder); if (filters?.limit != null) params.set("limit", String(filters.limit)); if (filters?.offset != null) params.set("offset", String(filters.offset)); @@ -189,6 +192,11 @@ const useDppApi = () => { return `${DPP_BASE_URL}/file/${encodeURIComponent(id)}`; }, []); + const getQrCodeUrl = useCallback((dppId: string, size?: number): string => { + const params = size ? `?size=${size}` : ""; + return `${DPP_BASE_URL}/dpp/${encodeURIComponent(dppId)}/qr${params}`; + }, []); + const updateDppStatus = useCallback( async (id: string, status: DppStatus): Promise => { return request("PUT", `/dpp/${encodeURIComponent(id)}/status`, { status }); @@ -257,6 +265,7 @@ const useDppApi = () => { listDpps, uploadFile, getFileUrl, + getQrCodeUrl, updateDppStatus, addAttachment, deleteAttachment, @@ -269,6 +278,7 @@ const useDppApi = () => { listDpps, uploadFile, getFileUrl, + getQrCodeUrl, updateDppStatus, addAttachment, deleteAttachment,