From 677a5a327215b84c7f2948c910de35a29b8ab52c Mon Sep 17 00:00:00 2001 From: Jurgis Sakalauskas Date: Tue, 14 Oct 2025 16:47:20 +0300 Subject: [PATCH] edit price --- client/src/api/item-prices.ts | 49 +++ .../project-detail/item-prices-panel.tsx | 294 +++++++++++++++--- .../project-items-desktop-table.tsx | 1 + .../project-detail/project-items-utils.ts | 1 + client/src/query/item-prices.ts | 25 +- server/src/db/item-prices-repository.ts | 5 +- 6 files changed, 335 insertions(+), 40 deletions(-) diff --git a/client/src/api/item-prices.ts b/client/src/api/item-prices.ts index fe940a2..511dffb 100644 --- a/client/src/api/item-prices.ts +++ b/client/src/api/item-prices.ts @@ -48,6 +48,15 @@ export interface CreateItemPricePayload { note?: string | null; } +export interface UpdateItemPricePayload { + condition?: PriceCondition; + amount?: number; + currency?: string; + sourceUrl?: string | null; + sourceUrlId?: string | null; + note?: string | null; +} + interface SingleItemPriceResponse { data: ItemPrice; } @@ -74,6 +83,46 @@ export const createItemPrice = async ( return response.data; }; +export const updateItemPrice = async ( + priceId: string, + payload: UpdateItemPricePayload +): Promise => { + const body: Record = {}; + + if (payload.condition !== undefined) { + body.condition = payload.condition; + } + if (payload.amount !== undefined) { + body.amount = payload.amount; + } + if (payload.currency !== undefined) { + body.currency = payload.currency; + } + if (payload.sourceUrlId !== undefined) { + body.sourceUrlId = payload.sourceUrlId; + } + if (payload.sourceUrl !== undefined) { + body.sourceUrl = payload.sourceUrl; + } + if (payload.note !== undefined) { + body.note = payload.note; + } + + if (Object.keys(body).length === 0) { + throw new Error('No fields provided to update item price'); + } + + const response = await apiFetch( + `/prices/${priceId}`, + { + method: 'PATCH', + body + } + ); + + return response.data; +}; + export const deleteItemPrice = async (priceId: string): Promise => { await apiFetch(`/prices/${priceId}`, { method: 'DELETE' diff --git a/client/src/pages/project-detail/item-prices-panel.tsx b/client/src/pages/project-detail/item-prices-panel.tsx index 0f7963b..e907456 100644 --- a/client/src/pages/project-detail/item-prices-panel.tsx +++ b/client/src/pages/project-detail/item-prices-panel.tsx @@ -1,15 +1,14 @@ import { FormEvent, useMemo, useState } from 'react'; -import { Trash2 } from 'lucide-react'; +import { Pencil, Trash2 } from 'lucide-react'; import { useCreateItemPriceMutation, useDeleteItemPriceMutation, - useItemPricesQuery + useItemPricesQuery, + useUpdateItemPriceMutation } from '../../query/item-prices'; import type { PriceCondition } from '../../api/item-prices'; import { formatRelativeTime } from '../../utils/relative-time'; -const priceConditionOptions: PriceCondition[] = ['new', 'used']; - interface ItemPricesPanelProps { itemId: string; projectId: string | undefined; @@ -24,28 +23,64 @@ export function ItemPricesPanel({ itemId, projectId }: ItemPricesPanelProps) { const [formError, setFormError] = useState(null); const [actionError, setActionError] = useState(null); const [pendingDeletionId, setPendingDeletionId] = useState(null); + const [editingPriceId, setEditingPriceId] = useState(null); + const [editingCondition, setEditingCondition] = useState('new'); + const [editingAmount, setEditingAmount] = useState(''); + const [editingCurrency, setEditingCurrency] = useState('EUR'); + const [editingSourceUrl, setEditingSourceUrl] = useState(''); + const [editingNote, setEditingNote] = useState(''); const pricesQuery = useItemPricesQuery(itemId, true); const createPriceMutation = useCreateItemPriceMutation(itemId, projectId); const deletePriceMutation = useDeleteItemPriceMutation(itemId, projectId); + const updatePriceMutation = useUpdateItemPriceMutation(itemId, projectId); const prices = pricesQuery.data?.data ?? []; + const sortedPrices = useMemo( + () => [...prices].sort((first, second) => first.amount - second.amount), + [prices] + ); const errorMessage = useMemo( () => formError ?? actionError ?? (createPriceMutation.isError ? createPriceMutation.error.message : null) ?? - (deletePriceMutation.isError ? deletePriceMutation.error.message : null), + (deletePriceMutation.isError ? deletePriceMutation.error.message : null) ?? + (updatePriceMutation.isError ? updatePriceMutation.error.message : null), [ formError, actionError, createPriceMutation.isError, createPriceMutation.error, deletePriceMutation.isError, - deletePriceMutation.error + deletePriceMutation.error, + updatePriceMutation.isError, + updatePriceMutation.error ] ); + const resetEditingState = () => { + setEditingPriceId(null); + setEditingCondition('new'); + setEditingAmount(''); + setEditingCurrency('EUR'); + setEditingSourceUrl(''); + setEditingNote(''); + updatePriceMutation.reset(); + }; + + const startEditingPrice = (price: (typeof prices)[number]) => { + setFormError(null); + setActionError(null); + updatePriceMutation.reset(); + setEditingPriceId(price.id); + setEditingCondition(price.condition); + setEditingAmount(price.amount.toFixed(2)); + setEditingCurrency(price.currency.toUpperCase()); + setEditingSourceUrl(price.sourceUrl ?? ''); + setEditingNote(price.note ?? ''); + }; + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); setFormError(null); @@ -76,6 +111,56 @@ export function ItemPricesPanel({ itemId, projectId }: ItemPricesPanelProps) { } }; + const handleCancelEditing = () => { + setActionError(null); + resetEditingState(); + }; + + const handleUpdatePrice = async () => { + if (!editingPriceId) { + return; + } + + setActionError(null); + + const trimmedAmount = editingAmount.trim(); + const parsedAmount = Number(trimmedAmount); + + if (trimmedAmount === '' || !Number.isFinite(parsedAmount) || parsedAmount < 0) { + setActionError('Amount must be a non-negative number.'); + return; + } + + const trimmedCurrency = editingCurrency.trim().toUpperCase(); + + if (!trimmedCurrency) { + setActionError('Currency is required.'); + return; + } + + setEditingCurrency(trimmedCurrency); + + const trimmedSourceUrl = editingSourceUrl.trim(); + const trimmedNote = editingNote.trim(); + + try { + await updatePriceMutation.mutateAsync({ + priceId: editingPriceId, + payload: { + condition: editingCondition, + amount: parsedAmount, + currency: trimmedCurrency, + sourceUrl: trimmedSourceUrl ? trimmedSourceUrl : null, + note: trimmedNote ? trimmedNote : null + } + }); + + resetEditingState(); + } catch (error) { + setActionError((error as Error).message); + } + }; + const handleDeletePrice = async (priceId: string) => { setActionError(null); deletePriceMutation.reset(); @@ -149,17 +234,100 @@ export function ItemPricesPanel({ itemId, projectId }: ItemPricesPanelProps) { Loading prices… - ) : prices.length ? ( - prices.map((price) => { + ) : sortedPrices.length ? ( + sortedPrices.map((price) => { const lastUpdated = price.updatedAt ?? price.createdAt; + const isEditing = editingPriceId === price.id; return ( - {price.condition} - {price.amount.toFixed(2)} - {price.currency} - - {price.sourceUrl ? ( + + {isEditing ? ( + <> + +
+ + setEditingCondition(event.target.checked ? 'new' : 'used') + } + className="h-4 w-4 rounded border border-slate-300 text-blue-600 focus:ring-blue-500" + /> + New +
+ + ) : ( + {price.condition} + )} + + + {isEditing ? ( + <> + + setEditingAmount(event.target.value)} + className="w-full rounded-md border border-slate-300 px-2 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200" + placeholder="129.99" + required + /> + + ) : ( + {price.amount.toFixed(2)} + )} + + + {isEditing ? ( + <> + + + setEditingCurrency(event.target.value.toUpperCase()) + } + maxLength={3} + className="w-full rounded-md border border-slate-300 px-2 py-2 text-sm uppercase shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200" + placeholder="EUR" + required + /> + + ) : ( + {price.currency} + )} + + + {isEditing ? ( + <> + + setEditingSourceUrl(event.target.value)} + className="w-full rounded-md border border-slate-300 px-2 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200" + placeholder="https://shop.example.com/deal" + /> + + ) : price.sourceUrl ? ( — )} - - {price.note ? price.note : } + + {isEditing ? ( + <> + +