This commit is contained in:
Jurgis Sakalauskas
2025-10-13 18:10:52 +03:00
parent ff18d0daeb
commit 7bc38e9a6e
7 changed files with 253 additions and 76 deletions
+53
View File
@@ -88,6 +88,7 @@ export interface ImportedItemData {
model: string | null;
note: string | null;
attributes: Record<string, unknown>;
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<string, unknown>;
sourceUrl?: string | null;
sourceUrlId?: string | null;
}
export const updateItem = async (
itemId: string,
payload: UpdateItemPayload
): Promise<Item> => {
const body: Record<string, unknown> = {};
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<SingleItemResponse>(`/items/${itemId}`, {
method: 'PATCH',
body
});
return response.data;
};
+23 -1
View File
@@ -111,9 +111,31 @@ export const updateProject = async (
projectId: string,
payload: UpdateProjectPayload
): Promise<Project> => {
const body: Record<string, unknown> = {};
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<SingleProjectResponse>(`/projects/${projectId}`, {
method: 'PATCH',
body: payload
body
});
return response.data;
+20 -8
View File
@@ -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({
<div className="flex-1 overflow-y-auto px-8 py-6">
<header className="mb-6">
<h2 className="text-xl font-semibold text-slate-900">
{mode === 'url' ? 'Review Item Details' : 'Add Item Manually'}
{mode === 'url'
? 'Review Item Details'
: mode === 'edit'
? 'Edit Item'
: 'Add Item Manually'}
</h2>
<p className="text-sm text-slate-500">
{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.'}
</p>
</header>
@@ -249,7 +261,7 @@ export function ItemFormModal({
>
<div className="flex flex-col gap-2 md:col-span-2">
<label className="text-sm font-medium text-slate-700" htmlFor="modal-item-source-url">
Source URL {mode === 'manual' ? '(optional)' : ''}
Source URL {mode === 'url' ? '' : '(optional)'}
</label>
<input
id="modal-item-source-url"
@@ -2,11 +2,18 @@ import { useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import {
useCreateItemMutation,
useUpdateItemMutation,
useProjectItemsQuery,
useProjectQuery,
useUpdateProjectMutation
} from '../../query/projects';
import { importItemFromUrl, type ImportedItemData } from '../../api/items';
import {
importItemFromUrl,
type CreateItemPayload,
type ImportedItemData,
type Item,
type UpdateItemPayload
} from '../../api/items';
import { ItemFormModal } from '../../components/item-form-modal';
import { ProjectHeader } from './project-header';
import { ProjectDescriptionSection } from './project-description-section';
@@ -18,18 +25,20 @@ export function ProjectDetailPage() {
const projectQuery = useProjectQuery(projectId);
const itemsQuery = useProjectItemsQuery(projectId);
const createItemMutation = useCreateItemMutation(projectId);
const updateItemMutation = useUpdateItemMutation(projectId);
const updateProjectMutation = useUpdateProjectMutation(projectId);
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
const [quickUrl, setQuickUrl] = useState('');
const [quickError, setQuickError] = useState<string | null>(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<string | null>(null);
const [importedItemData, setImportedItemData] = useState<ImportedItemData | null>(null);
const [isImportingItem, setIsImportingItem] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const importRequestIdRef = useRef(0);
const [itemBeingEdited, setItemBeingEdited] = useState<Item | null>(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 <p className="text-sm text-red-600">Project identifier is missing from the URL.</p>;
}
@@ -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 (
<div className="flex flex-col gap-10">
<section className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
@@ -211,6 +281,7 @@ export function ProjectDetailPage() {
onAddUrl={handleAddUrlClick}
onAddManual={handleAddManualClick}
isImportingFromUrl={isImportingItem}
onEditItem={handleEditItem}
/>
<ItemFormModal
@@ -219,12 +290,12 @@ export function ProjectDetailPage() {
initialUrl={modalInitialUrl}
onClose={handleModalClose}
onSuccess={handleModalSuccess}
onSubmit={createItemMutation.mutateAsync}
isSubmitting={createItemMutation.isPending}
onSubmit={itemModalSubmit}
isSubmitting={itemModalIsSubmitting}
projectAttributes={projectAttributes}
initialData={itemModalMode === 'url' ? importedItemData : null}
isInitialDataLoading={itemModalMode === 'url' ? isImportingItem : false}
initialDataError={itemModalMode === 'url' ? importError : null}
initialData={modalInitialData}
isInitialDataLoading={itemModalInitialDataLoading}
initialDataError={itemModalInitialDataError}
/>
</div>
);
@@ -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({
<tr className="hover:bg-slate-50">
<td className="px-4 py-3 align-top">
<div className="flex flex-col gap-1">
<span className="inline-flex items-center gap-2 text-slate-900">
<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">
{item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model}
</span>
</span>
<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}
{item.sourceUrl ? (
<a
href={item.sourceUrl}
target="_blank"
rel="noreferrer"
className="text-xs font-medium text-blue-600 hover:text-blue-700"
>
View source
</a>
) : null}
</div>
</td>
{visibleAttributes.map((attribute) => (
@@ -357,19 +369,7 @@ export function ProjectItemsSection({
aria-label={priceButtonLabel}
title={priceButtonLabel}
>
<svg
aria-hidden="true"
className="h-4 w-4"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M14.243 2.929a2 2 0 00-2.828 0L4 10.344V13h2.656l7.415-7.414a2 2 0 000-2.828z" />
<path
fillRule="evenodd"
d="M3.25 15a1.75 1.75 0 001.75 1.75h10A1.75 1.75 0 0016.75 15v-4a.75.75 0 00-1.5 0v4a.25.25 0 01-.25.25h-10a.25.25 0 01-.25-.25v-10a.25.25 0 01.25-.25h4a.75.75 0 000-1.5h-4A1.75 1.75 0 003.25 5v10z"
clipRule="evenodd"
/>
</svg>
<Pencil aria-hidden className="h-5 w-5 text-slate-400 transition hover:text-blue-600" />
<span className="sr-only">{priceButtonLabel}</span>
</button>
</div>
@@ -399,33 +399,40 @@ export function ProjectItemsSection({
return (
<div key={item.id} className="rounded-xl border border-slate-200 p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<div className="flex items-start gap-3">
<button
type="button"
onClick={() => onEditItem(item)}
className="group flex flex-1 items-center gap-2 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'
}`}
/>
<h3 className="text-lg font-semibold text-slate-900">
<span className="text-lg font-semibold group-hover:underline">
{item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model}
</h3>
</div>
</span>
</button>
{item.sourceUrl ? (
<a
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"
title="Open source link"
>
<ExternalLink aria-hidden className="h-5 w-5" />
<span className="sr-only">Open source link</span>
</a>
) : null}
</div>
{item.note ? (
<p className="mt-1 text-sm text-slate-600">Note: {item.note}</p>
) : null}
{item.sourceUrl ? (
<a
href={item.sourceUrl}
target="_blank"
rel="noreferrer"
className="mt-1 inline-flex text-xs font-medium text-blue-600 hover:text-blue-700"
>
View source
</a>
) : null}
<div className="mt-3 space-y-2">
{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"
>
<svg aria-hidden="true" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M14.243 2.929a2 2 0 00-2.828 0L4 10.344V13h2.656l7.415-7.414a2 2 0 000-2.828z" />
<path
fillRule="evenodd"
d="M3.25 15a1.75 1.75 0 001.75 1.75h10A1.75 1.75 0 0016.75 15v-4a.75.75 0 00-1.5 0v4a.25.25 0 01-.25.25h-10a.25.25 0 01-.25-.25v-10a.25.25 0 01.25-.25h4a.75.75 0 000-1.5h-4A1.75 1.75 0 003.25 5v10z"
clipRule="evenodd"
/>
</svg>
<Pencil aria-hidden className="h-5 w-5 text-slate-400 transition hover:text-blue-600" />
{priceButtonLabel}
</button>
+19 -1
View File
@@ -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) });
}
});
};