Add item status modal with note editing

This commit is contained in:
Jurgis Sakalauskas
2025-10-18 10:49:23 +03:00
parent c24a01b282
commit 97010a35f6
5 changed files with 236 additions and 10 deletions
@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react';
import type { Item, ItemStatus } from '@shared/models/item';
interface ItemStatusModalProps {
isOpen: boolean;
item: Item | null;
onClose: () => void;
onSubmit: (payload: { status: ItemStatus; note: string | null }) => Promise<void>;
isSubmitting: boolean;
errorMessage: string | null;
}
export function ItemStatusModal({
isOpen,
item,
onClose,
onSubmit,
isSubmitting,
errorMessage
}: ItemStatusModalProps) {
const [status, setStatus] = useState<ItemStatus>('active');
const [note, setNote] = useState('');
useEffect(() => {
if (!isOpen || !item) {
return;
}
setStatus(item.status);
setNote(item.note ?? '');
}, [isOpen, item]);
if (!isOpen || !item) {
return null;
}
const handleSubmit = async () => {
await onSubmit({ status, note: note.trim().length ? note : null });
};
const isActive = status === 'active';
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 px-4"
role="dialog"
aria-modal="true"
onClick={() => {
if (!isSubmitting) {
onClose();
}
}}
>
<div
className="relative w-full max-w-lg rounded-2xl bg-white shadow-2xl"
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"
disabled={isSubmitting}
>
Close
</button>
<div className="px-8 py-6">
<header className="mb-5 flex flex-col gap-1">
<h2 className="text-xl font-semibold text-slate-900">Item Status &amp; Note</h2>
<p className="text-sm text-slate-500">
Update the activity state and note for {item.manufacturer ? `${item.manufacturer} ${item.model}` : item.model}.
</p>
</header>
<div className="space-y-5">
<label className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
<input
type="checkbox"
className="h-5 w-5 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
checked={isActive}
onChange={(event) => setStatus(event.target.checked ? 'active' : 'rejected')}
disabled={isSubmitting}
/>
<span>
<span className="font-semibold">{isActive ? 'Active' : 'Rejected'}</span>
<span className="block text-xs text-slate-500">
Toggle to mark this item as {isActive ? 'inactive' : 'active'}.
</span>
</span>
</label>
<label className="block text-sm text-slate-700">
<span className="mb-1 block font-semibold">Internal Note</span>
<textarea
value={note}
onChange={(event) => setNote(event.target.value)}
rows={4}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Add context or reminders for this item…"
disabled={isSubmitting}
/>
<span className="mt-1 block text-xs text-slate-500">
Notes are optional. Leave blank to remove the note.
</span>
</label>
</div>
{errorMessage ? (
<p className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
) : null}
<footer className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed"
disabled={isSubmitting}
>
Cancel
</button>
<button
type="button"
onClick={handleSubmit}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-400"
disabled={isSubmitting}
>
{isSubmitting ? 'Saving…' : 'Save Changes'}
</button>
</footer>
</div>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import type { Item } from '@shared/models/item';
import type { Item, ItemStatus } from '@shared/models/item';
import { useQueryClient } from '@tanstack/react-query';
import {
projectsKeys,
@@ -23,6 +23,7 @@ import { ProjectDescriptionSection } from './project-description-section';
import { TrackedAttributesSection } from './tracked-attributes-section';
import { ProjectItemsSection } from './project-items-section';
import { ItemImageGalleryOverlay, type GalleryImage } from './item-image-gallery-overlay';
import { ItemStatusModal } from './item-status-modal';
export function ProjectDetailPage() {
const { projectId } = useParams<{ projectId: string }>();
@@ -48,6 +49,10 @@ export function ProjectDetailPage() {
const [isLoadingItemDetails, setIsLoadingItemDetails] = useState(false);
const [editItemError, setEditItemError] = useState<string | null>(null);
const [isSavingItem, setIsSavingItem] = useState(false);
const [isStatusModalOpen, setIsStatusModalOpen] = useState(false);
const [statusModalItem, setStatusModalItem] = useState<Item | null>(null);
const [isSavingStatus, setIsSavingStatus] = useState(false);
const [statusModalError, setStatusModalError] = useState<string | null>(null);
const [isGalleryOpen, setIsGalleryOpen] = useState(false);
const [galleryItemName, setGalleryItemName] = useState('');
const [galleryImages, setGalleryImages] = useState<GalleryImage[]>([]);
@@ -348,6 +353,52 @@ export function ProjectDetailPage() {
}
};
const handleOpenItemStatusModal = (item: Item) => {
setStatusModalItem(item);
setStatusModalError(null);
setIsStatusModalOpen(true);
};
const handleCloseItemStatusModal = () => {
if (isSavingStatus) {
return;
}
setIsStatusModalOpen(false);
setStatusModalError(null);
setStatusModalItem(null);
};
const handleSubmitItemStatus = async ({
status,
note
}: {
status: ItemStatus;
note: string | null;
}) => {
if (!statusModalItem) {
return;
}
setIsSavingStatus(true);
setStatusModalError(null);
try {
await updateItemMutation.mutateAsync({
itemId: statusModalItem.id,
payload: { status, note }
});
setIsStatusModalOpen(false);
setStatusModalItem(null);
} catch (error) {
const message =
error instanceof Error && error.message.trim().length
? error.message.trim()
: 'Failed to update item.';
setStatusModalError(message);
} finally {
setIsSavingStatus(false);
}
};
const handleModalClose = () => {
setIsItemModalOpen(false);
setQuickError(null);
@@ -597,6 +648,7 @@ export function ProjectDetailPage() {
onAddManual={handleAddManualClick}
isImportingFromUrl={isImportingItem}
onEditItem={handleEditItem}
onEditItemStatus={handleOpenItemStatusModal}
projectId={projectId}
onViewItemImages={handleViewItemImages}
/>
@@ -626,6 +678,15 @@ export function ProjectDetailPage() {
onNext={handleGalleryNext}
onPrevious={handleGalleryPrevious}
/>
<ItemStatusModal
isOpen={isStatusModalOpen}
item={statusModalItem}
onClose={handleCloseItemStatusModal}
onSubmit={handleSubmitItemStatus}
isSubmitting={isSavingStatus || updateItemMutation.isPending}
errorMessage={statusModalError}
/>
</div>
);
}
@@ -11,6 +11,7 @@ interface ProjectItemsDesktopTableProps {
expandedItemId: string | null;
onTogglePrices: (itemId: string) => void;
onEditItem: (item: Item) => void;
onEditItemStatus: (item: Item) => void;
projectId: string | undefined;
onViewItemImages: (item: Item) => void;
showLargeImages: boolean;
@@ -22,6 +23,7 @@ export function ProjectItemsDesktopTable({
expandedItemId,
onTogglePrices,
onEditItem,
onEditItemStatus,
projectId,
onViewItemImages,
showLargeImages
@@ -73,9 +75,9 @@ export function ProjectItemsDesktopTable({
<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"
onClick={() => onEditItemStatus(item)}
className="inline-flex h-6 w-6 items-center justify-center rounded-full border border-transparent transition hover:border-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
title="Toggle active status or edit note"
>
<span
aria-hidden
@@ -83,6 +85,18 @@ export function ProjectItemsDesktopTable({
item.status === 'active' ? 'bg-emerald-500' : 'bg-slate-300'
}`}
/>
<span className="sr-only">
{item.status === 'active'
? `Set ${displayName} as rejected or edit note`
: `Set ${displayName} as active or edit note`}
</span>
</button>
<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 className="font-semibold group-hover:underline">
{displayName}
</span>
@@ -10,6 +10,7 @@ interface ProjectItemsMobileListProps {
expandedItemId: string | null;
onTogglePrices: (itemId: string) => void;
onEditItem: (item: Item) => void;
onEditItemStatus: (item: Item) => void;
projectId: string | undefined;
onViewItemImages: (item: Item) => void;
showLargeImages: boolean;
@@ -21,6 +22,7 @@ export function ProjectItemsMobileList({
expandedItemId,
onTogglePrices,
onEditItem,
onEditItemStatus,
projectId,
onViewItemImages,
showLargeImages
@@ -45,18 +47,30 @@ export function ProjectItemsMobileList({
/>
<div className="flex flex-1 flex-col gap-2">
<div className="flex items-start gap-2">
<button
type="button"
onClick={() => onEditItemStatus(item)}
className="mt-1 inline-flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full border border-transparent transition hover:border-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white"
title="Toggle active status or edit note"
>
<span
aria-hidden
className={`inline-block h-3 w-3 rounded-full ${
item.status === 'active' ? 'bg-emerald-500' : 'bg-slate-300'
}`}
/>
<span className="sr-only">
{item.status === 'active'
? `Set ${displayName} as rejected or edit note`
: `Set ${displayName} as active or edit note`}
</span>
</button>
<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'
}`}
/>
<span className="font-semibold group-hover:underline">{displayName}</span>
</button>
{item.sourceUrl ? (
@@ -18,6 +18,7 @@ interface ProjectItemsSectionProps {
onAddManual: () => void;
isImportingFromUrl: boolean;
onEditItem: (item: Item) => void;
onEditItemStatus: (item: Item) => void;
projectId: string | undefined;
onViewItemImages: (item: Item) => void;
}
@@ -34,6 +35,7 @@ export function ProjectItemsSection({
onAddManual,
isImportingFromUrl,
onEditItem,
onEditItemStatus,
projectId,
onViewItemImages
}: ProjectItemsSectionProps) {
@@ -138,6 +140,7 @@ export function ProjectItemsSection({
expandedItemId={expandedItemId}
onTogglePrices={handleTogglePrices}
onEditItem={onEditItem}
onEditItemStatus={onEditItemStatus}
projectId={projectId}
onViewItemImages={onViewItemImages}
showLargeImages={showLargeImages}
@@ -149,6 +152,7 @@ export function ProjectItemsSection({
expandedItemId={expandedItemId}
onTogglePrices={handleTogglePrices}
onEditItem={onEditItem}
onEditItemStatus={onEditItemStatus}
projectId={projectId}
onViewItemImages={onViewItemImages}
showLargeImages={showLargeImages}