From 4de2693ed4e5880bb5ac52ec9e8fbdea9737eaea Mon Sep 17 00:00:00 2001 From: Josue19-08 Date: Fri, 23 Jan 2026 12:26:24 -0600 Subject: [PATCH 01/12] feat(types): add TypeScript types for Asset management Add comprehensive type definitions for assets including: - Asset, AssetStatus, AssetCondition enums - AssetHistoryEvent, AssetDocument, MaintenanceRecord, AssetNote interfaces - Input types for mutations (transfer, maintenance, notes) Co-Authored-By: Claude Opus 4.5 --- frontend/lib/query/types/asset.ts | 152 ++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 frontend/lib/query/types/asset.ts diff --git a/frontend/lib/query/types/asset.ts b/frontend/lib/query/types/asset.ts new file mode 100644 index 0000000..7ed789e --- /dev/null +++ b/frontend/lib/query/types/asset.ts @@ -0,0 +1,152 @@ +/** + * TypeScript types for Asset management + */ + +export enum AssetStatus { + ACTIVE = 'ACTIVE', + ASSIGNED = 'ASSIGNED', + MAINTENANCE = 'MAINTENANCE', + RETIRED = 'RETIRED', +} + +export enum AssetCondition { + NEW = 'NEW', + GOOD = 'GOOD', + FAIR = 'FAIR', + POOR = 'POOR', + DAMAGED = 'DAMAGED', +} + +export interface AssetCategory { + id: string; + name: string; + description?: string; +} + +export interface Department { + id: string; + name: string; +} + +export interface AssetUser { + id: string; + name: string; + email: string; +} + +export interface Asset { + id: string; + assetId: string; + name: string; + description: string | null; + category: AssetCategory; + serialNumber: string | null; + purchaseDate: string | null; + purchasePrice: number | null; + currentValue: number | null; + warrantyExpiration: string | null; + status: AssetStatus; + condition: AssetCondition; + department: Department; + location: string | null; + assignedTo: AssetUser | null; + imageUrls: string[] | null; + customFields: Record | null; + tags: string[] | null; + manufacturer: string | null; + model: string | null; + barcode: string | null; + qrCode: string | null; + notes: string | null; + createdAt: string; + updatedAt: string; + createdBy: AssetUser | null; + updatedBy: AssetUser | null; +} + +export type AssetHistoryAction = + | 'CREATED' + | 'UPDATED' + | 'STATUS_CHANGED' + | 'TRANSFERRED' + | 'MAINTENANCE' + | 'NOTE_ADDED' + | 'DOCUMENT_UPLOADED'; + +export interface AssetHistoryEvent { + id: string; + assetId: string; + action: AssetHistoryAction; + description: string; + previousValue: Record | null; + newValue: Record | null; + performedBy: AssetUser; + createdAt: string; +} + +export interface AssetDocument { + id: string; + assetId: string; + name: string; + type: string; + url: string; + size: number; + uploadedBy: AssetUser; + createdAt: string; +} + +export type MaintenanceType = 'PREVENTIVE' | 'CORRECTIVE' | 'SCHEDULED'; +export type MaintenanceStatus = 'SCHEDULED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED'; + +export interface MaintenanceRecord { + id: string; + assetId: string; + type: MaintenanceType; + description: string; + scheduledDate: string; + completedDate: string | null; + cost: number | null; + performedBy: AssetUser | null; + notes: string | null; + status: MaintenanceStatus; + createdAt: string; +} + +export interface AssetNote { + id: string; + assetId: string; + content: string; + createdBy: AssetUser; + createdAt: string; + updatedAt: string; +} + +// Input types for mutations +export interface UpdateAssetStatusInput { + status: AssetStatus; +} + +export interface TransferAssetInput { + departmentId: string; + assignedToId?: string; + location?: string; + notes?: string; +} + +export interface CreateMaintenanceInput { + type: MaintenanceType; + description: string; + scheduledDate: string; + notes?: string; +} + +export interface CreateNoteInput { + content: string; +} + +export interface AssetHistoryFilters { + action?: AssetHistoryAction; + startDate?: string; + endDate?: string; + search?: string; +} From d8a4d92d4cb7e4e77c41d556dc8ed463c0692eb6 Mon Sep 17 00:00:00 2001 From: Josue19-08 Date: Fri, 23 Jan 2026 12:26:29 -0600 Subject: [PATCH 02/12] feat(query): extend React Query keys for assets module Add query keys for: - Asset details, lists, history, documents, maintenance, notes - Departments and users lists for transfer modal Co-Authored-By: Claude Opus 4.5 --- frontend/lib/query/keys.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/frontend/lib/query/keys.ts b/frontend/lib/query/keys.ts index 8475a60..c6647e4 100644 --- a/frontend/lib/query/keys.ts +++ b/frontend/lib/query/keys.ts @@ -7,4 +7,24 @@ export const queryKeys = { register: ['auth', 'register'] as const, login: ['auth', 'login'] as const, }, + assets: { + all: ['assets'] as const, + lists: () => [...queryKeys.assets.all, 'list'] as const, + list: (filters: Record) => [...queryKeys.assets.lists(), filters] as const, + details: () => [...queryKeys.assets.all, 'detail'] as const, + detail: (id: string) => [...queryKeys.assets.details(), id] as const, + history: (id: string, filters?: Record) => + [...queryKeys.assets.detail(id), 'history', filters] as const, + documents: (id: string) => [...queryKeys.assets.detail(id), 'documents'] as const, + maintenance: (id: string) => [...queryKeys.assets.detail(id), 'maintenance'] as const, + notes: (id: string) => [...queryKeys.assets.detail(id), 'notes'] as const, + }, + departments: { + all: ['departments'] as const, + list: () => [...queryKeys.departments.all, 'list'] as const, + }, + users: { + all: ['users'] as const, + list: () => [...queryKeys.users.all, 'list'] as const, + }, } as const; \ No newline at end of file From 5d0c165d2f000db305b59075f84f3479afe97916 Mon Sep 17 00:00:00 2001 From: Josue19-08 Date: Fri, 23 Jan 2026 12:26:34 -0600 Subject: [PATCH 03/12] feat(api): add Asset API client with all CRUD operations Implement API client methods for: - Get asset details and update status - Transfer asset between departments - Asset history, documents, maintenance records, notes - File upload for documents Co-Authored-By: Claude Opus 4.5 --- frontend/lib/api/assets.ts | 164 +++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 frontend/lib/api/assets.ts diff --git a/frontend/lib/api/assets.ts b/frontend/lib/api/assets.ts new file mode 100644 index 0000000..e517234 --- /dev/null +++ b/frontend/lib/api/assets.ts @@ -0,0 +1,164 @@ +import { + Asset, + AssetHistoryEvent, + AssetDocument, + MaintenanceRecord, + AssetNote, + UpdateAssetStatusInput, + TransferAssetInput, + CreateMaintenanceInput, + CreateNoteInput, + AssetHistoryFilters, + Department, + AssetUser, +} from '../query/types/asset'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api'; + +function getAuthToken(): string | null { + if (typeof window === 'undefined') return null; + const match = document.cookie.match(/auth-token=([^;]+)/); + return match ? match[1] : null; +} + +/** + * API Client for Asset management + * Handles all asset-related HTTP requests with proper error handling + */ +class AssetApiClient { + private async request(endpoint: string, options?: RequestInit): Promise { + const url = `${API_BASE_URL}${endpoint}`; + const token = getAuthToken(); + + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }), + ...options?.headers, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ + message: 'An error occurred', + statusCode: response.status, + })); + throw error; + } + + // Handle empty responses (204 No Content) + if (response.status === 204) { + return undefined as T; + } + + return response.json(); + } + + // Asset CRUD + async getAsset(id: string): Promise { + return this.request(`/assets/${id}`); + } + + async updateAssetStatus(id: string, data: UpdateAssetStatusInput): Promise { + return this.request(`/assets/${id}/status`, { + method: 'PATCH', + body: JSON.stringify(data), + }); + } + + async transferAsset(id: string, data: TransferAssetInput): Promise { + return this.request(`/assets/${id}/transfer`, { + method: 'POST', + body: JSON.stringify(data), + }); + } + + async deleteAsset(id: string): Promise { + return this.request(`/assets/${id}`, { method: 'DELETE' }); + } + + // History + async getAssetHistory(id: string, filters?: AssetHistoryFilters): Promise { + const params = new URLSearchParams(); + if (filters?.action) params.append('action', filters.action); + if (filters?.startDate) params.append('startDate', filters.startDate); + if (filters?.endDate) params.append('endDate', filters.endDate); + if (filters?.search) params.append('search', filters.search); + + const query = params.toString() ? `?${params.toString()}` : ''; + return this.request(`/assets/${id}/history${query}`); + } + + // Documents + async getAssetDocuments(id: string): Promise { + return this.request(`/assets/${id}/documents`); + } + + async uploadDocument(id: string, file: File, name?: string): Promise { + const formData = new FormData(); + formData.append('file', file); + if (name) formData.append('name', name); + + const token = getAuthToken(); + const response = await fetch(`${API_BASE_URL}/assets/${id}/documents`, { + method: 'POST', + headers: { + ...(token && { Authorization: `Bearer ${token}` }), + }, + body: formData, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ + message: 'Upload failed', + statusCode: response.status, + })); + throw error; + } + + return response.json(); + } + + async deleteDocument(assetId: string, documentId: string): Promise { + return this.request(`/assets/${assetId}/documents/${documentId}`, { + method: 'DELETE', + }); + } + + // Maintenance + async getMaintenanceRecords(id: string): Promise { + return this.request(`/assets/${id}/maintenance`); + } + + async createMaintenanceRecord(id: string, data: CreateMaintenanceInput): Promise { + return this.request(`/assets/${id}/maintenance`, { + method: 'POST', + body: JSON.stringify(data), + }); + } + + // Notes + async getAssetNotes(id: string): Promise { + return this.request(`/assets/${id}/notes`); + } + + async createNote(id: string, data: CreateNoteInput): Promise { + return this.request(`/assets/${id}/notes`, { + method: 'POST', + body: JSON.stringify(data), + }); + } + + // Departments (for transfer modal) + async getDepartments(): Promise { + return this.request('/departments'); + } + + // Users (for transfer modal) + async getUsers(): Promise { + return this.request('/users'); + } +} + +export const assetApiClient = new AssetApiClient(); From 787fe2db7561a53b588e30b90f4b67b490d633be Mon Sep 17 00:00:00 2001 From: Josue19-08 Date: Fri, 23 Jan 2026 12:26:39 -0600 Subject: [PATCH 04/12] feat(hooks): add React Query hooks for asset operations Implement custom hooks with cache invalidation: - useAsset, useAssetHistory, useAssetDocuments - useMaintenanceRecords, useAssetNotes - Mutations: useUpdateAssetStatus, useTransferAsset, useDeleteAsset - useUploadDocument, useCreateMaintenanceRecord, useCreateNote Co-Authored-By: Claude Opus 4.5 --- frontend/lib/query/hooks/useAsset.ts | 218 +++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 frontend/lib/query/hooks/useAsset.ts diff --git a/frontend/lib/query/hooks/useAsset.ts b/frontend/lib/query/hooks/useAsset.ts new file mode 100644 index 0000000..d2db574 --- /dev/null +++ b/frontend/lib/query/hooks/useAsset.ts @@ -0,0 +1,218 @@ +import { + useQuery, + useMutation, + useQueryClient, + UseQueryOptions, + UseMutationOptions, +} from '@tanstack/react-query'; +import { assetApiClient } from '@/lib/api/assets'; +import { queryKeys } from '../keys'; +import { + Asset, + AssetHistoryEvent, + AssetDocument, + MaintenanceRecord, + AssetNote, + UpdateAssetStatusInput, + TransferAssetInput, + CreateMaintenanceInput, + CreateNoteInput, + AssetHistoryFilters, + Department, + AssetUser, +} from '../types/asset'; +import { ApiError } from '../types'; + +// Queries +export function useAsset( + id: string, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: queryKeys.assets.detail(id), + queryFn: () => assetApiClient.getAsset(id), + enabled: !!id, + ...options, + }); +} + +export function useAssetHistory( + id: string, + filters?: AssetHistoryFilters, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: queryKeys.assets.history(id, filters as Record | undefined), + queryFn: () => assetApiClient.getAssetHistory(id, filters), + enabled: !!id, + ...options, + }); +} + +export function useAssetDocuments( + id: string, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: queryKeys.assets.documents(id), + queryFn: () => assetApiClient.getAssetDocuments(id), + enabled: !!id, + ...options, + }); +} + +export function useMaintenanceRecords( + id: string, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: queryKeys.assets.maintenance(id), + queryFn: () => assetApiClient.getMaintenanceRecords(id), + enabled: !!id, + ...options, + }); +} + +export function useAssetNotes( + id: string, + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: queryKeys.assets.notes(id), + queryFn: () => assetApiClient.getAssetNotes(id), + enabled: !!id, + ...options, + }); +} + +export function useDepartments( + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: queryKeys.departments.list(), + queryFn: () => assetApiClient.getDepartments(), + ...options, + }); +} + +export function useUsers( + options?: Omit, 'queryKey' | 'queryFn'> +) { + return useQuery({ + queryKey: queryKeys.users.list(), + queryFn: () => assetApiClient.getUsers(), + ...options, + }); +} + +// Mutations +export function useUpdateAssetStatus( + id: string, + options?: UseMutationOptions +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data) => assetApiClient.updateAssetStatus(id, data), + onSuccess: (updatedAsset) => { + queryClient.setQueryData(queryKeys.assets.detail(id), updatedAsset); + queryClient.invalidateQueries({ queryKey: queryKeys.assets.history(id) }); + }, + ...options, + }); +} + +export function useTransferAsset( + id: string, + options?: UseMutationOptions +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data) => assetApiClient.transferAsset(id, data), + onSuccess: (updatedAsset) => { + queryClient.setQueryData(queryKeys.assets.detail(id), updatedAsset); + queryClient.invalidateQueries({ queryKey: queryKeys.assets.history(id) }); + }, + ...options, + }); +} + +export function useDeleteAsset( + id: string, + options?: UseMutationOptions +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => assetApiClient.deleteAsset(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.assets.all }); + }, + ...options, + }); +} + +export function useUploadDocument( + assetId: string, + options?: UseMutationOptions +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ file, name }) => assetApiClient.uploadDocument(assetId, file, name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.assets.documents(assetId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.assets.history(assetId) }); + }, + ...options, + }); +} + +export function useDeleteDocument( + assetId: string, + options?: UseMutationOptions +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (documentId) => assetApiClient.deleteDocument(assetId, documentId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.assets.documents(assetId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.assets.history(assetId) }); + }, + ...options, + }); +} + +export function useCreateMaintenanceRecord( + assetId: string, + options?: UseMutationOptions +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data) => assetApiClient.createMaintenanceRecord(assetId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.assets.maintenance(assetId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.assets.history(assetId) }); + }, + ...options, + }); +} + +export function useCreateNote( + assetId: string, + options?: UseMutationOptions +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data) => assetApiClient.createNote(assetId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.assets.notes(assetId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.assets.history(assetId) }); + }, + ...options, + }); +} From 3e7372431eb748fe199365e1c52a110ce6b9ca8c Mon Sep 17 00:00:00 2001 From: Josue19-08 Date: Fri, 23 Jan 2026 12:26:45 -0600 Subject: [PATCH 05/12] feat(ui): add reusable UI components for asset detail page Add base UI components: - Tabs: Tab navigation with context-based state - Breadcrumb: Navigation breadcrumb with links - Modal: Accessible modal dialog with escape key support - Dropdown: Select dropdown with keyboard navigation - Badge: Status and condition badges with variants - Skeleton: Loading skeleton for content placeholders Co-Authored-By: Claude Opus 4.5 --- frontend/components/ui/Badge.tsx | 31 ++++++++++ frontend/components/ui/Breadcrumb.tsx | 34 +++++++++++ frontend/components/ui/Dropdown.tsx | 87 +++++++++++++++++++++++++++ frontend/components/ui/Modal.tsx | 77 ++++++++++++++++++++++++ frontend/components/ui/Skeleton.tsx | 9 +++ frontend/components/ui/Tabs.tsx | 82 +++++++++++++++++++++++++ 6 files changed, 320 insertions(+) create mode 100644 frontend/components/ui/Badge.tsx create mode 100644 frontend/components/ui/Breadcrumb.tsx create mode 100644 frontend/components/ui/Dropdown.tsx create mode 100644 frontend/components/ui/Modal.tsx create mode 100644 frontend/components/ui/Skeleton.tsx create mode 100644 frontend/components/ui/Tabs.tsx diff --git a/frontend/components/ui/Badge.tsx b/frontend/components/ui/Badge.tsx new file mode 100644 index 0000000..189b3c4 --- /dev/null +++ b/frontend/components/ui/Badge.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'info'; + +interface BadgeProps { + variant?: BadgeVariant; + children: React.ReactNode; + className?: string; +} + +const variantStyles: Record = { + default: 'bg-gray-100 text-gray-800', + success: 'bg-green-100 text-green-800', + warning: 'bg-yellow-100 text-yellow-800', + danger: 'bg-red-100 text-red-800', + info: 'bg-blue-100 text-blue-800', +}; + +export function Badge({ + variant = 'default', + children, + className = '', +}: BadgeProps) { + return ( + + {children} + + ); +} diff --git a/frontend/components/ui/Breadcrumb.tsx b/frontend/components/ui/Breadcrumb.tsx new file mode 100644 index 0000000..ed9b49b --- /dev/null +++ b/frontend/components/ui/Breadcrumb.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import Link from 'next/link'; +import { ChevronRight } from 'lucide-react'; + +export interface BreadcrumbItem { + label: string; + href?: string; +} + +interface BreadcrumbProps { + items: BreadcrumbItem[]; +} + +export function Breadcrumb({ items }: BreadcrumbProps) { + return ( + + ); +} diff --git a/frontend/components/ui/Dropdown.tsx b/frontend/components/ui/Dropdown.tsx new file mode 100644 index 0000000..ab154d7 --- /dev/null +++ b/frontend/components/ui/Dropdown.tsx @@ -0,0 +1,87 @@ +'use client'; +import React, { useState, useRef, useEffect } from 'react'; +import { ChevronDown } from 'lucide-react'; + +interface DropdownOption { + value: string; + label: string; + icon?: React.ReactNode; +} + +interface DropdownProps { + value: string; + options: DropdownOption[]; + onChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export function Dropdown({ + value, + options, + onChange, + placeholder = 'Select...', + disabled, + className = '', +}: DropdownProps) { + const [isOpen, setIsOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const selectedOption = options.find((o) => o.value === value); + + return ( +
+ + {isOpen && ( +
+ {options.map((option) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/components/ui/Modal.tsx b/frontend/components/ui/Modal.tsx new file mode 100644 index 0000000..1cc62f1 --- /dev/null +++ b/frontend/components/ui/Modal.tsx @@ -0,0 +1,77 @@ +'use client'; +import React, { useEffect } from 'react'; +import { X } from 'lucide-react'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + size?: 'sm' | 'md' | 'lg' | 'xl'; +} + +export function Modal({ + isOpen, + onClose, + title, + children, + size = 'md', +}: ModalProps) { + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } + return () => { + document.body.style.overflow = 'unset'; + }; + }, [isOpen]); + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + if (isOpen) { + document.addEventListener('keydown', handleEscape); + } + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const sizeClasses = { + sm: 'max-w-sm', + md: 'max-w-md', + lg: 'max-w-lg', + xl: 'max-w-xl', + }; + + return ( +
+ + ); +} diff --git a/frontend/components/ui/Skeleton.tsx b/frontend/components/ui/Skeleton.tsx new file mode 100644 index 0000000..4f06237 --- /dev/null +++ b/frontend/components/ui/Skeleton.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +interface SkeletonProps { + className?: string; +} + +export function Skeleton({ className = '' }: SkeletonProps) { + return
; +} diff --git a/frontend/components/ui/Tabs.tsx b/frontend/components/ui/Tabs.tsx new file mode 100644 index 0000000..7675c01 --- /dev/null +++ b/frontend/components/ui/Tabs.tsx @@ -0,0 +1,82 @@ +'use client'; +import React, { createContext, useContext, useState } from 'react'; + +interface TabsContextType { + activeTab: string; + setActiveTab: (tab: string) => void; +} + +const TabsContext = createContext(null); + +interface TabsProps { + defaultValue: string; + children: React.ReactNode; + className?: string; +} + +export function Tabs({ defaultValue, children, className = '' }: TabsProps) { + const [activeTab, setActiveTab] = useState(defaultValue); + + return ( + +
{children}
+
+ ); +} + +interface TabsListProps { + children: React.ReactNode; + className?: string; +} + +export function TabsList({ children, className = '' }: TabsListProps) { + return ( +
+ {children} +
+ ); +} + +interface TabsTriggerProps { + value: string; + children: React.ReactNode; +} + +export function TabsTrigger({ value, children }: TabsTriggerProps) { + const context = useContext(TabsContext); + if (!context) throw new Error('TabsTrigger must be used within Tabs'); + + const isActive = context.activeTab === value; + + return ( + + ); +} + +interface TabsContentProps { + value: string; + children: React.ReactNode; +} + +export function TabsContent({ value, children }: TabsContentProps) { + const context = useContext(TabsContext); + if (!context) throw new Error('TabsContent must be used within Tabs'); + + if (context.activeTab !== value) return null; + + return
{children}
; +} From 5b2488dba91007bca70582fe0ef616ce87ffdb91 Mon Sep 17 00:00:00 2001 From: Josue19-08 Date: Fri, 23 Jan 2026 12:26:52 -0600 Subject: [PATCH 06/12] feat(assets): add asset detail view components Add comprehensive asset components: - ImageGallery: Photo gallery with lightbox zoom - QRCodeDisplay: QR code generation and download - StatusDropdown: Asset status change with visual indicators - AssetOverview: Complete asset information display - AssetActions: Action buttons (transfer, maintenance, delete, etc.) - HistoryTimeline: Visual timeline with filtering and search - DocumentList: Document management with upload/delete - MaintenanceList: Maintenance records and scheduling - NotesList: Internal notes with add functionality - Modals: TransferModal, MaintenanceModal, UploadDocumentModal Co-Authored-By: Claude Opus 4.5 --- frontend/components/assets/AssetActions.tsx | 104 ++++++++ frontend/components/assets/AssetOverview.tsx | 237 ++++++++++++++++++ frontend/components/assets/DocumentList.tsx | 95 +++++++ .../components/assets/HistoryTimeline.tsx | 131 ++++++++++ frontend/components/assets/ImageGallery.tsx | 143 +++++++++++ .../components/assets/MaintenanceList.tsx | 115 +++++++++ frontend/components/assets/NotesList.tsx | 122 +++++++++ frontend/components/assets/QRCodeDisplay.tsx | 54 ++++ frontend/components/assets/StatusDropdown.tsx | 43 ++++ .../assets/modals/MaintenanceModal.tsx | 148 +++++++++++ .../assets/modals/TransferModal.tsx | 121 +++++++++ .../assets/modals/UploadDocumentModal.tsx | 165 ++++++++++++ 12 files changed, 1478 insertions(+) create mode 100644 frontend/components/assets/AssetActions.tsx create mode 100644 frontend/components/assets/AssetOverview.tsx create mode 100644 frontend/components/assets/DocumentList.tsx create mode 100644 frontend/components/assets/HistoryTimeline.tsx create mode 100644 frontend/components/assets/ImageGallery.tsx create mode 100644 frontend/components/assets/MaintenanceList.tsx create mode 100644 frontend/components/assets/NotesList.tsx create mode 100644 frontend/components/assets/QRCodeDisplay.tsx create mode 100644 frontend/components/assets/StatusDropdown.tsx create mode 100644 frontend/components/assets/modals/MaintenanceModal.tsx create mode 100644 frontend/components/assets/modals/TransferModal.tsx create mode 100644 frontend/components/assets/modals/UploadDocumentModal.tsx diff --git a/frontend/components/assets/AssetActions.tsx b/frontend/components/assets/AssetActions.tsx new file mode 100644 index 0000000..b776569 --- /dev/null +++ b/frontend/components/assets/AssetActions.tsx @@ -0,0 +1,104 @@ +'use client'; +import React, { useState } from 'react'; +import Button from '@/components/ui/Button'; +import { Modal } from '@/components/ui/Modal'; +import { + ArrowRightLeft, + Wrench, + Upload, + MessageSquare, + Printer, + Trash2, + AlertTriangle, +} from 'lucide-react'; + +interface AssetActionsProps { + assetId: string; + assetName: string; + onTransfer: () => void; + onScheduleMaintenance: () => void; + onUploadDocument: () => void; + onAddNote: () => void; + onPrintLabel: () => void; + onDelete: () => void; + isDeleting?: boolean; +} + +export function AssetActions({ + assetName, + onTransfer, + onScheduleMaintenance, + onUploadDocument, + onAddNote, + onPrintLabel, + onDelete, + isDeleting, +}: AssetActionsProps) { + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const handleDelete = () => { + onDelete(); + setShowDeleteConfirm(false); + }; + + return ( + <> +
+ + + + + + +
+ + setShowDeleteConfirm(false)} + title="Delete Asset" + > +
+ +
+

+ Are you sure you want to delete {assetName}? This + action cannot be undone. +

+
+
+
+ + +
+
+ + ); +} diff --git a/frontend/components/assets/AssetOverview.tsx b/frontend/components/assets/AssetOverview.tsx new file mode 100644 index 0000000..9a1a309 --- /dev/null +++ b/frontend/components/assets/AssetOverview.tsx @@ -0,0 +1,237 @@ +'use client'; +import React from 'react'; +import { Asset, AssetStatus, AssetCondition } from '@/lib/query/types/asset'; +import { Badge } from '@/components/ui/Badge'; +import { ImageGallery } from './ImageGallery'; +import { QRCodeDisplay } from './QRCodeDisplay'; +import { StatusDropdown } from './StatusDropdown'; +import { + Building2, + MapPin, + User, + Calendar, + DollarSign, + Tag, + Hash, + Edit, + Factory, + Box, +} from 'lucide-react'; +import Button from '@/components/ui/Button'; + +interface AssetOverviewProps { + asset: Asset; + onStatusChange: (status: AssetStatus) => void; + onEdit: () => void; + isUpdatingStatus?: boolean; +} + +type BadgeVariant = 'success' | 'warning' | 'danger' | 'default' | 'info'; + +const conditionVariants: Record = { + [AssetCondition.NEW]: 'success', + [AssetCondition.GOOD]: 'success', + [AssetCondition.FAIR]: 'warning', + [AssetCondition.POOR]: 'warning', + [AssetCondition.DAMAGED]: 'danger', +}; + +interface DetailItemProps { + icon: React.ReactNode; + label: string; + value: string | null | undefined; +} + +function DetailItem({ icon, label, value }: DetailItemProps) { + return ( +
+
{icon}
+
+

{label}

+

{value || '-'}

+
+
+ ); +} + +export function AssetOverview({ + asset, + onStatusChange, + onEdit, + isUpdatingStatus, +}: AssetOverviewProps) { + return ( +
+ {/* Left Column - Images */} +
+ + +
+ + {/* Right Column - Details */} +
+
+
+

{asset.name}

+

+ Asset ID: {asset.assetId} +

+
+ +
+ + {/* Status and Condition */} +
+
+ + +
+
+ + + {asset.condition} + +
+
+ + {/* Description */} + {asset.description && ( +

{asset.description}

+ )} + + {/* Details Grid */} +
+ } + label="Category" + value={asset.category?.name} + /> + } + label="Department" + value={asset.department?.name} + /> + } + label="Location" + value={asset.location} + /> + } + label="Assigned To" + value={asset.assignedTo?.name} + /> + } + label="Serial Number" + value={asset.serialNumber} + /> + } + label="Manufacturer" + value={asset.manufacturer} + /> + } + label="Model" + value={asset.model} + /> + } + label="Purchase Date" + value={ + asset.purchaseDate + ? new Date(asset.purchaseDate).toLocaleDateString() + : null + } + /> + } + label="Purchase Price" + value={ + asset.purchasePrice + ? `$${asset.purchasePrice.toLocaleString()}` + : null + } + /> + } + label="Current Value" + value={ + asset.currentValue + ? `$${asset.currentValue.toLocaleString()}` + : null + } + /> + } + label="Warranty Expires" + value={ + asset.warrantyExpiration + ? new Date(asset.warrantyExpiration).toLocaleDateString() + : null + } + /> +
+ + {/* Tags */} + {asset.tags && asset.tags.length > 0 && ( +
+ +
+ {asset.tags.map((tag, i) => ( + + {tag} + + ))} +
+
+ )} + + {/* Custom Fields */} + {asset.customFields && Object.keys(asset.customFields).length > 0 && ( +
+ +
+ {Object.entries(asset.customFields).map(([key, value]) => ( +
+ {key} + {String(value)} +
+ ))} +
+
+ )} + + {/* Notes */} + {asset.notes && ( +
+ +

+ {asset.notes} +

+
+ )} +
+
+ ); +} diff --git a/frontend/components/assets/DocumentList.tsx b/frontend/components/assets/DocumentList.tsx new file mode 100644 index 0000000..eb89d76 --- /dev/null +++ b/frontend/components/assets/DocumentList.tsx @@ -0,0 +1,95 @@ +'use client'; +import React from 'react'; +import { AssetDocument } from '@/lib/query/types/asset'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { FileText, Download, Trash2, Upload } from 'lucide-react'; +import Button from '@/components/ui/Button'; + +interface DocumentListProps { + documents: AssetDocument[]; + onUpload: () => void; + onDelete: (id: string) => void; + isLoading?: boolean; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function DocumentList({ + documents, + onUpload, + onDelete, + isLoading, +}: DocumentListProps) { + if (isLoading) { + return ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ); + } + + return ( +
+
+

Documents

+ +
+ + {documents.length === 0 ? ( +
+ +

No documents uploaded

+ +
+ ) : ( +
+ {documents.map((doc) => ( +
+
+ +
+

{doc.name}

+

+ {formatFileSize(doc.size)} - Uploaded by{' '} + {doc.uploadedBy.name} on{' '} + {new Date(doc.createdAt).toLocaleDateString()} +

+
+
+
+ + + + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/components/assets/HistoryTimeline.tsx b/frontend/components/assets/HistoryTimeline.tsx new file mode 100644 index 0000000..c22b69d --- /dev/null +++ b/frontend/components/assets/HistoryTimeline.tsx @@ -0,0 +1,131 @@ +'use client'; +import React, { useState, useMemo } from 'react'; +import { AssetHistoryEvent, AssetHistoryAction } from '@/lib/query/types/asset'; +import { Badge } from '@/components/ui/Badge'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { Search } from 'lucide-react'; + +interface HistoryTimelineProps { + events: AssetHistoryEvent[]; + isLoading?: boolean; +} + +type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'info'; + +const actionColors: Record = { + CREATED: 'success', + UPDATED: 'info', + STATUS_CHANGED: 'warning', + TRANSFERRED: 'info', + MAINTENANCE: 'warning', + NOTE_ADDED: 'default', + DOCUMENT_UPLOADED: 'default', +}; + +const actionLabels: Record = { + CREATED: 'Created', + UPDATED: 'Updated', + STATUS_CHANGED: 'Status Changed', + TRANSFERRED: 'Transferred', + MAINTENANCE: 'Maintenance', + NOTE_ADDED: 'Note Added', + DOCUMENT_UPLOADED: 'Document Uploaded', +}; + +export function HistoryTimeline({ events, isLoading }: HistoryTimelineProps) { + const [searchTerm, setSearchTerm] = useState(''); + const [actionFilter, setActionFilter] = useState(''); + + const filteredEvents = useMemo(() => { + return events.filter((event) => { + const matchesSearch = + !searchTerm || + event.description.toLowerCase().includes(searchTerm.toLowerCase()) || + event.performedBy.name.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesAction = !actionFilter || event.action === actionFilter; + return matchesSearch && matchesAction; + }); + }, [events, searchTerm, actionFilter]); + + if (isLoading) { + return ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ); + } + + return ( +
+ {/* Filters */} +
+
+ + setSearchTerm(e.target.value)} + className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> +
+ +
+ + {/* Timeline */} + {filteredEvents.length === 0 ? ( +

No history events found

+ ) : ( +
+
+
+ {filteredEvents.map((event) => ( +
+
+
+
+
+ + {actionLabels[event.action]} + +

+ {event.description} +

+
+ +
+

+ By {event.performedBy.name} +

+
+
+ ))} +
+
+ )} +
+ ); +} diff --git a/frontend/components/assets/ImageGallery.tsx b/frontend/components/assets/ImageGallery.tsx new file mode 100644 index 0000000..fab8be9 --- /dev/null +++ b/frontend/components/assets/ImageGallery.tsx @@ -0,0 +1,143 @@ +'use client'; +import React, { useState } from 'react'; +import Image from 'next/image'; +import { ChevronLeft, ChevronRight, X, ZoomIn, ImageIcon } from 'lucide-react'; + +interface ImageGalleryProps { + images: string[]; + assetName: string; +} + +export function ImageGallery({ images, assetName }: ImageGalleryProps) { + const [selectedIndex, setSelectedIndex] = useState(0); + const [isZoomed, setIsZoomed] = useState(false); + + if (!images || images.length === 0) { + return ( +
+ + No images available +
+ ); + } + + const handlePrevious = () => + setSelectedIndex((i) => (i === 0 ? images.length - 1 : i - 1)); + const handleNext = () => + setSelectedIndex((i) => (i === images.length - 1 ? 0 : i + 1)); + + return ( + <> +
+
+ {`${assetName} setIsZoomed(true)} + /> + +
+ + {images.length > 1 && ( + <> + + + + )} + + {images.length > 1 && ( +
+ {images.map((img, index) => ( + + ))} +
+ )} +
+ + {/* Zoom Modal */} + {isZoomed && ( +
setIsZoomed(false)} + > + + {images.length > 1 && ( + <> + + + + )} + {assetName} e.stopPropagation()} + /> +
+ )} + + ); +} diff --git a/frontend/components/assets/MaintenanceList.tsx b/frontend/components/assets/MaintenanceList.tsx new file mode 100644 index 0000000..b04a066 --- /dev/null +++ b/frontend/components/assets/MaintenanceList.tsx @@ -0,0 +1,115 @@ +'use client'; +import React from 'react'; +import { MaintenanceRecord, MaintenanceStatus } from '@/lib/query/types/asset'; +import { Badge } from '@/components/ui/Badge'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { Wrench, Calendar, Plus } from 'lucide-react'; +import Button from '@/components/ui/Button'; + +interface MaintenanceListProps { + records: MaintenanceRecord[]; + onSchedule: () => void; + isLoading?: boolean; +} + +type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'info'; + +const statusColors: Record = { + SCHEDULED: 'info', + IN_PROGRESS: 'warning', + COMPLETED: 'success', + CANCELLED: 'danger', +}; + +const statusLabels: Record = { + SCHEDULED: 'Scheduled', + IN_PROGRESS: 'In Progress', + COMPLETED: 'Completed', + CANCELLED: 'Cancelled', +}; + +export function MaintenanceList({ + records, + onSchedule, + isLoading, +}: MaintenanceListProps) { + if (isLoading) { + return ( +
+ {[1, 2].map((i) => ( + + ))} +
+ ); + } + + return ( +
+
+

Maintenance Records

+ +
+ + {records.length === 0 ? ( +
+ +

No maintenance records

+ +
+ ) : ( +
+ {records.map((record) => ( +
+
+
+ +
+
+ {record.type} + + {statusLabels[record.status]} + +
+

+ {record.description} +

+
+ + + Scheduled:{' '} + {new Date(record.scheduledDate).toLocaleDateString()} + + {record.completedDate && ( + + Completed:{' '} + {new Date(record.completedDate).toLocaleDateString()} + + )} +
+ {record.notes && ( +

+ {record.notes} +

+ )} +
+
+ {record.cost !== null && record.cost !== undefined && ( + + ${record.cost.toFixed(2)} + + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/components/assets/NotesList.tsx b/frontend/components/assets/NotesList.tsx new file mode 100644 index 0000000..30454ee --- /dev/null +++ b/frontend/components/assets/NotesList.tsx @@ -0,0 +1,122 @@ +'use client'; +import React, { useState } from 'react'; +import { AssetNote } from '@/lib/query/types/asset'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { MessageSquare, Plus } from 'lucide-react'; +import Button from '@/components/ui/Button'; + +interface NotesListProps { + notes: AssetNote[]; + onAddNote: (content: string) => void; + isLoading?: boolean; + isAdding?: boolean; +} + +export function NotesList({ + notes, + onAddNote, + isLoading, + isAdding, +}: NotesListProps) { + const [isFormOpen, setIsFormOpen] = useState(false); + const [newNote, setNewNote] = useState(''); + + const handleSubmit = () => { + if (newNote.trim()) { + onAddNote(newNote); + setNewNote(''); + setIsFormOpen(false); + } + }; + + if (isLoading) { + return ( +
+ {[1, 2].map((i) => ( + + ))} +
+ ); + } + + return ( +
+
+

Notes

+ +
+ + {isFormOpen && ( +
+