diff --git a/client/package.json b/client/package.json index 974705a..a48f0a7 100644 --- a/client/package.json +++ b/client/package.json @@ -16,6 +16,7 @@ "description": "React frontend for the best-choice project", "dependencies": { "@tanstack/react-query": "^5.90.2", + "lucide-react": "^0.545.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.9.4" diff --git a/client/src/api/items.ts b/client/src/api/items.ts index 8fcb021..fc9e2ae 100644 --- a/client/src/api/items.ts +++ b/client/src/api/items.ts @@ -88,6 +88,7 @@ export interface ImportedItemData { model: string | null; note: string | null; attributes: Record; + sourceUrl?: string | null; } export const createItem = async ( @@ -131,3 +132,55 @@ export const importItemFromUrl = async ( return response.data; }; + +export interface UpdateItemPayload { + manufacturer?: string | null; + model?: string; + status?: ItemStatus; + note?: string | null; + attributes?: Record; + sourceUrl?: string | null; + sourceUrlId?: string | null; +} + +export const updateItem = async ( + itemId: string, + payload: UpdateItemPayload +): Promise => { + const body: Record = {}; + + if ('manufacturer' in payload) { + body.manufacturer = payload.manufacturer ?? null; + } + + if ('model' in payload) { + body.model = payload.model; + } + + if ('status' in payload) { + body.status = payload.status; + } + + if ('note' in payload) { + body.note = payload.note ?? null; + } + + if ('attributes' in payload) { + body.attributes = payload.attributes ?? {}; + } + + if ('sourceUrl' in payload) { + body.sourceUrl = payload.sourceUrl ?? null; + } + + if ('sourceUrlId' in payload) { + body.sourceUrlId = payload.sourceUrlId ?? null; + } + + const response = await apiFetch(`/items/${itemId}`, { + method: 'PATCH', + body + }); + + return response.data; +}; diff --git a/client/src/api/projects.ts b/client/src/api/projects.ts index 662a253..0cea8a0 100644 --- a/client/src/api/projects.ts +++ b/client/src/api/projects.ts @@ -111,9 +111,31 @@ export const updateProject = async ( projectId: string, payload: UpdateProjectPayload ): Promise => { + const body: Record = {}; + + if (payload.name !== undefined) { + body.name = payload.name; + } + + if (payload.description !== undefined) { + body.description = payload.description ?? null; + } + + if (payload.status !== undefined) { + body.status = payload.status; + } + + if (payload.attributes !== undefined) { + body.attributes = payload.attributes; + } + + if (payload.priorityRules !== undefined) { + body.priorityRules = payload.priorityRules; + } + const response = await apiFetch(`/projects/${projectId}`, { method: 'PATCH', - body: payload + body }); return response.data; diff --git a/client/src/components/item-form-modal.tsx b/client/src/components/item-form-modal.tsx index 564885f..04ee92f 100644 --- a/client/src/components/item-form-modal.tsx +++ b/client/src/components/item-form-modal.tsx @@ -3,7 +3,7 @@ import type { CreateItemPayload, ImportedItemData } from '../api/items'; export interface ItemFormModalProps { isOpen: boolean; - mode: 'url' | 'manual'; + mode: 'url' | 'manual' | 'edit'; initialUrl: string | null; onClose: () => void; onSuccess: () => void; @@ -67,7 +67,8 @@ export function ItemFormModal({ } setError(null); - setSourceUrl(initialUrl ?? ''); + const resolvedSourceUrl = initialData?.sourceUrl ?? initialUrl ?? ''; + setSourceUrl(resolvedSourceUrl); setManufacturer(initialData?.manufacturer ?? ''); setModel(initialData?.model ?? ''); setNote(initialData?.note ?? ''); @@ -160,14 +161,19 @@ export function ItemFormModal({ } try { - await onSubmit({ + const payload: CreateItemPayload = { manufacturer: manufacturer.trim() ? manufacturer.trim() : null, model: model.trim(), - status: 'active', note: note.trim() ? note.trim() : null, attributes: attributesPayload, sourceUrl: sourceUrl.trim() ? sourceUrl.trim() : null - }); + }; + + if (mode !== 'edit') { + payload.status = 'active'; + } + + await onSubmit(payload); onSuccess(); } catch (submitError) { setError((submitError as Error).message); @@ -201,12 +207,18 @@ export function ItemFormModal({

- {mode === 'url' ? 'Review Item Details' : 'Add Item Manually'} + {mode === 'url' + ? 'Review Item Details' + : mode === 'edit' + ? 'Edit Item' + : 'Add Item Manually'}

{mode === 'url' ? 'Double-check the imported information and fill in any gaps.' - : 'Provide the specs and notes for this item.'} + : mode === 'edit' + ? 'Update this item with the latest information.' + : 'Provide the specs and notes for this item.'}

@@ -249,7 +261,7 @@ export function ItemFormModal({ >
(null); const [quickUrl, setQuickUrl] = useState(''); const [quickError, setQuickError] = useState(null); const [isItemModalOpen, setIsItemModalOpen] = useState(false); - const [itemModalMode, setItemModalMode] = useState<'url' | 'manual'>('manual'); + const [itemModalMode, setItemModalMode] = useState<'url' | 'manual' | 'edit'>('manual'); const [modalInitialUrl, setModalInitialUrl] = useState(null); const [importedItemData, setImportedItemData] = useState(null); const [isImportingItem, setIsImportingItem] = useState(false); const [importError, setImportError] = useState(null); const importRequestIdRef = useRef(0); + const [itemBeingEdited, setItemBeingEdited] = useState(null); const project = projectQuery.data; const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]); @@ -57,7 +66,7 @@ export function ProjectDetailPage() { return Array.from(attributeSet); }, [projectAttributes, items]); - const openItemModal = (mode: 'url' | 'manual', initialUrl: string | null) => { + const openItemModal = (mode: 'url' | 'manual' | 'edit', initialUrl: string | null) => { setItemModalMode(mode); setModalInitialUrl(initialUrl); setIsItemModalOpen(true); @@ -68,6 +77,8 @@ export function ProjectDetailPage() { return; } + setItemBeingEdited(null); + const trimmed = quickUrl.trim(); if (!trimmed) { setQuickError('Enter a URL to import item details.'); @@ -82,6 +93,7 @@ export function ProjectDetailPage() { setQuickError(null); setImportError(null); setImportedItemData(null); + setItemBeingEdited(null); const requestId = importRequestIdRef.current + 1; importRequestIdRef.current = requestId; @@ -118,9 +130,20 @@ export function ProjectDetailPage() { setImportedItemData(null); setIsImportingItem(false); importRequestIdRef.current += 1; + setItemBeingEdited(null); openItemModal('manual', quickUrl.trim() || null); }; + const handleEditItem = (item: Item) => { + setQuickError(null); + setImportError(null); + setImportedItemData(null); + setIsImportingItem(false); + importRequestIdRef.current += 1; + setItemBeingEdited(item); + openItemModal('edit', item.sourceUrl ?? null); + }; + const handleModalClose = () => { setIsItemModalOpen(false); setQuickError(null); @@ -128,6 +151,7 @@ export function ProjectDetailPage() { setImportedItemData(null); setIsImportingItem(false); importRequestIdRef.current += 1; + setItemBeingEdited(null); }; const handleModalSuccess = () => { @@ -140,6 +164,7 @@ export function ProjectDetailPage() { setImportedItemData(null); setIsImportingItem(false); importRequestIdRef.current += 1; + setItemBeingEdited(null); }; const handleDescriptionUpdate = async (description: string | null) => { @@ -150,6 +175,26 @@ export function ProjectDetailPage() { await updateProjectMutation.mutateAsync({ attributes }); }; + const handleUpdateItemSubmit = (payload: CreateItemPayload) => { + if (!itemBeingEdited) { + return Promise.reject(new Error('No item selected for editing')); + } + + const updatePayload: UpdateItemPayload = { + manufacturer: payload.manufacturer, + model: payload.model, + note: payload.note, + attributes: payload.attributes, + sourceUrl: payload.sourceUrl, + sourceUrlId: payload.sourceUrlId + }; + + return updateItemMutation.mutateAsync({ + itemId: itemBeingEdited.id, + payload: updatePayload + }); + }; + if (!projectId) { return

Project identifier is missing from the URL.

; } @@ -177,6 +222,31 @@ export function ProjectDetailPage() { setExpandedItemId((current) => (current === itemId ? null : itemId)); }; + const modalInitialData = + itemModalMode === 'url' + ? importedItemData + : itemModalMode === 'edit' && itemBeingEdited + ? { + manufacturer: itemBeingEdited.manufacturer, + model: itemBeingEdited.model, + note: itemBeingEdited.note, + attributes: itemBeingEdited.attributes ?? {}, + sourceUrl: itemBeingEdited.sourceUrl ?? null + } + : null; + + const itemModalSubmit = + itemModalMode === 'edit' ? handleUpdateItemSubmit : createItemMutation.mutateAsync; + + const itemModalIsSubmitting = itemModalMode === 'edit' + ? updateItemMutation.isPending + : createItemMutation.isPending; + + const itemModalInitialDataLoading = + itemModalMode === 'url' ? isImportingItem : false; + + const itemModalInitialDataError = itemModalMode === 'url' ? importError : null; + return (
@@ -211,6 +281,7 @@ export function ProjectDetailPage() { onAddUrl={handleAddUrlClick} onAddManual={handleAddManualClick} isImportingFromUrl={isImportingItem} + onEditItem={handleEditItem} />
); diff --git a/client/src/pages/project-detail/project-items-section.tsx b/client/src/pages/project-detail/project-items-section.tsx index b2adc86..e7ccd97 100644 --- a/client/src/pages/project-detail/project-items-section.tsx +++ b/client/src/pages/project-detail/project-items-section.tsx @@ -1,4 +1,5 @@ import { Fragment, useEffect, useMemo, useState } from 'react'; +import { ExternalLink, Pencil } from 'lucide-react'; import type { Item } from '../../api/items'; import { ItemPricesPanel } from './item-prices-panel'; @@ -15,6 +16,7 @@ interface ProjectItemsSectionProps { onAddUrl: () => void; onAddManual: () => void; isImportingFromUrl: boolean; + onEditItem: (item: Item) => void; } const formatAttributeValue = (value: unknown): string => { @@ -180,7 +182,8 @@ export function ProjectItemsSection({ onQuickUrlChange, onAddUrl, onAddManual, - isImportingFromUrl + isImportingFromUrl, + onEditItem }: ProjectItemsSectionProps) { const baseColumnCount = 2; // Item, Prices (status indicator lives inside item cell) const [showDifferencesOnly, setShowDifferencesOnly] = useState(false); @@ -309,30 +312,39 @@ export function ProjectItemsSection({
- - - - {item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model} - - +
+ + {item.sourceUrl ? ( + + + Open source link + + ) : null} +
{item.note ? ( {item.note} ) : null} - {item.sourceUrl ? ( - - View source - - ) : null}
{visibleAttributes.map((attribute) => ( @@ -357,19 +369,7 @@ export function ProjectItemsSection({ aria-label={priceButtonLabel} title={priceButtonLabel} > - + {priceButtonLabel}
@@ -399,33 +399,40 @@ export function ProjectItemsSection({ return (
-
-
+
+
+ + + {item.sourceUrl ? ( + + + Open source link + + ) : null}
{item.note ? (

Note: {item.note}

) : null} - {item.sourceUrl ? ( - - View source - - ) : null}
{visibleAttributes.map((attribute) => ( @@ -450,14 +457,7 @@ export function ProjectItemsSection({ onClick={() => onToggleItem(item.id)} 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" > - + {priceButtonLabel} diff --git a/client/src/query/projects.ts b/client/src/query/projects.ts index c73e8a5..ed5ea5a 100644 --- a/client/src/query/projects.ts +++ b/client/src/query/projects.ts @@ -11,8 +11,10 @@ import { import { createItem, fetchProjectItems, + updateItem, type CreateItemPayload, - type ProjectItemsResponse + type ProjectItemsResponse, + type UpdateItemPayload } from '../api/items'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -98,3 +100,19 @@ export const useCreateItemMutation = (projectId: string | undefined) => { } }); }; + +export const useUpdateItemMutation = (projectId: string | undefined) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ itemId, payload }: { itemId: string; payload: UpdateItemPayload }) => + updateItem(itemId, payload), + onSuccess: () => { + if (!projectId) { + return; + } + queryClient.invalidateQueries({ queryKey: projectsKeys.items(projectId) }); + queryClient.invalidateQueries({ queryKey: projectsKeys.detail(projectId) }); + } + }); +};