diff --git a/frontend/components/InventoryTable.tsx b/frontend/components/InventoryTable.tsx index c02e3a2d..c6e5d725 100644 --- a/frontend/components/InventoryTable.tsx +++ b/frontend/components/InventoryTable.tsx @@ -5,6 +5,7 @@ import { Item } from '@/lib/db'; import { ChevronRight, ChevronDown, Layers, Package } from 'lucide-react'; import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; +import ItemDetailModal from '@/components/ItemDetailModal'; function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -29,6 +30,22 @@ export default function InventoryTable({ onEditCategory, categoriesList = [] }: InventoryTableProps) { + const [selectedItemDetail, setSelectedItemDetail] = useState(null); + const [refreshTrigger, setRefreshTrigger] = useState(0); + + const handleItemClick = (item: Item) => { + setSelectedItemDetail(item); + onItemClick(item); + }; + + const handleCloseDetail = () => { + setSelectedItemDetail(null); + }; + + const handleItemRefresh = () => { + setRefreshTrigger(prev => prev + 1); + }; + if (categories.length === 0) { return (
@@ -81,7 +98,7 @@ export default function InventoryTable({ {categoryItems.map(item => (
onItemClick(item)} + onClick={() => handleItemClick(item)} className="bg-background/40 border border-slate-800/50 p-4 rounded-2xl flex items-center justify-between hover:border-primary/40 cursor-pointer transition-all active:scale-[0.98]" >
@@ -109,6 +126,14 @@ export default function InventoryTable({
); })} + + {selectedItemDetail && ( + + )} ); } diff --git a/frontend/components/ItemDetailModal.tsx b/frontend/components/ItemDetailModal.tsx new file mode 100644 index 00000000..f4e38796 --- /dev/null +++ b/frontend/components/ItemDetailModal.tsx @@ -0,0 +1,177 @@ +'use client'; + +import React, { useState, useRef } from 'react'; +import { Item } from '@/lib/db'; +import { inventoryApi } from '@/lib/api'; +import ItemPhotoUpload from '@/components/ItemPhotoUpload'; +import { X, Camera, Trash2 } from 'lucide-react'; +import { toast } from 'react-hot-toast'; + +interface ItemDetailModalProps { + item: Item; + onClose: () => void; + onPhotoUpdated?: (photo: { thumbnail_url: string; full_url: string; uploaded_at: string }) => void; + onItemRefresh?: () => void; +} + +export default function ItemDetailModal({ + item, + onClose, + onPhotoUpdated, + onItemRefresh, +}: ItemDetailModalProps) { + const [showPhotoUpload, setShowPhotoUpload] = useState(false); + const [currentPhoto, setCurrentPhoto] = useState<{ thumbnail_url: string; full_url: string } | null>( + item.image_url ? { thumbnail_url: item.image_url, full_url: item.image_url } : null + ); + const [isDeleting, setIsDeleting] = useState(false); + const photoUploadRef = useRef(null); + + const handlePhotoUploadSuccess = (photo: { thumbnail_url: string; full_url: string; uploaded_at: string }) => { + setCurrentPhoto({ thumbnail_url: photo.thumbnail_url, full_url: photo.full_url }); + setShowPhotoUpload(false); + onPhotoUpdated?.(photo); + onItemRefresh?.(); + }; + + const handlePhotoUploadError = (errorMessage: string) => { + toast.error(errorMessage); + }; + + const handleDeletePhoto = async () => { + if (!item.id) return; + + if (!window.confirm('Delete this photo? This cannot be undone.')) { + return; + } + + setIsDeleting(true); + try { + await inventoryApi.deleteItemPhoto(item.id); + setCurrentPhoto(null); + toast.success('Photo deleted successfully'); + onItemRefresh?.(); + } catch (error: any) { + const errorMsg = error?.message || 'Failed to delete photo'; + toast.error(errorMsg); + } finally { + setIsDeleting(false); + } + }; + + return ( +
+
+ {/* Header */} +
+

{item.name}

+ +
+ + {/* Content */} +
+ {/* Item Details */} +
+
+

Category

+

{item.category}

+
+
+

Type

+

{item.type || 'N/A'}

+
+
+

Quantity

+

{item.quantity}

+
+
+

Part Number

+

{item.part_number || 'N/A'}

+
+ {item.barcode && ( +
+

Barcode

+

{item.barcode}

+
+ )} +
+ + {/* Photo Section */} +
+

+ + Photo +

+ + {!showPhotoUpload ? ( + <> + {currentPhoto ? ( +
+
+ {item.name} +
+
+ + +
+
+ ) : ( +
+

No photo uploaded

+ +
+ )} + + ) : ( +
+
+

Upload New Photo

+ +
+ {item.id && ( + + )} +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index f3f60402..f8a661f0 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -272,4 +272,18 @@ export const inventoryApi = { }); return res.data; }, + + // Photo Replacement + replaceItemPhoto: async (itemId: number, formData: FormData) => { + const res = await axiosInstance.put(`/items/${itemId}/photo`, formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + return res.data; + }, + + // Photo Deletion + deleteItemPhoto: async (itemId: number) => { + const res = await axiosInstance.delete(`/items/${itemId}/photo`); + return res.data; + }, }; diff --git a/frontend/public/sw.js b/frontend/public/sw.js index ea154de8..a7519147 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1 +1 @@ -if(!self.define){let e,s={};const n=(n,a)=>(n=new URL(n+".js",a).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(a,t)=>{const c=e||("document"in self?document.currentScript.src:"")||location.href;if(s[c])return;let i={};const r=e=>n(e,c),o={module:{uri:c},exports:i,require:r};s[c]=Promise.all(a.map(e=>o[e]||r(e))).then(e=>(t(...e),i))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"fed9804574e8a1bf041e4a5bba8cc2c1"},{url:"/_next/static/chunks/13.ae2fd9645430ed90.js",revision:"ae2fd9645430ed90"},{url:"/_next/static/chunks/158-dba1f3c61bdd6a36.js",revision:"dba1f3c61bdd6a36"},{url:"/_next/static/chunks/255-4f212684648fcab9.js",revision:"4f212684648fcab9"},{url:"/_next/static/chunks/374-a4ed2a0d861d9cab.js",revision:"a4ed2a0d861d9cab"},{url:"/_next/static/chunks/4bd1b696-c023c6e3521b1417.js",revision:"c023c6e3521b1417"},{url:"/_next/static/chunks/512-ce90bee899e5ddb3.js",revision:"ce90bee899e5ddb3"},{url:"/_next/static/chunks/7cb1fa1f-32fc9056ac653916.js",revision:"32fc9056ac653916"},{url:"/_next/static/chunks/802-838213ff73429b14.js",revision:"838213ff73429b14"},{url:"/_next/static/chunks/814-d4585cd22c8d1c30.js",revision:"d4585cd22c8d1c30"},{url:"/_next/static/chunks/app/_not-found/page-e122f8f409e58055.js",revision:"e122f8f409e58055"},{url:"/_next/static/chunks/app/admin/page-b32529b7a6d4e675.js",revision:"b32529b7a6d4e675"},{url:"/_next/static/chunks/app/inventory/page-f4fc5e180c7a3fe6.js",revision:"f4fc5e180c7a3fe6"},{url:"/_next/static/chunks/app/layout-eb0f519b25043024.js",revision:"eb0f519b25043024"},{url:"/_next/static/chunks/app/login/page-24c8c1eecd6671f9.js",revision:"24c8c1eecd6671f9"},{url:"/_next/static/chunks/app/logs/page-937f3663cbbc2ebb.js",revision:"937f3663cbbc2ebb"},{url:"/_next/static/chunks/app/page-985258059cabf681.js",revision:"985258059cabf681"},{url:"/_next/static/chunks/framework-050c1f32293f7182.js",revision:"050c1f32293f7182"},{url:"/_next/static/chunks/main-aa573100fdde0547.js",revision:"aa573100fdde0547"},{url:"/_next/static/chunks/main-app-613127bff4bcda66.js",revision:"613127bff4bcda66"},{url:"/_next/static/chunks/pages/_app-7d307437aca18ad4.js",revision:"7d307437aca18ad4"},{url:"/_next/static/chunks/pages/_error-cb2a52f75f2162e2.js",revision:"cb2a52f75f2162e2"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-7a9cf6b863b8c67d.js",revision:"7a9cf6b863b8c67d"},{url:"/_next/static/css/9fe8a66751ce0850.css",revision:"9fe8a66751ce0850"},{url:"/_next/static/h4xSZpFO0XjonjqbXxaN3/_buildManifest.js",revision:"31eba5c94d685fe15b2d866310094bce"},{url:"/_next/static/h4xSZpFO0XjonjqbXxaN3/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/custom-logo.png",revision:"d41d8cd98f00b204e9800998ecf8427e"},{url:"/logo.png",revision:"084f394cfe19b7d85abec9878a797795"},{url:"/logo_TFM.png",revision:"084f394cfe19b7d85abec9878a797795"},{url:"/manifest.json",revision:"7b6e42d577201b657b72b8698d44d0b2"},{url:"/network.json",revision:"b00477650779657bbd360477fa7e4fc7"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:n,state:a})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); +if(!self.define){let e,s={};const n=(n,c)=>(n=new URL(n+".js",c).href,s[n]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=s,document.head.appendChild(e)}else e=n,importScripts(n),s()}).then(()=>{let e=s[n];if(!e)throw new Error(`Module ${n} didn’t register its module`);return e}));self.define=(c,a)=>{const t=e||("document"in self?document.currentScript.src:"")||location.href;if(s[t])return;let i={};const r=e=>n(e,t),o={module:{uri:t},exports:i,require:r};s[t]=Promise.all(c.map(e=>o[e]||r(e))).then(e=>(a(...e),i))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"1b3727c98f8e2cd86dc3a295f9544482"},{url:"/_next/static/chunks/13.ae2fd9645430ed90.js",revision:"ae2fd9645430ed90"},{url:"/_next/static/chunks/255-4f212684648fcab9.js",revision:"4f212684648fcab9"},{url:"/_next/static/chunks/374-a4ed2a0d861d9cab.js",revision:"a4ed2a0d861d9cab"},{url:"/_next/static/chunks/4bd1b696-c023c6e3521b1417.js",revision:"c023c6e3521b1417"},{url:"/_next/static/chunks/512-ce90bee899e5ddb3.js",revision:"ce90bee899e5ddb3"},{url:"/_next/static/chunks/687-56a901e025c7ab87.js",revision:"56a901e025c7ab87"},{url:"/_next/static/chunks/7cb1fa1f-32fc9056ac653916.js",revision:"32fc9056ac653916"},{url:"/_next/static/chunks/802-838213ff73429b14.js",revision:"838213ff73429b14"},{url:"/_next/static/chunks/813-7c718080cf544415.js",revision:"7c718080cf544415"},{url:"/_next/static/chunks/814-d4585cd22c8d1c30.js",revision:"d4585cd22c8d1c30"},{url:"/_next/static/chunks/app/_not-found/page-e122f8f409e58055.js",revision:"e122f8f409e58055"},{url:"/_next/static/chunks/app/admin/page-5fcf1b483bdc79f2.js",revision:"5fcf1b483bdc79f2"},{url:"/_next/static/chunks/app/inventory/page-f549a95959cc462c.js",revision:"f549a95959cc462c"},{url:"/_next/static/chunks/app/layout-eb0f519b25043024.js",revision:"eb0f519b25043024"},{url:"/_next/static/chunks/app/login/page-3b1cd952c89d1af9.js",revision:"3b1cd952c89d1af9"},{url:"/_next/static/chunks/app/logs/page-f83bdd8a095dcf5a.js",revision:"f83bdd8a095dcf5a"},{url:"/_next/static/chunks/app/page-3ae308bbe63b826a.js",revision:"3ae308bbe63b826a"},{url:"/_next/static/chunks/framework-050c1f32293f7182.js",revision:"050c1f32293f7182"},{url:"/_next/static/chunks/main-aa573100fdde0547.js",revision:"aa573100fdde0547"},{url:"/_next/static/chunks/main-app-613127bff4bcda66.js",revision:"613127bff4bcda66"},{url:"/_next/static/chunks/pages/_app-7d307437aca18ad4.js",revision:"7d307437aca18ad4"},{url:"/_next/static/chunks/pages/_error-cb2a52f75f2162e2.js",revision:"cb2a52f75f2162e2"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-7a9cf6b863b8c67d.js",revision:"7a9cf6b863b8c67d"},{url:"/_next/static/css/e066c1069c810536.css",revision:"e066c1069c810536"},{url:"/_next/static/fz1dIcLLXp6jUSyLo-O73/_buildManifest.js",revision:"31eba5c94d685fe15b2d866310094bce"},{url:"/_next/static/fz1dIcLLXp6jUSyLo-O73/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/custom-logo.png",revision:"d41d8cd98f00b204e9800998ecf8427e"},{url:"/logo.png",revision:"084f394cfe19b7d85abec9878a797795"},{url:"/logo_TFM.png",revision:"084f394cfe19b7d85abec9878a797795"},{url:"/manifest.json",revision:"7b6e42d577201b657b72b8698d44d0b2"},{url:"/network.json",revision:"b00477650779657bbd360477fa7e4fc7"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:n,state:c})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); diff --git a/frontend/tests/components/ItemDetailModal.test.tsx b/frontend/tests/components/ItemDetailModal.test.tsx new file mode 100644 index 00000000..acea81a5 --- /dev/null +++ b/frontend/tests/components/ItemDetailModal.test.tsx @@ -0,0 +1,381 @@ +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; +import { vi } from 'vitest'; +import ItemDetailModal from '@/components/ItemDetailModal'; +import { Item } from '@/lib/db'; +import * as api from '@/lib/api'; +import { toast } from 'react-hot-toast'; + +vi.mock('react-hot-toast'); +vi.mock('@/lib/api'); +vi.mock('@/components/ItemPhotoUpload', () => ({ + default: ({ itemId, onUploadSuccess, onError }: any) => ( +
+ + +
+ ), +})); + +describe('ItemDetailModal', () => { + const mockItem: Item = { + id: 1, + name: 'Test Component', + category: 'Electronics', + quantity: 10, + part_number: 'PN-001', + barcode: 'BAR-001', + type: 'IC', + specs: 'Test specs', + min_quantity: 5, + image_url: 'http://test.com/photo.jpg', + }; + + const mockItemNoPhoto: Item = { + id: 2, + name: 'No Photo Item', + category: 'Electronics', + quantity: 5, + part_number: 'PN-002', + barcode: 'BAR-002', + type: 'Resistor', + specs: 'No photo specs', + min_quantity: 2, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Rendering', () => { + it('should render modal with item details', () => { + render( + + ); + expect(screen.getByText('Test Component')).toBeInTheDocument(); + expect(screen.getByText('Electronics')).toBeInTheDocument(); + expect(screen.getByText('IC')).toBeInTheDocument(); + }); + + it('should display item photo when image_url exists', () => { + render( + + ); + const photoImg = screen.getByAltText('Test Component') as HTMLImageElement; + expect(photoImg).toBeInTheDocument(); + expect(photoImg.src).toContain('photo.jpg'); + }); + + it('should show "No photo uploaded" when image_url is missing', () => { + render( + + ); + expect(screen.getByText('No photo uploaded')).toBeInTheDocument(); + }); + + it('should render item specifications in detail grid', () => { + render( + + ); + expect(screen.getByText('PN-001')).toBeInTheDocument(); + expect(screen.getByText('BAR-001')).toBeInTheDocument(); + }); + }); + + describe('Photo Replacement Button', () => { + it('should show "Replace Photo" button when photo exists', () => { + render( + + ); + expect(screen.getByText('Replace Photo')).toBeInTheDocument(); + }); + + it('should show "Upload Photo" button when no photo exists', () => { + render( + + ); + expect(screen.getByText('Upload Photo')).toBeInTheDocument(); + }); + + it('should toggle photo upload UI on "Replace Photo" click', () => { + render( + + ); + const replaceButton = screen.getByText('Replace Photo'); + fireEvent.click(replaceButton); + expect(screen.getByTestId('item-photo-upload')).toBeInTheDocument(); + expect(screen.getByText('Upload New Photo')).toBeInTheDocument(); + }); + + it('should hide photo upload UI on cancel', () => { + render( + + ); + fireEvent.click(screen.getByText('Replace Photo')); + expect(screen.getByTestId('item-photo-upload')).toBeInTheDocument(); + + const closeButton = screen.getByLabelText('Cancel upload'); + fireEvent.click(closeButton); + expect(screen.queryByTestId('item-photo-upload')).not.toBeInTheDocument(); + }); + }); + + describe('Photo Upload Success', () => { + it('should update photo and close upload UI on success', async () => { + const onPhotoUpdated = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText('Replace Photo')); + fireEvent.click(screen.getByTestId('mock-upload-success')); + + await waitFor(() => { + expect(onPhotoUpdated).toHaveBeenCalledWith( + expect.objectContaining({ + thumbnail_url: 'http://test.com/thumb.jpg', + }) + ); + }, { timeout: 1000 }); + + expect(screen.queryByTestId('item-photo-upload')).not.toBeInTheDocument(); + }); + + it('should call onItemRefresh callback on upload success', async () => { + const onItemRefresh = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText('Replace Photo')); + fireEvent.click(screen.getByTestId('mock-upload-success')); + + await waitFor(() => { + expect(onItemRefresh).toHaveBeenCalled(); + }, { timeout: 1000 }); + }); + }); + + describe('Photo Upload Error', () => { + it('should keep upload UI open on error', async () => { + render( + + ); + + fireEvent.click(screen.getByText('Replace Photo')); + fireEvent.click(screen.getByTestId('mock-upload-error')); + + await waitFor(() => { + expect(screen.getByTestId('item-photo-upload')).toBeInTheDocument(); + }, { timeout: 1000 }); + }); + }); + + describe('Photo Deletion', () => { + it('should show delete button when photo exists', () => { + render( + + ); + const buttons = screen.getAllByRole('button'); + const hasDeleteButton = buttons.some(b => b.className?.includes('rose-500')); + expect(hasDeleteButton).toBe(true); + }); + + it('should call deleteItemPhoto API on delete confirmation', async () => { + vi.mocked(api.inventoryApi.deleteItemPhoto).mockResolvedValue({ status: 'ok' }); + window.confirm = vi.fn(() => true); + + render( + + ); + + const buttons = screen.getAllByRole('button'); + const deleteButton = buttons.find(b => b.className?.includes('rose-500')); + + if (deleteButton) { + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(api.inventoryApi.deleteItemPhoto).toHaveBeenCalledWith(mockItem.id); + }, { timeout: 1000 }); + } + }); + + it('should show success toast on delete', async () => { + vi.mocked(api.inventoryApi.deleteItemPhoto).mockResolvedValue({ status: 'ok' }); + window.confirm = vi.fn(() => true); + + render( + + ); + + const buttons = screen.getAllByRole('button'); + const deleteButton = buttons.find(b => b.className?.includes('rose-500')); + + if (deleteButton) { + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith('Photo deleted successfully'); + }, { timeout: 1000 }); + } + }); + + it('should not delete without confirmation', async () => { + window.confirm = vi.fn(() => false); + + render( + + ); + + const buttons = screen.getAllByRole('button'); + const deleteButton = buttons.find(b => b.className?.includes('rose-500')); + + if (deleteButton) { + fireEvent.click(deleteButton); + expect(api.inventoryApi.deleteItemPhoto).not.toHaveBeenCalled(); + } + }); + + it('should call onItemRefresh on delete success', async () => { + vi.mocked(api.inventoryApi.deleteItemPhoto).mockResolvedValue({ status: 'ok' }); + window.confirm = vi.fn(() => true); + const onItemRefresh = vi.fn(); + + render( + + ); + + const buttons = screen.getAllByRole('button'); + const deleteButton = buttons.find(b => b.className?.includes('rose-500')); + + if (deleteButton) { + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(onItemRefresh).toHaveBeenCalled(); + }, { timeout: 1000 }); + } + }); + + it('should show error toast on delete failure', async () => { + vi.mocked(api.inventoryApi.deleteItemPhoto).mockRejectedValue( + new Error('Delete failed') + ); + window.confirm = vi.fn(() => true); + + render( + + ); + + const buttons = screen.getAllByRole('button'); + const deleteButton = buttons.find(b => b.className?.includes('rose-500')); + + if (deleteButton) { + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Delete failed'); + }, { timeout: 1000 }); + } + }); + }); + + describe('Modal Controls', () => { + it('should call onClose when close button is clicked', () => { + const onClose = vi.fn(); + render( + + ); + + const closeButton = screen.getByLabelText('Close modal'); + fireEvent.click(closeButton); + expect(onClose).toHaveBeenCalled(); + }); + + it('should be scrollable when content exceeds viewport', () => { + render( + + ); + + const modalContent = screen.getByText('Test Component').closest('div'); + expect(modalContent?.parentElement?.className).toContain('max-h-[90vh]'); + expect(modalContent?.parentElement?.className).toContain('overflow-y-auto'); + }); + }); +});