From 5cd864318f4480da1c25f0fa2e5ff62d22169a2c Mon Sep 17 00:00:00 2001 From: Jurgis Sakalauskas Date: Tue, 14 Oct 2025 13:55:53 +0300 Subject: [PATCH] price edit below the item --- .../project-detail/project-detail-page.tsx | 31 +- .../project-detail/project-items-section.tsx | 313 +++++++++--------- 2 files changed, 162 insertions(+), 182 deletions(-) diff --git a/client/src/pages/project-detail/project-detail-page.tsx b/client/src/pages/project-detail/project-detail-page.tsx index 41f5c23..2bace7d 100644 --- a/client/src/pages/project-detail/project-detail-page.tsx +++ b/client/src/pages/project-detail/project-detail-page.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { useParams } from 'react-router-dom'; import type { Item } from '@shared/models/item'; import { @@ -19,7 +19,6 @@ import { ProjectHeader } from './project-header'; import { ProjectDescriptionSection } from './project-description-section'; import { TrackedAttributesSection } from './tracked-attributes-section'; import { ProjectItemsSection } from './project-items-section'; -import { ItemPricesModal } from './item-prices-modal'; export function ProjectDetailPage() { const { projectId } = useParams<{ projectId: string }>(); @@ -29,7 +28,6 @@ export function ProjectDetailPage() { const updateItemMutation = useUpdateItemMutation(projectId); const updateProjectMutation = useUpdateProjectMutation(projectId); - const [priceModalItemId, setPriceModalItemId] = useState(null); const [quickUrl, setQuickUrl] = useState(''); const [quickError, setQuickError] = useState(null); const [isItemModalOpen, setIsItemModalOpen] = useState(false); @@ -44,17 +42,6 @@ export function ProjectDetailPage() { const project = projectQuery.data; const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]); const projectAttributes = useMemo(() => project?.attributes ?? [], [project]); - const priceModalItem = useMemo( - () => items.find((item) => item.id === priceModalItemId) ?? null, - [items, priceModalItemId] - ); - - useEffect(() => { - if (priceModalItemId && !priceModalItem) { - setPriceModalItemId(null); - } - }, [priceModalItemId, priceModalItem]); - const availableAttributes = useMemo(() => { const attributeSet = new Set(); @@ -229,14 +216,6 @@ export function ProjectDetailPage() { } }; - const handleEditPrices = (item: Item) => { - setPriceModalItemId(item.id); - }; - - const handlePricesModalClose = () => { - setPriceModalItemId(null); - }; - const modalInitialData = itemModalMode === 'url' ? importedItemData @@ -295,7 +274,7 @@ export function ProjectDetailPage() { onAddManual={handleAddManualClick} isImportingFromUrl={isImportingItem} onEditItem={handleEditItem} - onEditPrices={handleEditPrices} + projectId={projectId} /> - ); } diff --git a/client/src/pages/project-detail/project-items-section.tsx b/client/src/pages/project-detail/project-items-section.tsx index 1224d6d..2a7aaac 100644 --- a/client/src/pages/project-detail/project-items-section.tsx +++ b/client/src/pages/project-detail/project-items-section.tsx @@ -1,6 +1,7 @@ -import { useEffect, useMemo, useState } from 'react'; +import { Fragment, useEffect, useMemo, useState } from 'react'; import { ExternalLink, Pencil } from 'lucide-react'; import type { Item } from '@shared/models/item'; +import { ItemPricesPanel } from './item-prices-panel'; interface ProjectItemsSectionProps { items: Item[]; @@ -14,7 +15,7 @@ interface ProjectItemsSectionProps { onAddManual: () => void; isImportingFromUrl: boolean; onEditItem: (item: Item) => void; - onEditPrices: (item: Item) => void; + projectId: string | undefined; } const formatAttributeValue = (value: unknown): string => { @@ -144,105 +145,66 @@ const getPriceSummaryDisplay = ( } const resolveCurrency = (preferred: string | null) => preferred ?? summary.currency ?? null; - const formatConditionPrice = ( - amount: number | null, + const formatConditionLabel = ( condition: 'new' | 'used', - useFrom: boolean, + amount: number | null, + count: number, currency: string | null, - isMixed: boolean + hasMixedCurrency: boolean ): string | null => { - if (amount === null || isMixed) { + if (!count) { return null; } + if (amount === null || hasMixedCurrency) { + return `${condition} prices vary (${count})`; + } const resolvedCurrency = resolveCurrency(currency); const formatted = formatPriceAmount(amount, resolvedCurrency); - const prefix = useFrom ? 'from ' : ''; - return `${prefix}${formatted} ${condition}`; + return `${condition} from ${formatted} (${count})`; }; - const hasMultiplePrices = summary.priceCount > 1; - const hasNewPrices = - summary.newCount > 0 && summary.newMinAmount !== null && !summary.newHasMixedCurrency; - const hasUsedPrices = - summary.usedCount > 0 && summary.usedMinAmount !== null && !summary.usedHasMixedCurrency; + const newLabel = formatConditionLabel( + 'new', + summary.newMinAmount, + summary.newCount, + summary.newCurrency, + summary.newHasMixedCurrency + ); + const usedLabel = formatConditionLabel( + 'used', + summary.usedMinAmount, + summary.usedCount, + summary.usedCurrency, + summary.usedHasMixedCurrency + ); - if (summary.priceCount === 1) { - if (hasNewPrices) { - const label = formatConditionPrice( - summary.newMinAmount, - 'new', - false, - summary.newCurrency, - summary.newHasMixedCurrency - ); - if (label) { - return { primary: label, secondary: null }; - } + if (newLabel || usedLabel) { + if (newLabel && usedLabel) { + return { primary: newLabel, secondary: usedLabel }; } - if (hasUsedPrices) { - const label = formatConditionPrice( - summary.usedMinAmount, - 'used', - false, - summary.usedCurrency, - summary.usedHasMixedCurrency - ); - if (label) { - return { primary: label, secondary: null }; - } - } - return { primary: formatPriceAmount(summary.minAmount, summary.currency), secondary: null }; + return { primary: newLabel ?? usedLabel ?? '—', secondary: null }; } - if (hasMultiplePrices) { - const parts: string[] = []; - - if (hasNewPrices) { - const label = formatConditionPrice( - summary.newMinAmount, - 'new', - true, - summary.newCurrency, - summary.newHasMixedCurrency - ); - if (label) { - parts.push(label); - } + if (!summary.hasMixedCurrency) { + const resolvedCurrency = resolveCurrency(null); + const formattedMin = formatPriceAmount(summary.minAmount, resolvedCurrency); + const formattedMax = formatPriceAmount(summary.maxAmount, resolvedCurrency); + if (summary.minAmount === summary.maxAmount) { + return { + primary: `${formattedMin} (${summary.priceCount})`, + secondary: null + }; } - - if (hasUsedPrices) { - const label = formatConditionPrice( - summary.usedMinAmount, - 'used', - true, - summary.usedCurrency, - summary.usedHasMixedCurrency - ); - if (label) { - parts.push(label); - } - } - - if (parts.length === 1) { - return { primary: parts[0], secondary: null }; - } - if (parts.length >= 2) { - return { primary: parts[0], secondary: parts[1] }; - } - - if (!summary.hasMixedCurrency) { - const formattedMin = formatPriceAmount(summary.minAmount, summary.currency); - const formattedMax = formatPriceAmount(summary.maxAmount, summary.currency); - if (summary.minAmount === summary.maxAmount) { - return { primary: formattedMin, secondary: null }; - } - return { primary: `from ${formattedMin}`, secondary: `up to ${formattedMax}` }; - } - - return { primary: 'Multiple currencies', secondary: null }; + return { + primary: `from ${formattedMin} (${summary.priceCount})`, + secondary: `up to ${formattedMax}` + }; } - return { primary: formatPriceAmount(summary.minAmount, summary.currency), secondary: null }; + return { + primary: `Multiple currencies (${summary.priceCount})`, + secondary: null + }; }; export function ProjectItemsSection({ @@ -257,9 +219,10 @@ export function ProjectItemsSection({ onAddManual, isImportingFromUrl, onEditItem, - onEditPrices + projectId }: ProjectItemsSectionProps) { const [showDifferencesOnly, setShowDifferencesOnly] = useState(false); + const [expandedItemId, setExpandedItemId] = useState(null); const mismatchedAttributes = useMemo(() => { const result = new Set(); @@ -303,6 +266,16 @@ export function ProjectItemsSection({ } }, [showDifferencesOnly, mismatchedAttributes]); + useEffect(() => { + if (!expandedItemId) { + return; + } + const itemStillPresent = items.some((item) => item.id === expandedItemId); + if (!itemStillPresent) { + setExpandedItemId(null); + } + }, [items, expandedItemId]); + const visibleAttributes = useMemo(() => { if (!showDifferencesOnly) { return projectAttributes; @@ -315,6 +288,10 @@ export function ProjectItemsSection({ const showNoDifferencesMessage = !hasAttributeDifferences && projectAttributes.length > 0 && items.length > 1; + const handleTogglePrices = (itemId: string) => { + setExpandedItemId((current) => (current === itemId ? null : itemId)); + }; + return ( <>
@@ -376,74 +353,89 @@ export function ProjectItemsSection({ {items.map((item) => { const attributes = (item.attributes ?? {}) as Record; const priceDisplay = getPriceSummaryDisplay(item.priceSummary); + const isExpanded = expandedItemId === item.id; + const desktopPanelId = `item-${item.id}-prices-desktop`; return ( - - -
-
- - {item.sourceUrl ? ( - + + + - {item.note ? ( - {item.note} - ) : null} -
- - {visibleAttributes.map((attribute) => ( - - {formatAttributeValue(attributes[attribute])} - ))} - -
+ {visibleAttributes.map((attribute) => ( + + {formatAttributeValue(attributes[attribute])} + + ))} +
- - {priceDisplay.primary} - +
+ + {priceDisplay.primary} + + +
{priceDisplay.secondary ? ( {priceDisplay.secondary} ) : null}
- -
- - + + + {isExpanded ? ( + + +
+ +
+ + + ) : null} + ); })} @@ -455,6 +447,8 @@ export function ProjectItemsSection({ {items.map((item) => { const attributes = (item.attributes ?? {}) as Record; const priceDisplay = getPriceSummaryDisplay(item.priceSummary); + const isExpanded = expandedItemId === item.id; + const mobilePanelId = `item-${item.id}-prices-mobile`; return (
@@ -480,7 +474,7 @@ export function ProjectItemsSection({ href={item.sourceUrl} target="_blank" rel="noreferrer" - className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-blue-200 text-blue-600 transition hover:bg-blue-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white" + className="inline-flex h-9 w-9 items-center justify-center rounded-md text-blue-600 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white" title="Open source link" > @@ -506,19 +500,32 @@ export function ProjectItemsSection({ ))}
- {priceDisplay.primary} +
+ + {priceDisplay.primary} + + +
{priceDisplay.secondary ? ( {priceDisplay.secondary} ) : null}
- + {isExpanded ? ( +
+ +
+ ) : null}
); })}