mirror of
https://github.com/sakaljurgis/best-choice.git
synced 2026-07-08 21:47:40 +00:00
Add item status modal with note editing
This commit is contained in:
@@ -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 & 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}
|
||||
|
||||
Reference in New Issue
Block a user