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 { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
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 { useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
projectsKeys,
|
projectsKeys,
|
||||||
@@ -23,6 +23,7 @@ import { ProjectDescriptionSection } from './project-description-section';
|
|||||||
import { TrackedAttributesSection } from './tracked-attributes-section';
|
import { TrackedAttributesSection } from './tracked-attributes-section';
|
||||||
import { ProjectItemsSection } from './project-items-section';
|
import { ProjectItemsSection } from './project-items-section';
|
||||||
import { ItemImageGalleryOverlay, type GalleryImage } from './item-image-gallery-overlay';
|
import { ItemImageGalleryOverlay, type GalleryImage } from './item-image-gallery-overlay';
|
||||||
|
import { ItemStatusModal } from './item-status-modal';
|
||||||
|
|
||||||
export function ProjectDetailPage() {
|
export function ProjectDetailPage() {
|
||||||
const { projectId } = useParams<{ projectId: string }>();
|
const { projectId } = useParams<{ projectId: string }>();
|
||||||
@@ -48,6 +49,10 @@ export function ProjectDetailPage() {
|
|||||||
const [isLoadingItemDetails, setIsLoadingItemDetails] = useState(false);
|
const [isLoadingItemDetails, setIsLoadingItemDetails] = useState(false);
|
||||||
const [editItemError, setEditItemError] = useState<string | null>(null);
|
const [editItemError, setEditItemError] = useState<string | null>(null);
|
||||||
const [isSavingItem, setIsSavingItem] = useState(false);
|
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 [isGalleryOpen, setIsGalleryOpen] = useState(false);
|
||||||
const [galleryItemName, setGalleryItemName] = useState('');
|
const [galleryItemName, setGalleryItemName] = useState('');
|
||||||
const [galleryImages, setGalleryImages] = useState<GalleryImage[]>([]);
|
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 = () => {
|
const handleModalClose = () => {
|
||||||
setIsItemModalOpen(false);
|
setIsItemModalOpen(false);
|
||||||
setQuickError(null);
|
setQuickError(null);
|
||||||
@@ -597,6 +648,7 @@ export function ProjectDetailPage() {
|
|||||||
onAddManual={handleAddManualClick}
|
onAddManual={handleAddManualClick}
|
||||||
isImportingFromUrl={isImportingItem}
|
isImportingFromUrl={isImportingItem}
|
||||||
onEditItem={handleEditItem}
|
onEditItem={handleEditItem}
|
||||||
|
onEditItemStatus={handleOpenItemStatusModal}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onViewItemImages={handleViewItemImages}
|
onViewItemImages={handleViewItemImages}
|
||||||
/>
|
/>
|
||||||
@@ -626,6 +678,15 @@ export function ProjectDetailPage() {
|
|||||||
onNext={handleGalleryNext}
|
onNext={handleGalleryNext}
|
||||||
onPrevious={handleGalleryPrevious}
|
onPrevious={handleGalleryPrevious}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ItemStatusModal
|
||||||
|
isOpen={isStatusModalOpen}
|
||||||
|
item={statusModalItem}
|
||||||
|
onClose={handleCloseItemStatusModal}
|
||||||
|
onSubmit={handleSubmitItemStatus}
|
||||||
|
isSubmitting={isSavingStatus || updateItemMutation.isPending}
|
||||||
|
errorMessage={statusModalError}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface ProjectItemsDesktopTableProps {
|
|||||||
expandedItemId: string | null;
|
expandedItemId: string | null;
|
||||||
onTogglePrices: (itemId: string) => void;
|
onTogglePrices: (itemId: string) => void;
|
||||||
onEditItem: (item: Item) => void;
|
onEditItem: (item: Item) => void;
|
||||||
|
onEditItemStatus: (item: Item) => void;
|
||||||
projectId: string | undefined;
|
projectId: string | undefined;
|
||||||
onViewItemImages: (item: Item) => void;
|
onViewItemImages: (item: Item) => void;
|
||||||
showLargeImages: boolean;
|
showLargeImages: boolean;
|
||||||
@@ -22,6 +23,7 @@ export function ProjectItemsDesktopTable({
|
|||||||
expandedItemId,
|
expandedItemId,
|
||||||
onTogglePrices,
|
onTogglePrices,
|
||||||
onEditItem,
|
onEditItem,
|
||||||
|
onEditItemStatus,
|
||||||
projectId,
|
projectId,
|
||||||
onViewItemImages,
|
onViewItemImages,
|
||||||
showLargeImages
|
showLargeImages
|
||||||
@@ -73,9 +75,9 @@ export function ProjectItemsDesktopTable({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onEditItem(item)}
|
onClick={() => onEditItemStatus(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"
|
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="Edit item details"
|
title="Toggle active status or edit note"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
@@ -83,6 +85,18 @@ export function ProjectItemsDesktopTable({
|
|||||||
item.status === 'active' ? 'bg-emerald-500' : 'bg-slate-300'
|
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">
|
<span className="font-semibold group-hover:underline">
|
||||||
{displayName}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface ProjectItemsMobileListProps {
|
|||||||
expandedItemId: string | null;
|
expandedItemId: string | null;
|
||||||
onTogglePrices: (itemId: string) => void;
|
onTogglePrices: (itemId: string) => void;
|
||||||
onEditItem: (item: Item) => void;
|
onEditItem: (item: Item) => void;
|
||||||
|
onEditItemStatus: (item: Item) => void;
|
||||||
projectId: string | undefined;
|
projectId: string | undefined;
|
||||||
onViewItemImages: (item: Item) => void;
|
onViewItemImages: (item: Item) => void;
|
||||||
showLargeImages: boolean;
|
showLargeImages: boolean;
|
||||||
@@ -21,6 +22,7 @@ export function ProjectItemsMobileList({
|
|||||||
expandedItemId,
|
expandedItemId,
|
||||||
onTogglePrices,
|
onTogglePrices,
|
||||||
onEditItem,
|
onEditItem,
|
||||||
|
onEditItemStatus,
|
||||||
projectId,
|
projectId,
|
||||||
onViewItemImages,
|
onViewItemImages,
|
||||||
showLargeImages
|
showLargeImages
|
||||||
@@ -45,18 +47,30 @@ export function ProjectItemsMobileList({
|
|||||||
/>
|
/>
|
||||||
<div className="flex flex-1 flex-col gap-2">
|
<div className="flex flex-1 flex-col gap-2">
|
||||||
<div className="flex items-start 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onEditItem(item)}
|
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"
|
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"
|
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>
|
<span className="font-semibold group-hover:underline">{displayName}</span>
|
||||||
</button>
|
</button>
|
||||||
{item.sourceUrl ? (
|
{item.sourceUrl ? (
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface ProjectItemsSectionProps {
|
|||||||
onAddManual: () => void;
|
onAddManual: () => void;
|
||||||
isImportingFromUrl: boolean;
|
isImportingFromUrl: boolean;
|
||||||
onEditItem: (item: Item) => void;
|
onEditItem: (item: Item) => void;
|
||||||
|
onEditItemStatus: (item: Item) => void;
|
||||||
projectId: string | undefined;
|
projectId: string | undefined;
|
||||||
onViewItemImages: (item: Item) => void;
|
onViewItemImages: (item: Item) => void;
|
||||||
}
|
}
|
||||||
@@ -34,6 +35,7 @@ export function ProjectItemsSection({
|
|||||||
onAddManual,
|
onAddManual,
|
||||||
isImportingFromUrl,
|
isImportingFromUrl,
|
||||||
onEditItem,
|
onEditItem,
|
||||||
|
onEditItemStatus,
|
||||||
projectId,
|
projectId,
|
||||||
onViewItemImages
|
onViewItemImages
|
||||||
}: ProjectItemsSectionProps) {
|
}: ProjectItemsSectionProps) {
|
||||||
@@ -138,6 +140,7 @@ export function ProjectItemsSection({
|
|||||||
expandedItemId={expandedItemId}
|
expandedItemId={expandedItemId}
|
||||||
onTogglePrices={handleTogglePrices}
|
onTogglePrices={handleTogglePrices}
|
||||||
onEditItem={onEditItem}
|
onEditItem={onEditItem}
|
||||||
|
onEditItemStatus={onEditItemStatus}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onViewItemImages={onViewItemImages}
|
onViewItemImages={onViewItemImages}
|
||||||
showLargeImages={showLargeImages}
|
showLargeImages={showLargeImages}
|
||||||
@@ -149,6 +152,7 @@ export function ProjectItemsSection({
|
|||||||
expandedItemId={expandedItemId}
|
expandedItemId={expandedItemId}
|
||||||
onTogglePrices={handleTogglePrices}
|
onTogglePrices={handleTogglePrices}
|
||||||
onEditItem={onEditItem}
|
onEditItem={onEditItem}
|
||||||
|
onEditItemStatus={onEditItemStatus}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onViewItemImages={onViewItemImages}
|
onViewItemImages={onViewItemImages}
|
||||||
showLargeImages={showLargeImages}
|
showLargeImages={showLargeImages}
|
||||||
|
|||||||
Reference in New Issue
Block a user