mirror of
https://github.com/sakaljurgis/best-choice.git
synced 2026-07-08 21:47:40 +00:00
update price display
This commit is contained in:
Generated
+3443
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,14 @@ export interface ItemPriceSummary {
|
|||||||
currency: string | null;
|
currency: string | null;
|
||||||
priceCount: number;
|
priceCount: number;
|
||||||
hasMixedCurrency: boolean;
|
hasMixedCurrency: boolean;
|
||||||
|
newMinAmount: number | null;
|
||||||
|
newCount: number;
|
||||||
|
newCurrency: string | null;
|
||||||
|
newHasMixedCurrency: boolean;
|
||||||
|
usedMinAmount: number | null;
|
||||||
|
usedCount: number;
|
||||||
|
usedCurrency: string | null;
|
||||||
|
usedHasMixedCurrency: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListResponseMeta {
|
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 { useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
useCreateItemMutation,
|
useCreateItemMutation,
|
||||||
@@ -19,6 +19,7 @@ import { ProjectHeader } from './project-header';
|
|||||||
import { ProjectDescriptionSection } from './project-description-section';
|
import { ProjectDescriptionSection } from './project-description-section';
|
||||||
import { TrackedAttributesSection } from './tracked-attributes-section';
|
import { TrackedAttributesSection } from './tracked-attributes-section';
|
||||||
import { ProjectItemsSection } from './project-items-section';
|
import { ProjectItemsSection } from './project-items-section';
|
||||||
|
import { ItemPricesModal } from './item-prices-modal';
|
||||||
|
|
||||||
export function ProjectDetailPage() {
|
export function ProjectDetailPage() {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
@@ -28,7 +29,7 @@ export function ProjectDetailPage() {
|
|||||||
const updateItemMutation = useUpdateItemMutation(projectId);
|
const updateItemMutation = useUpdateItemMutation(projectId);
|
||||||
const updateProjectMutation = useUpdateProjectMutation(projectId);
|
const updateProjectMutation = useUpdateProjectMutation(projectId);
|
||||||
|
|
||||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
|
const [priceModalItemId, setPriceModalItemId] = useState<string | null>(null);
|
||||||
const [quickUrl, setQuickUrl] = useState('');
|
const [quickUrl, setQuickUrl] = useState('');
|
||||||
const [quickError, setQuickError] = useState<string | null>(null);
|
const [quickError, setQuickError] = useState<string | null>(null);
|
||||||
const [isItemModalOpen, setIsItemModalOpen] = useState(false);
|
const [isItemModalOpen, setIsItemModalOpen] = useState(false);
|
||||||
@@ -43,6 +44,16 @@ export function ProjectDetailPage() {
|
|||||||
const project = projectQuery.data;
|
const project = projectQuery.data;
|
||||||
const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]);
|
const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]);
|
||||||
const projectAttributes = useMemo(() => project?.attributes ?? [], [project]);
|
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 availableAttributes = useMemo(() => {
|
||||||
const attributeSet = new Set<string>();
|
const attributeSet = new Set<string>();
|
||||||
@@ -218,8 +229,12 @@ export function ProjectDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleItemExpansion = (itemId: string) => {
|
const handleEditPrices = (item: Item) => {
|
||||||
setExpandedItemId((current) => (current === itemId ? null : itemId));
|
setPriceModalItemId(item.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePricesModalClose = () => {
|
||||||
|
setPriceModalItemId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const modalInitialData =
|
const modalInitialData =
|
||||||
@@ -273,8 +288,6 @@ export function ProjectDetailPage() {
|
|||||||
isLoading={itemsQuery.isLoading}
|
isLoading={itemsQuery.isLoading}
|
||||||
error={itemsQuery.isError ? (itemsQuery.error as Error) : null}
|
error={itemsQuery.isError ? (itemsQuery.error as Error) : null}
|
||||||
projectAttributes={projectAttributes}
|
projectAttributes={projectAttributes}
|
||||||
expandedItemId={expandedItemId}
|
|
||||||
onToggleItem={toggleItemExpansion}
|
|
||||||
quickUrl={quickUrl}
|
quickUrl={quickUrl}
|
||||||
quickError={quickError}
|
quickError={quickError}
|
||||||
onQuickUrlChange={handleQuickUrlChange}
|
onQuickUrlChange={handleQuickUrlChange}
|
||||||
@@ -282,6 +295,7 @@ export function ProjectDetailPage() {
|
|||||||
onAddManual={handleAddManualClick}
|
onAddManual={handleAddManualClick}
|
||||||
isImportingFromUrl={isImportingItem}
|
isImportingFromUrl={isImportingItem}
|
||||||
onEditItem={handleEditItem}
|
onEditItem={handleEditItem}
|
||||||
|
onEditPrices={handleEditPrices}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ItemFormModal
|
<ItemFormModal
|
||||||
@@ -297,6 +311,11 @@ export function ProjectDetailPage() {
|
|||||||
isInitialDataLoading={itemModalInitialDataLoading}
|
isInitialDataLoading={itemModalInitialDataLoading}
|
||||||
initialDataError={itemModalInitialDataError}
|
initialDataError={itemModalInitialDataError}
|
||||||
/>
|
/>
|
||||||
|
<ItemPricesModal
|
||||||
|
isOpen={Boolean(priceModalItem)}
|
||||||
|
item={priceModalItem}
|
||||||
|
onClose={handlePricesModalClose}
|
||||||
|
/>
|
||||||
</div>
|
</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 { ExternalLink, Pencil } from 'lucide-react';
|
||||||
import type { Item } from '../../api/items';
|
import type { Item } from '../../api/items';
|
||||||
import { ItemPricesPanel } from './item-prices-panel';
|
|
||||||
|
|
||||||
interface ProjectItemsSectionProps {
|
interface ProjectItemsSectionProps {
|
||||||
items: Item[];
|
items: Item[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
projectAttributes: string[];
|
projectAttributes: string[];
|
||||||
expandedItemId: string | null;
|
|
||||||
onToggleItem: (itemId: string) => void;
|
|
||||||
quickUrl: string;
|
quickUrl: string;
|
||||||
quickError: string | null;
|
quickError: string | null;
|
||||||
onQuickUrlChange: (value: string) => void;
|
onQuickUrlChange: (value: string) => void;
|
||||||
@@ -17,6 +14,7 @@ interface ProjectItemsSectionProps {
|
|||||||
onAddManual: () => void;
|
onAddManual: () => void;
|
||||||
isImportingFromUrl: boolean;
|
isImportingFromUrl: boolean;
|
||||||
onEditItem: (item: Item) => void;
|
onEditItem: (item: Item) => void;
|
||||||
|
onEditPrices: (item: Item) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAttributeValue = (value: unknown): string => {
|
const formatAttributeValue = (value: unknown): string => {
|
||||||
@@ -145,29 +143,106 @@ const getPriceSummaryDisplay = (
|
|||||||
return { primary: '—', secondary: null };
|
return { primary: '—', secondary: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (summary.hasMixedCurrency) {
|
const resolveCurrency = (preferred: string | null) => preferred ?? summary.currency ?? null;
|
||||||
const countLabel = `${summary.priceCount} price${summary.priceCount === 1 ? '' : 's'}`;
|
const formatConditionPrice = (
|
||||||
return {
|
amount: number | null,
|
||||||
primary: countLabel,
|
condition: 'new' | 'used',
|
||||||
secondary: 'Multiple currencies'
|
useFrom: boolean,
|
||||||
};
|
currency: string | null,
|
||||||
}
|
isMixed: boolean
|
||||||
|
): string | null => {
|
||||||
const formattedMin = formatPriceAmount(summary.minAmount, summary.currency);
|
if (amount === null || isMixed) {
|
||||||
|
return null;
|
||||||
if (summary.priceCount === 1 || summary.minAmount === summary.maxAmount) {
|
}
|
||||||
return {
|
const resolvedCurrency = resolveCurrency(currency);
|
||||||
primary: formattedMin,
|
const formatted = formatPriceAmount(amount, resolvedCurrency);
|
||||||
secondary: summary.priceCount > 1 ? `${summary.priceCount} prices` : null
|
const prefix = useFrom ? 'from ' : '';
|
||||||
};
|
return `${prefix}${formatted} ${condition}`;
|
||||||
}
|
|
||||||
|
|
||||||
const formattedMax = formatPriceAmount(summary.maxAmount, summary.currency);
|
|
||||||
|
|
||||||
return {
|
|
||||||
primary: `${formattedMin} – ${formattedMax}`,
|
|
||||||
secondary: `${summary.priceCount} prices`
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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({
|
export function ProjectItemsSection({
|
||||||
@@ -175,17 +250,15 @@ export function ProjectItemsSection({
|
|||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
projectAttributes,
|
projectAttributes,
|
||||||
expandedItemId,
|
|
||||||
onToggleItem,
|
|
||||||
quickUrl,
|
quickUrl,
|
||||||
quickError,
|
quickError,
|
||||||
onQuickUrlChange,
|
onQuickUrlChange,
|
||||||
onAddUrl,
|
onAddUrl,
|
||||||
onAddManual,
|
onAddManual,
|
||||||
isImportingFromUrl,
|
isImportingFromUrl,
|
||||||
onEditItem
|
onEditItem,
|
||||||
|
onEditPrices
|
||||||
}: ProjectItemsSectionProps) {
|
}: ProjectItemsSectionProps) {
|
||||||
const baseColumnCount = 2; // Item, Prices (status indicator lives inside item cell)
|
|
||||||
const [showDifferencesOnly, setShowDifferencesOnly] = useState(false);
|
const [showDifferencesOnly, setShowDifferencesOnly] = useState(false);
|
||||||
|
|
||||||
const mismatchedAttributes = useMemo(() => {
|
const mismatchedAttributes = useMemo(() => {
|
||||||
@@ -238,7 +311,6 @@ export function ProjectItemsSection({
|
|||||||
return projectAttributes.filter((attribute) => mismatchedAttributes.has(attribute));
|
return projectAttributes.filter((attribute) => mismatchedAttributes.has(attribute));
|
||||||
}, [showDifferencesOnly, projectAttributes, mismatchedAttributes]);
|
}, [showDifferencesOnly, projectAttributes, mismatchedAttributes]);
|
||||||
|
|
||||||
const totalColumns = baseColumnCount + visibleAttributes.length;
|
|
||||||
const hasAttributeDifferences = mismatchedAttributes.size > 0;
|
const hasAttributeDifferences = mismatchedAttributes.size > 0;
|
||||||
const showNoDifferencesMessage =
|
const showNoDifferencesMessage =
|
||||||
!hasAttributeDifferences && projectAttributes.length > 0 && items.length > 1;
|
!hasAttributeDifferences && projectAttributes.length > 0 && items.length > 1;
|
||||||
@@ -302,87 +374,76 @@ export function ProjectItemsSection({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-200">
|
<tbody className="divide-y divide-slate-200">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const isExpanded = expandedItemId === item.id;
|
|
||||||
const attributes = (item.attributes ?? {}) as Record<string, unknown>;
|
const attributes = (item.attributes ?? {}) as Record<string, unknown>;
|
||||||
const priceDisplay = getPriceSummaryDisplay(item.priceSummary);
|
const priceDisplay = getPriceSummaryDisplay(item.priceSummary);
|
||||||
const priceButtonLabel = isExpanded ? 'Hide Prices' : 'Edit Prices';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment key={item.id}>
|
<tr key={item.id} className="hover:bg-slate-50">
|
||||||
<tr className="hover:bg-slate-50">
|
<td className="px-4 py-3 align-top">
|
||||||
<td className="px-4 py-3 align-top">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex items-center gap-2">
|
||||||
<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>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onToggleItem(item.id)}
|
onClick={() => onEditItem(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"
|
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"
|
||||||
aria-label={priceButtonLabel}
|
title="Edit item details"
|
||||||
title={priceButtonLabel}
|
|
||||||
>
|
>
|
||||||
<Pencil aria-hidden className="h-5 w-5 text-slate-400 transition hover:text-blue-600" />
|
<span
|
||||||
<span className="sr-only">{priceButtonLabel}</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>
|
</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>
|
</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>
|
||||||
</tr>
|
))}
|
||||||
{isExpanded ? (
|
<td className="px-4 py-3 align-top">
|
||||||
<tr className="bg-slate-50">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<td colSpan={totalColumns} className="px-4 py-5">
|
<div className="flex flex-col gap-0.5 text-xs">
|
||||||
<ItemPricesPanel itemId={item.id} />
|
<span className="text-sm font-semibold text-slate-900">
|
||||||
</td>
|
{priceDisplay.primary}
|
||||||
</tr>
|
</span>
|
||||||
) : null}
|
{priceDisplay.secondary ? (
|
||||||
</Fragment>
|
<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>
|
</tbody>
|
||||||
@@ -392,10 +453,8 @@ export function ProjectItemsSection({
|
|||||||
|
|
||||||
<div className="space-y-4 md:hidden">
|
<div className="space-y-4 md:hidden">
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const isExpanded = expandedItemId === item.id;
|
|
||||||
const attributes = (item.attributes ?? {}) as Record<string, unknown>;
|
const attributes = (item.attributes ?? {}) as Record<string, unknown>;
|
||||||
const priceDisplay = getPriceSummaryDisplay(item.priceSummary);
|
const priceDisplay = getPriceSummaryDisplay(item.priceSummary);
|
||||||
const priceButtonLabel = isExpanded ? 'Hide Prices' : 'Edit Prices';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={item.id} className="rounded-xl border border-slate-200 p-4 shadow-sm">
|
<div key={item.id} className="rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||||
@@ -454,18 +513,12 @@ export function ProjectItemsSection({
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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" />
|
<Pencil aria-hidden className="h-5 w-5 text-slate-400 transition hover:text-blue-600" />
|
||||||
{priceButtonLabel}
|
Edit Prices
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isExpanded ? (
|
|
||||||
<div className="mt-4">
|
|
||||||
<ItemPricesPanel itemId={item.id} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -16,7 +16,15 @@ const itemColumns = `
|
|||||||
price_summary.max_amount AS price_max_amount,
|
price_summary.max_amount AS price_max_amount,
|
||||||
price_summary.total_count AS price_count,
|
price_summary.total_count AS price_count,
|
||||||
price_summary.currency AS price_currency,
|
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 {
|
export interface ItemRow {
|
||||||
@@ -36,6 +44,14 @@ export interface ItemRow {
|
|||||||
price_count: number | null;
|
price_count: number | null;
|
||||||
price_currency: string | null;
|
price_currency: string | null;
|
||||||
price_currency_count: number | 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 {
|
export interface ItemRecord {
|
||||||
@@ -59,6 +75,14 @@ export interface ItemPriceSummary {
|
|||||||
currency: string | null;
|
currency: string | null;
|
||||||
priceCount: number;
|
priceCount: number;
|
||||||
hasMixedCurrency: boolean;
|
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 => {
|
const mapItemRow = (row: ItemRow): ItemRecord => {
|
||||||
@@ -70,7 +94,17 @@ const mapItemRow = (row: ItemRow): ItemRecord => {
|
|||||||
maxAmount: Number(row.price_max_amount),
|
maxAmount: Number(row.price_max_amount),
|
||||||
currency: row.price_currency,
|
currency: row.price_currency,
|
||||||
priceCount,
|
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;
|
: null;
|
||||||
|
|
||||||
@@ -122,7 +156,15 @@ export const listItems = async (options: ListItemsOptions): Promise<ItemRecord[]
|
|||||||
MAX(ip.amount) AS max_amount,
|
MAX(ip.amount) AS max_amount,
|
||||||
COUNT(*)::INT AS total_count,
|
COUNT(*)::INT AS total_count,
|
||||||
COUNT(DISTINCT ip.currency)::INT AS currency_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
|
FROM item_prices ip
|
||||||
WHERE ip.item_id = i.id
|
WHERE ip.item_id = i.id
|
||||||
) price_summary ON TRUE
|
) price_summary ON TRUE
|
||||||
|
|||||||
Reference in New Issue
Block a user