diff --git a/backend/src/main.ts b/backend/src/main.ts index 57a65cd..6988430 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -6,6 +6,15 @@ import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); + // Enable CORS for frontend + app.enableCors({ + origin: process.env.FRONTEND_URL || 'http://localhost:3000', + credentials: true, + }); + + // Set global API prefix + app.setGlobalPrefix('api'); + // Enable validation globally app.useGlobalPipes(new ValidationPipe({ whitelist: true, @@ -37,7 +46,7 @@ async function bootstrap() { }, }); - const port = process.env.PORT ?? 3000; + const port = process.env.PORT ?? 8000; await app.listen(port); console.log(`Application is running on: http://localhost:${port}`); console.log(`Swagger docs available at: http://localhost:${port}/api/docs`); diff --git a/frontend/app/assets/[id]/not-found.tsx b/frontend/app/assets/[id]/not-found.tsx new file mode 100644 index 0000000..48c2aa4 --- /dev/null +++ b/frontend/app/assets/[id]/not-found.tsx @@ -0,0 +1,22 @@ +import Link from 'next/link'; + +export default function AssetNotFound() { + return ( +
+

404

+

+ Asset Not Found +

+

+ The asset you're looking for doesn't exist or has been + removed. +

+ + Back to Assets + +
+ ); +} diff --git a/frontend/app/assets/[id]/page.tsx b/frontend/app/assets/[id]/page.tsx new file mode 100644 index 0000000..a36cf29 --- /dev/null +++ b/frontend/app/assets/[id]/page.tsx @@ -0,0 +1,236 @@ +'use client'; +import React, { useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { Breadcrumb } from '@/components/ui/Breadcrumb'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs'; +import { Skeleton } from '@/components/ui/Skeleton'; +import { AssetOverview } from '@/components/assets/AssetOverview'; +import { AssetActions } from '@/components/assets/AssetActions'; +import { HistoryTimeline } from '@/components/assets/HistoryTimeline'; +import { DocumentList } from '@/components/assets/DocumentList'; +import { MaintenanceList } from '@/components/assets/MaintenanceList'; +import { NotesList } from '@/components/assets/NotesList'; +import { TransferModal } from '@/components/assets/modals/TransferModal'; +import { MaintenanceModal } from '@/components/assets/modals/MaintenanceModal'; +import { UploadDocumentModal } from '@/components/assets/modals/UploadDocumentModal'; +import { + useAsset, + useAssetHistory, + useAssetDocuments, + useMaintenanceRecords, + useAssetNotes, + useUpdateAssetStatus, + useDeleteAsset, + useDeleteDocument, + useCreateNote, +} from '@/lib/query/hooks/useAsset'; +import { generateAssetLabelPDF } from '@/lib/utils/pdfGenerator'; +import { AssetStatus } from '@/lib/query/types/asset'; +import Link from 'next/link'; + +export default function AssetDetailPage() { + const params = useParams(); + const router = useRouter(); + const assetId = params.id as string; + + // Modal states + const [showTransferModal, setShowTransferModal] = useState(false); + const [showMaintenanceModal, setShowMaintenanceModal] = useState(false); + const [showUploadModal, setShowUploadModal] = useState(false); + + // Queries + const { data: asset, isLoading, error } = useAsset(assetId); + const { data: history = [], isLoading: isLoadingHistory } = + useAssetHistory(assetId); + const { data: documents = [], isLoading: isLoadingDocs } = + useAssetDocuments(assetId); + const { data: maintenance = [], isLoading: isLoadingMaintenance } = + useMaintenanceRecords(assetId); + const { data: notes = [], isLoading: isLoadingNotes } = + useAssetNotes(assetId); + + // Mutations + const updateStatus = useUpdateAssetStatus(assetId); + const deleteAsset = useDeleteAsset(assetId, { + onSuccess: () => router.push('/assets'), + }); + const deleteDocument = useDeleteDocument(assetId); + const createNote = useCreateNote(assetId); + + // 404 handling + if (error) { + const statusCode = (error as { statusCode?: number }).statusCode; + if (statusCode === 404) { + return ; + } + } + + // Loading state + if (isLoading) { + return ; + } + + if (!asset) { + return ; + } + + const handleStatusChange = (status: AssetStatus) => { + updateStatus.mutate({ status }); + }; + + const handlePrintLabel = async () => { + await generateAssetLabelPDF(asset); + }; + + const breadcrumbItems = [ + { label: 'Dashboard', href: '/dashboard' }, + { label: 'Assets', href: '/assets' }, + { label: asset.name }, + ]; + + return ( +
+ {/* Breadcrumb */} + + + {/* Header with Actions */} +
+ setShowTransferModal(true)} + onScheduleMaintenance={() => setShowMaintenanceModal(true)} + onUploadDocument={() => setShowUploadModal(true)} + onAddNote={() => {}} + onPrintLabel={handlePrintLabel} + onDelete={() => deleteAsset.mutate()} + isDeleting={deleteAsset.isPending} + /> +
+ + {/* Tabs */} + + + Overview + History + Documents + Maintenance + Notes + + +
+ + router.push(`/assets/${assetId}/edit`)} + isUpdatingStatus={updateStatus.isPending} + /> + + + + + + + + setShowUploadModal(true)} + onDelete={(id) => deleteDocument.mutate(id)} + isLoading={isLoadingDocs} + /> + + + + setShowMaintenanceModal(true)} + isLoading={isLoadingMaintenance} + /> + + + + createNote.mutate({ content })} + isLoading={isLoadingNotes} + isAdding={createNote.isPending} + /> + +
+
+ + {/* Modals */} + setShowTransferModal(false)} + assetId={assetId} + /> + setShowMaintenanceModal(false)} + assetId={assetId} + /> + setShowUploadModal(false)} + assetId={assetId} + /> +
+ ); +} + +function AssetNotFound() { + return ( +
+

404

+

+ Asset Not Found +

+

+ The asset you're looking for doesn't exist or has been + removed. +

+ + Back to Assets + +
+ ); +} + +function AssetDetailSkeleton() { + return ( +
+ +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( + + ))} +
+ +
+
+ + +
+
+ + +
+ + +
+ +
+ {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((i) => ( + + ))} +
+
+
+
+ ); +} diff --git a/frontend/app/providers.tsx b/frontend/app/providers.tsx new file mode 100644 index 0000000..b79ccde --- /dev/null +++ b/frontend/app/providers.tsx @@ -0,0 +1,19 @@ +'use client'; + +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, // 1 minute + refetchOnWindowFocus: false, + }, + }, +}); + +export function Providers({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} diff --git a/frontend/components/admin/UserForm.tsx b/frontend/components/admin/UserForm.tsx index e8ed5ab..0b4635e 100644 --- a/frontend/components/admin/UserForm.tsx +++ b/frontend/components/admin/UserForm.tsx @@ -5,7 +5,7 @@ import { Role, User } from '../../../backend/src/types/admin' interface UserFormProps { initialData?: Partial; roles: Role[]; - onSubmit: (data: any) => void; + onSubmit: (data: Partial) => void; onCancel: () => void; } 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 && ( +
+