From 6c1c14c5883ef0fe153b3ee83313ac70cc2aa85f Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 12 Aug 2026 23:58:00 +0700 Subject: [PATCH] feat: enhance image preview components with additional item handling and navigation features --- .../dialogs/image-preview-button.tsx | 7 ++ .../dialogs/image-preview-modal.tsx | 99 ++++++++++++++----- resources/js/components/dialogs/index.ts | 1 + .../js/pages/admin/master/product/index.tsx | 18 ++++ .../admin/master/product/variant/sub-row.tsx | 5 + .../pages/admin/master/raw-material/index.tsx | 21 +++- .../master/raw-material/variant/sub-row.tsx | 5 + 7 files changed, 130 insertions(+), 26 deletions(-) diff --git a/resources/js/components/dialogs/image-preview-button.tsx b/resources/js/components/dialogs/image-preview-button.tsx index 17d5587..994df6c 100644 --- a/resources/js/components/dialogs/image-preview-button.tsx +++ b/resources/js/components/dialogs/image-preview-button.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { ImagePreviewModal } from '@/components/dialogs'; +import type { ImagePreviewItem } from '@/components/dialogs/image-preview-modal'; type ImagePreviewButtonProps = { srcs: string[]; @@ -9,6 +10,8 @@ type ImagePreviewButtonProps = { description?: string; alt?: string; className?: string; + items?: ImagePreviewItem[]; + startIndex?: number; }; export function ImagePreviewButton({ @@ -19,6 +22,8 @@ export function ImagePreviewButton({ description, alt, className = 'h-10 w-10', + items, + startIndex = 0, }: ImagePreviewButtonProps) { const [open, setOpen] = useState(false); @@ -49,6 +54,8 @@ export function ImagePreviewButton({ sources={modalSources ?? srcs} title={title} description={description} + items={items} + startIndex={startIndex} /> ); diff --git a/resources/js/components/dialogs/image-preview-modal.tsx b/resources/js/components/dialogs/image-preview-modal.tsx index cb85748..174cce5 100644 --- a/resources/js/components/dialogs/image-preview-modal.tsx +++ b/resources/js/components/dialogs/image-preview-modal.tsx @@ -1,13 +1,22 @@ -import { ChevronLeft, ChevronRight } from 'lucide-react'; -import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, - DialogTitle, + DialogTitle } from '@/components/ui/dialog'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +export type ImagePreviewItem = { + id?: string | number; + src: string | null; + sources?: string[]; + title?: string; + description?: string; +}; type ImagePreviewModalProps = { open: boolean; @@ -17,6 +26,8 @@ type ImagePreviewModalProps = { description?: string; alt?: string; sources?: string[]; + items?: ImagePreviewItem[]; + startIndex?: number; }; export function ImagePreviewModal({ @@ -27,23 +38,49 @@ export function ImagePreviewModal({ description, alt = 'Preview', sources, + items, + startIndex = 0, }: ImagePreviewModalProps) { - const allImages = sources && sources.length > 0 ? sources : src ? [src] : []; - const [currentIndex, setCurrentIndex] = useState(0); + const [itemIndex, setItemIndex] = useState(startIndex); + const [imageIndex, setImageIndex] = useState(0); - const currentSrc = allImages[currentIndex] ?? src; - const hasMultiple = allImages.length > 1; + useEffect(() => { + if (open) { + setItemIndex(startIndex); + setImageIndex(0); + } + }, [open, startIndex]); - function handlePrev() { - setCurrentIndex((prev) => - prev === 0 ? allImages.length - 1 : prev - 1, - ); + useEffect(() => { + setImageIndex(0); + }, [itemIndex]); + + const isMultiItem = items && items.length > 1; + const currentItem = isMultiItem ? items[itemIndex] : null; + + const allImages = currentItem + ? (currentItem.sources?.length ? currentItem.sources : currentItem.src ? [currentItem.src] : []) + : (sources?.length ? sources : src ? [src] : []); + + const currentSrc = allImages[imageIndex] ?? allImages[0] ?? currentItem?.src ?? src; + const currentTitle = currentItem?.title ?? title; + const currentDescription = currentItem?.description ?? description; + const hasMultipleImages = allImages.length > 1; + + function handleImagePrev() { + setImageIndex((prev) => prev === 0 ? allImages.length - 1 : prev - 1); } - function handleNext() { - setCurrentIndex((prev) => - prev === allImages.length - 1 ? 0 : prev + 1, - ); + function handleImageNext() { + setImageIndex((prev) => prev === allImages.length - 1 ? 0 : prev + 1); + } + + function handleItemPrev() { + setItemIndex((prev) => prev === 0 ? items.length - 1 : prev - 1); + } + + function handleItemNext() { + setItemIndex((prev) => prev === items.length - 1 ? 0 : prev + 1); } return ( @@ -51,17 +88,17 @@ export function ImagePreviewModal({ open={open} onOpenChange={(v) => { if (!v) { -setCurrentIndex(0); -} - + setItemIndex(startIndex); + setImageIndex(0); + } onOpenChange(v); }} > - {(title || description) && ( + {(currentTitle || currentDescription) && ( - {title && {title}} - {description && {description}} + {currentTitle && {currentTitle}} + {currentDescription && {currentDescription}} )} {currentSrc && ( @@ -71,13 +108,13 @@ setCurrentIndex(0); alt={alt} className="max-h-[80vh] w-full rounded-lg object-contain" /> - {hasMultiple && ( + {hasMultipleImages && ( <> @@ -85,17 +122,29 @@ setCurrentIndex(0); variant="secondary" size="icon" className="absolute right-2 top-1/2 h-8 w-8 -translate-y-1/2 rounded-full bg-black/50 text-white hover:bg-black/70" - onClick={handleNext} + onClick={handleImageNext} >
- {currentIndex + 1} / {allImages.length} + {imageIndex + 1} / {allImages.length}
)} )} + {isMultiItem && ( + + + + + )}
); diff --git a/resources/js/components/dialogs/index.ts b/resources/js/components/dialogs/index.ts index b88ffc6..cbb1a11 100644 --- a/resources/js/components/dialogs/index.ts +++ b/resources/js/components/dialogs/index.ts @@ -3,3 +3,4 @@ export { DeleteConfirmDialog } from './delete-confirm-dialog'; export { FormDialog } from './form-dialog'; export { ImagePreviewButton } from './image-preview-button'; export { ImagePreviewModal } from './image-preview-modal'; +export type { ImagePreviewItem } from './image-preview-modal'; diff --git a/resources/js/pages/admin/master/product/index.tsx b/resources/js/pages/admin/master/product/index.tsx index b122c97..2190835 100644 --- a/resources/js/pages/admin/master/product/index.tsx +++ b/resources/js/pages/admin/master/product/index.tsx @@ -4,6 +4,7 @@ import { Plus } from 'lucide-react'; import { useMemo, useState } from 'react'; import { CardTable } from '@/components/data-display'; import { DeleteConfirmDialog } from '@/components/dialogs'; +import type { ImagePreviewItem } from '@/components/dialogs'; import { FilterPopover } from '@/components/data-display'; import { useCardTableExpand } from '@/components/hooks/use-card-table-expand'; import { PageHeader } from '@/components/layout'; @@ -103,6 +104,22 @@ export default function ProductIndex({ products, categories, productNames, filte [productNames], ); + const allPreviewItems: ImagePreviewItem[] = useMemo( + () => + products.data.flatMap((product) => + (product.product_variants ?? []) + .filter((v) => v.photo_urls?.length > 0) + .map((v) => ({ + id: `${product.id}-${v.id}`, + src: v.photo_urls[0], + sources: v.photo_urls, + title: v.name, + description: `Stok Bagus: ${v.formatted_stock} | Stok Reject: ${v.formatted_reject_stock} | Stok Ecer: ${v.formatted_retail_stock}`, + })) + ), + [products.data], + ); + const selectedCategory = useMemo( () => categories.find((c) => String(c.id) === filters.category) ?? null, [categories, filters.category], @@ -311,6 +328,7 @@ export default function ProductIndex({ products, categories, productNames, filte renderSubContent={(product) => ( { router.visit( variantEdit.url({ diff --git a/resources/js/pages/admin/master/product/variant/sub-row.tsx b/resources/js/pages/admin/master/product/variant/sub-row.tsx index f59fb48..dcfea44 100644 --- a/resources/js/pages/admin/master/product/variant/sub-row.tsx +++ b/resources/js/pages/admin/master/product/variant/sub-row.tsx @@ -2,6 +2,7 @@ import { router } from '@inertiajs/react'; import { ArrowRightLeft, Pencil, ScrollText, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { ImagePreviewButton } from '@/components/dialogs'; +import type { ImagePreviewItem } from '@/components/dialogs'; import { RowActions } from '@/components/data-display'; import { Table, @@ -20,10 +21,12 @@ import { TransferStockDialog } from './transfer-stock-dialog'; export function VariantSubRow({ product, + allPreviewItems, onEditVariant, onDeleteVariantClick, }: { product: Product; + allPreviewItems: ImagePreviewItem[]; onEditVariant: (product: Product, variant: ProductVariant) => void; onDeleteVariantClick: (product: Product, variant: ProductVariant) => void; }) { @@ -94,6 +97,8 @@ export function VariantSubRow({ modalSrcs={variant.photo_urls} title={variant.name} description={`Stok Bagus: ${variant.formatted_stock} | Stok Reject: ${variant.formatted_reject_stock} | Stok Ecer: ${variant.formatted_retail_stock}`} + items={allPreviewItems} + startIndex={allPreviewItems.findIndex((item) => item.id === `${product.id}-${variant.id}`)} /> ) : (
diff --git a/resources/js/pages/admin/master/raw-material/index.tsx b/resources/js/pages/admin/master/raw-material/index.tsx index a5ee762..d9207f5 100644 --- a/resources/js/pages/admin/master/raw-material/index.tsx +++ b/resources/js/pages/admin/master/raw-material/index.tsx @@ -3,6 +3,7 @@ import { Plus } from 'lucide-react'; import { useMemo, useState } from 'react'; import { CardTable } from '@/components/data-display'; import { DeleteConfirmDialog } from '@/components/dialogs'; +import type { ImagePreviewItem } from '@/components/dialogs'; import { FilterPopover } from '@/components/data-display'; import { useCardTableExpand } from '@/components/hooks/use-card-table-expand'; import { PageHeader } from '@/components/layout'; @@ -91,6 +92,21 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte [rawMaterialNames], ); + const allPreviewItems: ImagePreviewItem[] = useMemo( + () => + rawMaterials.data.flatMap((rm) => + (rm.raw_material_prices ?? []) + .filter((v) => v.photo_url) + .map((v) => ({ + id: `${rm.id}-${v.id}`, + src: v.photo_url, + title: v.variant, + description: `Stok: ${v.formatted_stock}`, + })) + ), + [rawMaterials.data], + ); + function handleDelete() { if (!deleting) { return; @@ -246,7 +262,10 @@ export default function RawMaterialIndex({ rawMaterials, rawMaterialNames, filte /> )} renderSubContent={(rawMaterial) => ( - + )} /> diff --git a/resources/js/pages/admin/master/raw-material/variant/sub-row.tsx b/resources/js/pages/admin/master/raw-material/variant/sub-row.tsx index 8381127..16aa7b4 100644 --- a/resources/js/pages/admin/master/raw-material/variant/sub-row.tsx +++ b/resources/js/pages/admin/master/raw-material/variant/sub-row.tsx @@ -3,6 +3,7 @@ import { Pencil, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { ConfirmDialog } from '@/components/dialogs'; import { ImagePreviewButton } from '@/components/dialogs'; +import type { ImagePreviewItem } from '@/components/dialogs'; import { RowActions } from '@/components/data-display'; import { Table, @@ -23,8 +24,10 @@ import type { RawMaterial, RawMaterialVariant } from '../columns'; export function RawMaterialVariantSubRow({ rawMaterial, + allPreviewItems, }: { rawMaterial: RawMaterial; + allPreviewItems: ImagePreviewItem[]; }) { const { can } = useCan(); const variants = rawMaterial.raw_material_prices ?? []; @@ -91,6 +94,8 @@ export function RawMaterialVariantSubRow({ modalSrc={variant.photo_url} title={variant.variant} description={`Stok: ${variant.formatted_stock}`} + items={allPreviewItems} + startIndex={allPreviewItems.findIndex((item) => item.id === `${rawMaterial.id}-${variant.id}`)} /> ) : (