update price display

This commit is contained in:
Jurgis Sakalauskas
2025-10-13 20:28:15 +03:00
parent 7bc38e9a6e
commit 3ac7e9183f
6 changed files with 3747 additions and 121 deletions
+3443
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -23,6 +23,14 @@ export interface ItemPriceSummary {
currency: string | null;
priceCount: number;
hasMixedCurrency: boolean;
newMinAmount: number | null;
newCount: number;
newCurrency: string | null;
newHasMixedCurrency: boolean;
usedMinAmount: number | null;
usedCount: number;
usedCurrency: string | null;
usedHasMixedCurrency: boolean;
}
interface ListResponseMeta {
@@ -0,0 +1,61 @@
import { useEffect } from 'react';
import type { Item } from '../../api/items';
import { ItemPricesPanel } from './item-prices-panel';
interface ItemPricesModalProps {
isOpen: boolean;
item: Item | null;
onClose: () => void;
}
export function ItemPricesModal({ isOpen, item, onClose }: ItemPricesModalProps) {
useEffect(() => {
if (!isOpen) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen || !item) {
return null;
}
const itemTitle = item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 px-4 py-10 md:py-16"
role="dialog"
aria-modal="true"
onClick={onClose}
>
<div
className="relative flex w-full max-w-4xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl max-h-[calc(100vh-5rem)] md:max-h-[calc(100vh-8rem)]"
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={onClose}
className="absolute right-4 top-4 rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-slate-500 transition hover:bg-slate-200"
>
Close
</button>
<div className="flex-1 overflow-y-auto px-6 py-6 md:px-8 md:py-8">
<header className="mb-5 flex flex-col gap-1">
<h2 className="text-xl font-semibold text-slate-900">Edit Item Prices</h2>
<p className="text-sm text-slate-500">{itemTitle}</p>
</header>
<ItemPricesPanel itemId={item.id} />
</div>
</div>
</div>
);
}
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
useCreateItemMutation,
@@ -19,6 +19,7 @@ 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 }>();
@@ -28,7 +29,7 @@ export function ProjectDetailPage() {
const updateItemMutation = useUpdateItemMutation(projectId);
const updateProjectMutation = useUpdateProjectMutation(projectId);
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
const [priceModalItemId, setPriceModalItemId] = useState<string | null>(null);
const [quickUrl, setQuickUrl] = useState('');
const [quickError, setQuickError] = useState<string | null>(null);
const [isItemModalOpen, setIsItemModalOpen] = useState(false);
@@ -43,6 +44,16 @@ 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<string>();
@@ -218,8 +229,12 @@ export function ProjectDetailPage() {
}
};
const toggleItemExpansion = (itemId: string) => {
setExpandedItemId((current) => (current === itemId ? null : itemId));
const handleEditPrices = (item: Item) => {
setPriceModalItemId(item.id);
};
const handlePricesModalClose = () => {
setPriceModalItemId(null);
};
const modalInitialData =
@@ -273,8 +288,6 @@ export function ProjectDetailPage() {
isLoading={itemsQuery.isLoading}
error={itemsQuery.isError ? (itemsQuery.error as Error) : null}
projectAttributes={projectAttributes}
expandedItemId={expandedItemId}
onToggleItem={toggleItemExpansion}
quickUrl={quickUrl}
quickError={quickError}
onQuickUrlChange={handleQuickUrlChange}
@@ -282,6 +295,7 @@ export function ProjectDetailPage() {
onAddManual={handleAddManualClick}
isImportingFromUrl={isImportingItem}
onEditItem={handleEditItem}
onEditPrices={handleEditPrices}
/>
<ItemFormModal
@@ -297,6 +311,11 @@ export function ProjectDetailPage() {
isInitialDataLoading={itemModalInitialDataLoading}
initialDataError={itemModalInitialDataError}
/>
<ItemPricesModal
isOpen={Boolean(priceModalItem)}
item={priceModalItem}
onClose={handlePricesModalClose}
/>
</div>
);
}
@@ -1,15 +1,12 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { ExternalLink, Pencil } from 'lucide-react';
import type { Item } from '../../api/items';
import { ItemPricesPanel } from './item-prices-panel';
interface ProjectItemsSectionProps {
items: Item[];
isLoading: boolean;
error: Error | null;
projectAttributes: string[];
expandedItemId: string | null;
onToggleItem: (itemId: string) => void;
quickUrl: string;
quickError: string | null;
onQuickUrlChange: (value: string) => void;
@@ -17,6 +14,7 @@ interface ProjectItemsSectionProps {
onAddManual: () => void;
isImportingFromUrl: boolean;
onEditItem: (item: Item) => void;
onEditPrices: (item: Item) => void;
}
const formatAttributeValue = (value: unknown): string => {
@@ -145,29 +143,106 @@ const getPriceSummaryDisplay = (
return { primary: '—', secondary: null };
}
if (summary.hasMixedCurrency) {
const countLabel = `${summary.priceCount} price${summary.priceCount === 1 ? '' : 's'}`;
return {
primary: countLabel,
secondary: 'Multiple currencies'
};
}
const formattedMin = formatPriceAmount(summary.minAmount, summary.currency);
if (summary.priceCount === 1 || summary.minAmount === summary.maxAmount) {
return {
primary: formattedMin,
secondary: summary.priceCount > 1 ? `${summary.priceCount} prices` : null
};
}
const formattedMax = formatPriceAmount(summary.maxAmount, summary.currency);
return {
primary: `${formattedMin} ${formattedMax}`,
secondary: `${summary.priceCount} prices`
const resolveCurrency = (preferred: string | null) => preferred ?? summary.currency ?? null;
const formatConditionPrice = (
amount: number | null,
condition: 'new' | 'used',
useFrom: boolean,
currency: string | null,
isMixed: boolean
): string | null => {
if (amount === null || isMixed) {
return null;
}
const resolvedCurrency = resolveCurrency(currency);
const formatted = formatPriceAmount(amount, resolvedCurrency);
const prefix = useFrom ? 'from ' : '';
return `${prefix}${formatted} ${condition}`;
};
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;
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 (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 };
}
if (hasMultiplePrices) {
const parts: string[] = [];
if (hasNewPrices) {
const label = formatConditionPrice(
summary.newMinAmount,
'new',
true,
summary.newCurrency,
summary.newHasMixedCurrency
);
if (label) {
parts.push(label);
}
}
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: formatPriceAmount(summary.minAmount, summary.currency), secondary: null };
};
export function ProjectItemsSection({
@@ -175,17 +250,15 @@ export function ProjectItemsSection({
isLoading,
error,
projectAttributes,
expandedItemId,
onToggleItem,
quickUrl,
quickError,
onQuickUrlChange,
onAddUrl,
onAddManual,
isImportingFromUrl,
onEditItem
onEditItem,
onEditPrices
}: ProjectItemsSectionProps) {
const baseColumnCount = 2; // Item, Prices (status indicator lives inside item cell)
const [showDifferencesOnly, setShowDifferencesOnly] = useState(false);
const mismatchedAttributes = useMemo(() => {
@@ -238,7 +311,6 @@ export function ProjectItemsSection({
return projectAttributes.filter((attribute) => mismatchedAttributes.has(attribute));
}, [showDifferencesOnly, projectAttributes, mismatchedAttributes]);
const totalColumns = baseColumnCount + visibleAttributes.length;
const hasAttributeDifferences = mismatchedAttributes.size > 0;
const showNoDifferencesMessage =
!hasAttributeDifferences && projectAttributes.length > 0 && items.length > 1;
@@ -302,87 +374,76 @@ export function ProjectItemsSection({
</thead>
<tbody className="divide-y divide-slate-200">
{items.map((item) => {
const isExpanded = expandedItemId === item.id;
const attributes = (item.attributes ?? {}) as Record<string, unknown>;
const priceDisplay = getPriceSummaryDisplay(item.priceSummary);
const priceButtonLabel = isExpanded ? 'Hide Prices' : 'Edit Prices';
return (
<Fragment key={item.id}>
<tr className="hover:bg-slate-50">
<td className="px-4 py-3 align-top">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onEditItem(item)}
className="group inline-flex items-center gap-2 rounded-md text-left text-slate-900 transition hover:text-blue-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
title="Edit item details"
>
<span
aria-hidden
className={`inline-block h-2.5 w-2.5 rounded-full ${
item.status === 'active' ? 'bg-emerald-500' : 'bg-slate-300'
}`}
/>
<span className="font-semibold group-hover:underline">
{item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model}
</span>
</button>
{item.sourceUrl ? (
<a
href={item.sourceUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 w-8 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"
title="Open source link"
>
<ExternalLink aria-hidden className="h-4 w-4" />
<span className="sr-only">Open source link</span>
</a>
) : null}
</div>
{item.note ? (
<span className="text-xs text-slate-500">{item.note}</span>
) : null}
</div>
</td>
{visibleAttributes.map((attribute) => (
<td key={attribute} className="px-4 py-3 align-top text-xs text-slate-600">
{formatAttributeValue(attributes[attribute])}
</td>
))}
<td className="px-4 py-3 align-top">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-0.5 text-xs">
<span className="text-sm font-semibold text-slate-900">
{priceDisplay.primary}
</span>
{priceDisplay.secondary ? (
<span className="text-slate-500">{priceDisplay.secondary}</span>
) : null}
</div>
<tr key={item.id} className="hover:bg-slate-50">
<td className="px-4 py-3 align-top">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onToggleItem(item.id)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-blue-200 text-blue-600 transition hover:bg-blue-50"
aria-label={priceButtonLabel}
title={priceButtonLabel}
onClick={() => onEditItem(item)}
className="group inline-flex items-center gap-2 rounded-md text-left text-slate-900 transition hover:text-blue-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
title="Edit item details"
>
<Pencil aria-hidden className="h-5 w-5 text-slate-400 transition hover:text-blue-600" />
<span className="sr-only">{priceButtonLabel}</span>
<span
aria-hidden
className={`inline-block h-2.5 w-2.5 rounded-full ${
item.status === 'active' ? 'bg-emerald-500' : 'bg-slate-300'
}`}
/>
<span className="font-semibold group-hover:underline">
{item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model}
</span>
</button>
{item.sourceUrl ? (
<a
href={item.sourceUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-8 w-8 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"
title="Open source link"
>
<ExternalLink aria-hidden className="h-4 w-4" />
<span className="sr-only">Open source link</span>
</a>
) : null}
</div>
{item.note ? (
<span className="text-xs text-slate-500">{item.note}</span>
) : null}
</div>
</td>
{visibleAttributes.map((attribute) => (
<td key={attribute} className="px-4 py-3 align-top text-xs text-slate-600">
{formatAttributeValue(attributes[attribute])}
</td>
</tr>
{isExpanded ? (
<tr className="bg-slate-50">
<td colSpan={totalColumns} className="px-4 py-5">
<ItemPricesPanel itemId={item.id} />
</td>
</tr>
) : null}
</Fragment>
))}
<td className="px-4 py-3 align-top">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-0.5 text-xs">
<span className="text-sm font-semibold text-slate-900">
{priceDisplay.primary}
</span>
{priceDisplay.secondary ? (
<span className="text-slate-500">{priceDisplay.secondary}</span>
) : null}
</div>
<button
type="button"
onClick={() => onEditPrices(item)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-blue-200 text-blue-600 transition hover:bg-blue-50"
aria-label="Edit Prices"
title="Edit Prices"
>
<Pencil aria-hidden className="h-5 w-5 text-slate-400 transition hover:text-blue-600" />
<span className="sr-only">Edit Prices</span>
</button>
</div>
</td>
</tr>
);
})}
</tbody>
@@ -392,10 +453,8 @@ export function ProjectItemsSection({
<div className="space-y-4 md:hidden">
{items.map((item) => {
const isExpanded = expandedItemId === item.id;
const attributes = (item.attributes ?? {}) as Record<string, unknown>;
const priceDisplay = getPriceSummaryDisplay(item.priceSummary);
const priceButtonLabel = isExpanded ? 'Hide Prices' : 'Edit Prices';
return (
<div key={item.id} className="rounded-xl border border-slate-200 p-4 shadow-sm">
@@ -454,18 +513,12 @@ export function ProjectItemsSection({
</div>
<button
type="button"
onClick={() => onToggleItem(item.id)}
onClick={() => onEditPrices(item)}
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-md border border-blue-200 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-blue-600 transition hover:bg-blue-50"
>
<Pencil aria-hidden className="h-5 w-5 text-slate-400 transition hover:text-blue-600" />
{priceButtonLabel}
Edit Prices
</button>
{isExpanded ? (
<div className="mt-4">
<ItemPricesPanel itemId={item.id} />
</div>
) : null}
</div>
);
})}
+45 -3
View File
@@ -16,7 +16,15 @@ const itemColumns = `
price_summary.max_amount AS price_max_amount,
price_summary.total_count AS price_count,
price_summary.currency AS price_currency,
price_summary.currency_count AS price_currency_count
price_summary.currency_count AS price_currency_count,
price_summary.min_new_amount AS price_min_new_amount,
price_summary.new_count AS price_new_count,
price_summary.new_currency AS price_new_currency,
price_summary.new_currency_count AS price_new_currency_count,
price_summary.min_used_amount AS price_min_used_amount,
price_summary.used_count AS price_used_count,
price_summary.used_currency AS price_used_currency,
price_summary.used_currency_count AS price_used_currency_count
`;
export interface ItemRow {
@@ -36,6 +44,14 @@ export interface ItemRow {
price_count: number | null;
price_currency: string | null;
price_currency_count: number | null;
price_min_new_amount: string | null;
price_new_count: number | null;
price_new_currency: string | null;
price_new_currency_count: number | null;
price_min_used_amount: string | null;
price_used_count: number | null;
price_used_currency: string | null;
price_used_currency_count: number | null;
}
export interface ItemRecord {
@@ -59,6 +75,14 @@ export interface ItemPriceSummary {
currency: string | null;
priceCount: number;
hasMixedCurrency: boolean;
newMinAmount: number | null;
newCount: number;
newCurrency: string | null;
newHasMixedCurrency: boolean;
usedMinAmount: number | null;
usedCount: number;
usedCurrency: string | null;
usedHasMixedCurrency: boolean;
}
const mapItemRow = (row: ItemRow): ItemRecord => {
@@ -70,7 +94,17 @@ const mapItemRow = (row: ItemRow): ItemRecord => {
maxAmount: Number(row.price_max_amount),
currency: row.price_currency,
priceCount,
hasMixedCurrency: (row.price_currency_count ?? 0) > 1
hasMixedCurrency: (row.price_currency_count ?? 0) > 1,
newMinAmount:
row.price_min_new_amount !== null ? Number(row.price_min_new_amount) : null,
newCount: row.price_new_count ?? 0,
newCurrency: row.price_new_currency,
newHasMixedCurrency: (row.price_new_currency_count ?? 0) > 1,
usedMinAmount:
row.price_min_used_amount !== null ? Number(row.price_min_used_amount) : null,
usedCount: row.price_used_count ?? 0,
usedCurrency: row.price_used_currency,
usedHasMixedCurrency: (row.price_used_currency_count ?? 0) > 1
}
: null;
@@ -122,7 +156,15 @@ export const listItems = async (options: ListItemsOptions): Promise<ItemRecord[]
MAX(ip.amount) AS max_amount,
COUNT(*)::INT AS total_count,
COUNT(DISTINCT ip.currency)::INT AS currency_count,
MIN(ip.currency) AS currency
MIN(ip.currency) AS currency,
MIN(CASE WHEN ip.condition = 'new' THEN ip.amount END) AS min_new_amount,
COUNT(CASE WHEN ip.condition = 'new' THEN 1 END)::INT AS new_count,
COUNT(DISTINCT CASE WHEN ip.condition = 'new' THEN ip.currency END)::INT AS new_currency_count,
MIN(CASE WHEN ip.condition = 'new' THEN ip.currency END) AS new_currency,
MIN(CASE WHEN ip.condition = 'used' THEN ip.amount END) AS min_used_amount,
COUNT(CASE WHEN ip.condition = 'used' THEN 1 END)::INT AS used_count,
COUNT(DISTINCT CASE WHEN ip.condition = 'used' THEN ip.currency END)::INT AS used_currency_count,
MIN(CASE WHEN ip.condition = 'used' THEN ip.currency END) AS used_currency
FROM item_prices ip
WHERE ip.item_id = i.id
) price_summary ON TRUE