mirror of
https://github.com/sakaljurgis/best-choice.git
synced 2026-07-08 21:47:40 +00:00
progress
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
"description": "React frontend for the best-choice project",
|
"description": "React frontend for the best-choice project",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.90.2",
|
"@tanstack/react-query": "^5.90.2",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-router-dom": "^7.9.4"
|
"react-router-dom": "^7.9.4"
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export interface ImportedItemData {
|
|||||||
model: string | null;
|
model: string | null;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
attributes: Record<string, unknown>;
|
attributes: Record<string, unknown>;
|
||||||
|
sourceUrl?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createItem = async (
|
export const createItem = async (
|
||||||
@@ -131,3 +132,55 @@ export const importItemFromUrl = async (
|
|||||||
|
|
||||||
return response.data;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -111,9 +111,31 @@ export const updateProject = async (
|
|||||||
projectId: string,
|
projectId: string,
|
||||||
payload: UpdateProjectPayload
|
payload: UpdateProjectPayload
|
||||||
): Promise<Project> => {
|
): 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}`, {
|
const response = await apiFetch<SingleProjectResponse>(`/projects/${projectId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: payload
|
body
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { CreateItemPayload, ImportedItemData } from '../api/items';
|
|||||||
|
|
||||||
export interface ItemFormModalProps {
|
export interface ItemFormModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
mode: 'url' | 'manual';
|
mode: 'url' | 'manual' | 'edit';
|
||||||
initialUrl: string | null;
|
initialUrl: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
@@ -67,7 +67,8 @@ export function ItemFormModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setSourceUrl(initialUrl ?? '');
|
const resolvedSourceUrl = initialData?.sourceUrl ?? initialUrl ?? '';
|
||||||
|
setSourceUrl(resolvedSourceUrl);
|
||||||
setManufacturer(initialData?.manufacturer ?? '');
|
setManufacturer(initialData?.manufacturer ?? '');
|
||||||
setModel(initialData?.model ?? '');
|
setModel(initialData?.model ?? '');
|
||||||
setNote(initialData?.note ?? '');
|
setNote(initialData?.note ?? '');
|
||||||
@@ -160,14 +161,19 @@ export function ItemFormModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSubmit({
|
const payload: CreateItemPayload = {
|
||||||
manufacturer: manufacturer.trim() ? manufacturer.trim() : null,
|
manufacturer: manufacturer.trim() ? manufacturer.trim() : null,
|
||||||
model: model.trim(),
|
model: model.trim(),
|
||||||
status: 'active',
|
|
||||||
note: note.trim() ? note.trim() : null,
|
note: note.trim() ? note.trim() : null,
|
||||||
attributes: attributesPayload,
|
attributes: attributesPayload,
|
||||||
sourceUrl: sourceUrl.trim() ? sourceUrl.trim() : null
|
sourceUrl: sourceUrl.trim() ? sourceUrl.trim() : null
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (mode !== 'edit') {
|
||||||
|
payload.status = 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
await onSubmit(payload);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} catch (submitError) {
|
} catch (submitError) {
|
||||||
setError((submitError as Error).message);
|
setError((submitError as Error).message);
|
||||||
@@ -201,11 +207,17 @@ export function ItemFormModal({
|
|||||||
<div className="flex-1 overflow-y-auto px-8 py-6">
|
<div className="flex-1 overflow-y-auto px-8 py-6">
|
||||||
<header className="mb-6">
|
<header className="mb-6">
|
||||||
<h2 className="text-xl font-semibold text-slate-900">
|
<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>
|
</h2>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
{mode === 'url'
|
{mode === 'url'
|
||||||
? 'Double-check the imported information and fill in any gaps.'
|
? 'Double-check the imported information and fill in any gaps.'
|
||||||
|
: mode === 'edit'
|
||||||
|
? 'Update this item with the latest information.'
|
||||||
: 'Provide the specs and notes for this item.'}
|
: 'Provide the specs and notes for this item.'}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
@@ -249,7 +261,7 @@ export function ItemFormModal({
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2 md:col-span-2">
|
<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">
|
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="modal-item-source-url"
|
id="modal-item-source-url"
|
||||||
|
|||||||
@@ -2,11 +2,18 @@ import { useMemo, useRef, useState } from 'react';
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
useCreateItemMutation,
|
useCreateItemMutation,
|
||||||
|
useUpdateItemMutation,
|
||||||
useProjectItemsQuery,
|
useProjectItemsQuery,
|
||||||
useProjectQuery,
|
useProjectQuery,
|
||||||
useUpdateProjectMutation
|
useUpdateProjectMutation
|
||||||
} from '../../query/projects';
|
} 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 { ItemFormModal } from '../../components/item-form-modal';
|
||||||
import { ProjectHeader } from './project-header';
|
import { ProjectHeader } from './project-header';
|
||||||
import { ProjectDescriptionSection } from './project-description-section';
|
import { ProjectDescriptionSection } from './project-description-section';
|
||||||
@@ -18,18 +25,20 @@ export function ProjectDetailPage() {
|
|||||||
const projectQuery = useProjectQuery(projectId);
|
const projectQuery = useProjectQuery(projectId);
|
||||||
const itemsQuery = useProjectItemsQuery(projectId);
|
const itemsQuery = useProjectItemsQuery(projectId);
|
||||||
const createItemMutation = useCreateItemMutation(projectId);
|
const createItemMutation = useCreateItemMutation(projectId);
|
||||||
|
const updateItemMutation = useUpdateItemMutation(projectId);
|
||||||
const updateProjectMutation = useUpdateProjectMutation(projectId);
|
const updateProjectMutation = useUpdateProjectMutation(projectId);
|
||||||
|
|
||||||
const [expandedItemId, setExpandedItemId] = useState<string | null>(null);
|
const [expandedItemId, setExpandedItemId] = 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);
|
||||||
const [itemModalMode, setItemModalMode] = useState<'url' | 'manual'>('manual');
|
const [itemModalMode, setItemModalMode] = useState<'url' | 'manual' | 'edit'>('manual');
|
||||||
const [modalInitialUrl, setModalInitialUrl] = useState<string | null>(null);
|
const [modalInitialUrl, setModalInitialUrl] = useState<string | null>(null);
|
||||||
const [importedItemData, setImportedItemData] = useState<ImportedItemData | null>(null);
|
const [importedItemData, setImportedItemData] = useState<ImportedItemData | null>(null);
|
||||||
const [isImportingItem, setIsImportingItem] = useState(false);
|
const [isImportingItem, setIsImportingItem] = useState(false);
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
const importRequestIdRef = useRef(0);
|
const importRequestIdRef = useRef(0);
|
||||||
|
const [itemBeingEdited, setItemBeingEdited] = useState<Item | null>(null);
|
||||||
|
|
||||||
const project = projectQuery.data;
|
const project = projectQuery.data;
|
||||||
const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]);
|
const items = useMemo(() => itemsQuery.data?.data ?? [], [itemsQuery.data]);
|
||||||
@@ -57,7 +66,7 @@ export function ProjectDetailPage() {
|
|||||||
return Array.from(attributeSet);
|
return Array.from(attributeSet);
|
||||||
}, [projectAttributes, items]);
|
}, [projectAttributes, items]);
|
||||||
|
|
||||||
const openItemModal = (mode: 'url' | 'manual', initialUrl: string | null) => {
|
const openItemModal = (mode: 'url' | 'manual' | 'edit', initialUrl: string | null) => {
|
||||||
setItemModalMode(mode);
|
setItemModalMode(mode);
|
||||||
setModalInitialUrl(initialUrl);
|
setModalInitialUrl(initialUrl);
|
||||||
setIsItemModalOpen(true);
|
setIsItemModalOpen(true);
|
||||||
@@ -68,6 +77,8 @@ export function ProjectDetailPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setItemBeingEdited(null);
|
||||||
|
|
||||||
const trimmed = quickUrl.trim();
|
const trimmed = quickUrl.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
setQuickError('Enter a URL to import item details.');
|
setQuickError('Enter a URL to import item details.');
|
||||||
@@ -82,6 +93,7 @@ export function ProjectDetailPage() {
|
|||||||
setQuickError(null);
|
setQuickError(null);
|
||||||
setImportError(null);
|
setImportError(null);
|
||||||
setImportedItemData(null);
|
setImportedItemData(null);
|
||||||
|
setItemBeingEdited(null);
|
||||||
|
|
||||||
const requestId = importRequestIdRef.current + 1;
|
const requestId = importRequestIdRef.current + 1;
|
||||||
importRequestIdRef.current = requestId;
|
importRequestIdRef.current = requestId;
|
||||||
@@ -118,9 +130,20 @@ export function ProjectDetailPage() {
|
|||||||
setImportedItemData(null);
|
setImportedItemData(null);
|
||||||
setIsImportingItem(false);
|
setIsImportingItem(false);
|
||||||
importRequestIdRef.current += 1;
|
importRequestIdRef.current += 1;
|
||||||
|
setItemBeingEdited(null);
|
||||||
openItemModal('manual', quickUrl.trim() || 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 = () => {
|
const handleModalClose = () => {
|
||||||
setIsItemModalOpen(false);
|
setIsItemModalOpen(false);
|
||||||
setQuickError(null);
|
setQuickError(null);
|
||||||
@@ -128,6 +151,7 @@ export function ProjectDetailPage() {
|
|||||||
setImportedItemData(null);
|
setImportedItemData(null);
|
||||||
setIsImportingItem(false);
|
setIsImportingItem(false);
|
||||||
importRequestIdRef.current += 1;
|
importRequestIdRef.current += 1;
|
||||||
|
setItemBeingEdited(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModalSuccess = () => {
|
const handleModalSuccess = () => {
|
||||||
@@ -140,6 +164,7 @@ export function ProjectDetailPage() {
|
|||||||
setImportedItemData(null);
|
setImportedItemData(null);
|
||||||
setIsImportingItem(false);
|
setIsImportingItem(false);
|
||||||
importRequestIdRef.current += 1;
|
importRequestIdRef.current += 1;
|
||||||
|
setItemBeingEdited(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDescriptionUpdate = async (description: string | null) => {
|
const handleDescriptionUpdate = async (description: string | null) => {
|
||||||
@@ -150,6 +175,26 @@ export function ProjectDetailPage() {
|
|||||||
await updateProjectMutation.mutateAsync({ attributes });
|
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) {
|
if (!projectId) {
|
||||||
return <p className="text-sm text-red-600">Project identifier is missing from the URL.</p>;
|
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));
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-10">
|
<div className="flex flex-col gap-10">
|
||||||
<section className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
<section className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||||
@@ -211,6 +281,7 @@ export function ProjectDetailPage() {
|
|||||||
onAddUrl={handleAddUrlClick}
|
onAddUrl={handleAddUrlClick}
|
||||||
onAddManual={handleAddManualClick}
|
onAddManual={handleAddManualClick}
|
||||||
isImportingFromUrl={isImportingItem}
|
isImportingFromUrl={isImportingItem}
|
||||||
|
onEditItem={handleEditItem}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ItemFormModal
|
<ItemFormModal
|
||||||
@@ -219,12 +290,12 @@ export function ProjectDetailPage() {
|
|||||||
initialUrl={modalInitialUrl}
|
initialUrl={modalInitialUrl}
|
||||||
onClose={handleModalClose}
|
onClose={handleModalClose}
|
||||||
onSuccess={handleModalSuccess}
|
onSuccess={handleModalSuccess}
|
||||||
onSubmit={createItemMutation.mutateAsync}
|
onSubmit={itemModalSubmit}
|
||||||
isSubmitting={createItemMutation.isPending}
|
isSubmitting={itemModalIsSubmitting}
|
||||||
projectAttributes={projectAttributes}
|
projectAttributes={projectAttributes}
|
||||||
initialData={itemModalMode === 'url' ? importedItemData : null}
|
initialData={modalInitialData}
|
||||||
isInitialDataLoading={itemModalMode === 'url' ? isImportingItem : false}
|
isInitialDataLoading={itemModalInitialDataLoading}
|
||||||
initialDataError={itemModalMode === 'url' ? importError : null}
|
initialDataError={itemModalInitialDataError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
import { Fragment, useEffect, useMemo, useState } from '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';
|
import { ItemPricesPanel } from './item-prices-panel';
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ interface ProjectItemsSectionProps {
|
|||||||
onAddUrl: () => void;
|
onAddUrl: () => void;
|
||||||
onAddManual: () => void;
|
onAddManual: () => void;
|
||||||
isImportingFromUrl: boolean;
|
isImportingFromUrl: boolean;
|
||||||
|
onEditItem: (item: Item) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatAttributeValue = (value: unknown): string => {
|
const formatAttributeValue = (value: unknown): string => {
|
||||||
@@ -180,7 +182,8 @@ export function ProjectItemsSection({
|
|||||||
onQuickUrlChange,
|
onQuickUrlChange,
|
||||||
onAddUrl,
|
onAddUrl,
|
||||||
onAddManual,
|
onAddManual,
|
||||||
isImportingFromUrl
|
isImportingFromUrl,
|
||||||
|
onEditItem
|
||||||
}: ProjectItemsSectionProps) {
|
}: ProjectItemsSectionProps) {
|
||||||
const baseColumnCount = 2; // Item, Prices (status indicator lives inside item cell)
|
const baseColumnCount = 2; // Item, Prices (status indicator lives inside item cell)
|
||||||
const [showDifferencesOnly, setShowDifferencesOnly] = useState(false);
|
const [showDifferencesOnly, setShowDifferencesOnly] = useState(false);
|
||||||
@@ -309,31 +312,40 @@ export function ProjectItemsSection({
|
|||||||
<tr 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">
|
||||||
<span className="inline-flex items-center gap-2 text-slate-900">
|
<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
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={`inline-block h-2.5 w-2.5 rounded-full ${
|
className={`inline-block h-2.5 w-2.5 rounded-full ${
|
||||||
item.status === 'active' ? 'bg-emerald-500' : 'bg-slate-300'
|
item.status === 'active' ? 'bg-emerald-500' : 'bg-slate-300'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<span className="font-semibold">
|
<span className="font-semibold group-hover:underline">
|
||||||
{item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model}
|
{item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</button>
|
||||||
{item.note ? (
|
|
||||||
<span className="text-xs text-slate-500">{item.note}</span>
|
|
||||||
) : null}
|
|
||||||
{item.sourceUrl ? (
|
{item.sourceUrl ? (
|
||||||
<a
|
<a
|
||||||
href={item.sourceUrl}
|
href={item.sourceUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-xs font-medium text-blue-600 hover:text-blue-700"
|
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"
|
||||||
>
|
>
|
||||||
View source
|
<ExternalLink aria-hidden className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Open source link</span>
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{item.note ? (
|
||||||
|
<span className="text-xs text-slate-500">{item.note}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{visibleAttributes.map((attribute) => (
|
{visibleAttributes.map((attribute) => (
|
||||||
<td key={attribute} className="px-4 py-3 align-top text-xs text-slate-600">
|
<td key={attribute} className="px-4 py-3 align-top text-xs text-slate-600">
|
||||||
@@ -357,19 +369,7 @@ export function ProjectItemsSection({
|
|||||||
aria-label={priceButtonLabel}
|
aria-label={priceButtonLabel}
|
||||||
title={priceButtonLabel}
|
title={priceButtonLabel}
|
||||||
>
|
>
|
||||||
<svg
|
<Pencil aria-hidden className="h-5 w-5 text-slate-400 transition hover:text-blue-600" />
|
||||||
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>
|
|
||||||
<span className="sr-only">{priceButtonLabel}</span>
|
<span className="sr-only">{priceButtonLabel}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -399,33 +399,40 @@ export function ProjectItemsSection({
|
|||||||
|
|
||||||
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">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={`inline-block h-2.5 w-2.5 rounded-full ${
|
className={`inline-block h-2.5 w-2.5 rounded-full ${
|
||||||
item.status === 'active' ? 'bg-emerald-500' : 'bg-slate-300'
|
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}
|
{item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model}
|
||||||
</h3>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{item.note ? (
|
|
||||||
<p className="mt-1 text-sm text-slate-600">Note: {item.note}</p>
|
|
||||||
) : null}
|
|
||||||
{item.sourceUrl ? (
|
{item.sourceUrl ? (
|
||||||
<a
|
<a
|
||||||
href={item.sourceUrl}
|
href={item.sourceUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="mt-1 inline-flex text-xs font-medium text-blue-600 hover:text-blue-700"
|
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"
|
||||||
>
|
>
|
||||||
View source
|
<ExternalLink aria-hidden className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Open source link</span>
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.note ? (
|
||||||
|
<p className="mt-1 text-sm text-slate-600">Note: {item.note}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
{visibleAttributes.map((attribute) => (
|
{visibleAttributes.map((attribute) => (
|
||||||
@@ -450,14 +457,7 @@ export function ProjectItemsSection({
|
|||||||
onClick={() => onToggleItem(item.id)}
|
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"
|
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">
|
<Pencil aria-hidden className="h-5 w-5 text-slate-400 transition hover:text-blue-600" />
|
||||||
<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>
|
|
||||||
{priceButtonLabel}
|
{priceButtonLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import {
|
|||||||
import {
|
import {
|
||||||
createItem,
|
createItem,
|
||||||
fetchProjectItems,
|
fetchProjectItems,
|
||||||
|
updateItem,
|
||||||
type CreateItemPayload,
|
type CreateItemPayload,
|
||||||
type ProjectItemsResponse
|
type ProjectItemsResponse,
|
||||||
|
type UpdateItemPayload
|
||||||
} from '../api/items';
|
} from '../api/items';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
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) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user