2025-10-28 11:21:04 -04:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect } from 'react';
|
|
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from '@/components/ui/dialog';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Badge } from '@/components/ui/badge';
|
|
|
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
|
|
|
import {
|
|
|
|
|
Table,
|
|
|
|
|
TableBody,
|
|
|
|
|
TableCell,
|
|
|
|
|
TableHead,
|
|
|
|
|
TableHeader,
|
|
|
|
|
TableRow,
|
|
|
|
|
} from '@/components/ui/table';
|
|
|
|
|
import { DollarSign, FileText, AlertCircle, Receipt } from 'lucide-react';
|
|
|
|
|
import { format } from 'date-fns';
|
|
|
|
|
|
|
|
|
|
interface PurchaseHistoryModalProps {
|
|
|
|
|
configItemId: number;
|
|
|
|
|
serialNumber?: string;
|
|
|
|
|
createDate?: string;
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface PurchaseHistoryData {
|
|
|
|
|
billingItems: any[];
|
|
|
|
|
invoices?: any[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function PurchaseHistoryModal({
|
|
|
|
|
configItemId,
|
|
|
|
|
serialNumber,
|
|
|
|
|
createDate,
|
|
|
|
|
open,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
}: PurchaseHistoryModalProps) {
|
|
|
|
|
const [data, setData] = useState<PurchaseHistoryData | null>(null);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [searchAttempt, setSearchAttempt] = useState(0); // 0 = initial, 1 = before, 2 = after
|
|
|
|
|
const [selectedInvoice, setSelectedInvoice] = useState<any>(null);
|
|
|
|
|
const [selectedTicket, setSelectedTicket] = useState<any>(null);
|
|
|
|
|
const [loadingTicket, setLoadingTicket] = useState(false);
|
|
|
|
|
const [timeEntries, setTimeEntries] = useState<any[]>([]);
|
|
|
|
|
const [loadingTimeEntries, setLoadingTimeEntries] = useState(false);
|
|
|
|
|
const [showTimeEntries, setShowTimeEntries] = useState(false);
|
|
|
|
|
const [invoiceLineItems, setInvoiceLineItems] = useState<any[]>([]);
|
|
|
|
|
const [loadingInvoiceItems, setLoadingInvoiceItems] = useState(false);
|
|
|
|
|
const [showInvoiceItems, setShowInvoiceItems] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (open && configItemId) {
|
|
|
|
|
setSearchAttempt(0); // Reset search attempt
|
|
|
|
|
fetchPurchaseHistory(0);
|
|
|
|
|
}
|
|
|
|
|
}, [open, configItemId]);
|
|
|
|
|
|
|
|
|
|
const fetchPurchaseHistory = async (attempt: number) => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Calculate date range based on creation date and attempt
|
|
|
|
|
let startDate: Date;
|
|
|
|
|
let endDate: Date;
|
|
|
|
|
|
|
|
|
|
if (!createDate) {
|
|
|
|
|
// Fallback if no creation date: search last 120 days
|
|
|
|
|
endDate = new Date();
|
|
|
|
|
startDate = new Date();
|
|
|
|
|
startDate.setDate(startDate.getDate() - 120);
|
|
|
|
|
} else {
|
|
|
|
|
const created = new Date(createDate);
|
|
|
|
|
|
|
|
|
|
if (attempt === 0) {
|
|
|
|
|
// Initial: 60 days before to 60 days after creation
|
|
|
|
|
startDate = new Date(created);
|
|
|
|
|
startDate.setDate(startDate.getDate() - 60);
|
|
|
|
|
endDate = new Date(created);
|
|
|
|
|
endDate.setDate(endDate.getDate() + 60);
|
|
|
|
|
console.log('Initial search: 60 days before/after creation date');
|
|
|
|
|
} else if (attempt === 1) {
|
|
|
|
|
// Second attempt: 120 days before the initial window
|
|
|
|
|
startDate = new Date(created);
|
|
|
|
|
startDate.setDate(startDate.getDate() - 180); // 60 + 120
|
|
|
|
|
endDate = new Date(created);
|
|
|
|
|
endDate.setDate(endDate.getDate() - 60);
|
|
|
|
|
console.log('Extended search: 120 days BEFORE initial window');
|
|
|
|
|
} else {
|
|
|
|
|
// Third attempt: 120 days after the initial window
|
|
|
|
|
startDate = new Date(created);
|
|
|
|
|
startDate.setDate(startDate.getDate() + 60);
|
|
|
|
|
endDate = new Date(created);
|
|
|
|
|
endDate.setDate(endDate.getDate() + 180); // 60 + 120
|
|
|
|
|
console.log('Extended search: 120 days AFTER initial window');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const url = `/api/config-enrichment?configItemId=${configItemId}&startDate=${startDate.toISOString().split('T')[0]}&endDate=${endDate.toISOString().split('T')[0]}`;
|
|
|
|
|
console.log('Fetching from:', url);
|
|
|
|
|
const response = await fetch(url);
|
|
|
|
|
console.log('Response status:', response.status);
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const errorText = await response.text();
|
|
|
|
|
console.error('Error response:', errorText);
|
|
|
|
|
throw new Error('Failed to fetch purchase history');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
console.log('Purchase history result:', result);
|
|
|
|
|
|
|
|
|
|
// If no results and we haven't tried all attempts yet, try next window
|
|
|
|
|
if (result.billingItems.length === 0 && attempt < 2) {
|
|
|
|
|
console.log('No results found, trying extended search...');
|
|
|
|
|
setSearchAttempt(attempt + 1);
|
|
|
|
|
await fetchPurchaseHistory(attempt + 1);
|
|
|
|
|
} else {
|
|
|
|
|
setData(result);
|
|
|
|
|
setSearchAttempt(attempt);
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Purchase history error:', err);
|
|
|
|
|
setError(err instanceof Error ? err.message : 'An error occurred');
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchTicketDetails = async (ticketId: number) => {
|
|
|
|
|
console.log('Fetching ticket details for ID:', ticketId);
|
|
|
|
|
setLoadingTicket(true);
|
|
|
|
|
setShowTimeEntries(false);
|
|
|
|
|
setTimeEntries([]);
|
|
|
|
|
try {
|
|
|
|
|
const url = `/api/tickets/${ticketId}`;
|
|
|
|
|
console.log('Fetching from:', url);
|
|
|
|
|
const response = await fetch(url);
|
|
|
|
|
console.log('Response status:', response.status);
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const errorText = await response.text();
|
|
|
|
|
console.error('Error response:', errorText);
|
|
|
|
|
throw new Error('Failed to fetch ticket details');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
console.log('Ticket result:', result);
|
|
|
|
|
setSelectedTicket(result.ticket);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error fetching ticket:', err);
|
|
|
|
|
setSelectedTicket({ ticketNumber: ticketId, title: 'Error loading ticket details' });
|
|
|
|
|
} finally {
|
|
|
|
|
setLoadingTicket(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchTimeEntries = async (ticketId: number) => {
|
|
|
|
|
setLoadingTimeEntries(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/tickets/${ticketId}/time-entries`);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error('Failed to fetch time entries');
|
|
|
|
|
}
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
setTimeEntries(result.timeEntries || []);
|
|
|
|
|
setShowTimeEntries(true);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error fetching time entries:', err);
|
|
|
|
|
setTimeEntries([]);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoadingTimeEntries(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const fetchInvoiceLineItems = async (invoiceId: number) => {
|
|
|
|
|
setLoadingInvoiceItems(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`/api/invoices/${invoiceId}/line-items`);
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error('Failed to fetch invoice line items');
|
|
|
|
|
}
|
|
|
|
|
const result = await response.json();
|
|
|
|
|
setInvoiceLineItems(result.lineItems || []);
|
|
|
|
|
setShowInvoiceItems(true);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error fetching invoice line items:', err);
|
|
|
|
|
setInvoiceLineItems([]);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoadingInvoiceItems(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<DialogContent className="!max-w-[95vw] !w-[95vw] max-h-[90vh] overflow-y-auto">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<div className="flex items-start justify-between gap-4">
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<DialogTitle className="flex items-center gap-2">
|
|
|
|
|
<Receipt className="w-5 h-5" />
|
|
|
|
|
Purchase History
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
Hardware purchase history for this device
|
|
|
|
|
{serialNumber && ` - Serial: ${serialNumber}`}
|
|
|
|
|
{createDate && (
|
|
|
|
|
<span className="block text-xs mt-1">
|
|
|
|
|
Searching around creation date: {format(new Date(createDate), 'MMM d, yyyy')}
|
|
|
|
|
{searchAttempt > 0 && ` (Extended search ${searchAttempt}/2)`}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</div>
|
|
|
|
|
{/* Summary Cards - Compact */}
|
|
|
|
|
{!loading && !error && data && data.billingItems.length > 0 && (
|
|
|
|
|
<div className="flex gap-3 mr-8">
|
|
|
|
|
<div className="px-3 py-2 bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900 rounded-lg">
|
|
|
|
|
<p className="text-xs text-muted-foreground">Sale Price</p>
|
|
|
|
|
<p className="text-sm font-bold">
|
2025-11-19 14:18:16 -05:00
|
|
|
${data.billingItems.reduce((sum: number, item: any) => sum + (Number(item.totalAmount) || 0), 0).toFixed(2)}
|
2025-10-28 11:21:04 -04:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="px-3 py-2 bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-950 dark:to-blue-900 rounded-lg">
|
|
|
|
|
<p className="text-xs text-muted-foreground">Cost</p>
|
|
|
|
|
<p className="text-sm font-bold">
|
2025-11-19 14:18:16 -05:00
|
|
|
${data.billingItems.reduce((sum: number, item: any) => sum + (Number(item.ourCost) || 0), 0).toFixed(2)}
|
2025-10-28 11:21:04 -04:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="px-3 py-2 bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-950 dark:to-purple-900 rounded-lg">
|
|
|
|
|
<p className="text-xs text-muted-foreground">Profit</p>
|
|
|
|
|
<p className="text-sm font-bold">
|
2025-11-19 14:18:16 -05:00
|
|
|
${data.billingItems.reduce((sum: number, item: any) => sum + (Number(item.profit) || 0), 0).toFixed(2)}
|
2025-10-28 11:21:04 -04:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
{loading && (
|
|
|
|
|
<div className="space-y-4 py-4">
|
|
|
|
|
<Skeleton className="h-24 w-full" />
|
|
|
|
|
<Skeleton className="h-64 w-full" />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{error && (
|
|
|
|
|
<div className="flex items-center gap-2 text-red-500 p-4 bg-red-50 dark:bg-red-950/20 rounded-lg">
|
|
|
|
|
<AlertCircle className="w-5 h-5" />
|
|
|
|
|
<p>Error: {error}</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!loading && !error && data && (
|
|
|
|
|
<div className="space-y-6 py-4">
|
|
|
|
|
{/* Billing Items */}
|
|
|
|
|
{data.billingItems.length > 0 ? (
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
|
|
|
|
<FileText className="w-5 h-5" />
|
|
|
|
|
Purchase Details
|
|
|
|
|
</h3>
|
|
|
|
|
<div className="border rounded-lg overflow-hidden overflow-x-auto">
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableHead className="w-28">Date</TableHead>
|
|
|
|
|
<TableHead className="min-w-[300px] max-w-[400px]">Description</TableHead>
|
|
|
|
|
<TableHead className="w-32">Serial</TableHead>
|
|
|
|
|
<TableHead className="w-24">Cost</TableHead>
|
|
|
|
|
<TableHead className="w-24">Sale Price</TableHead>
|
|
|
|
|
<TableHead className="w-24">Profit</TableHead>
|
|
|
|
|
<TableHead className="w-24">Invoice</TableHead>
|
|
|
|
|
<TableHead className="w-24">Ticket</TableHead>
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{data.billingItems.map((item: any, index: number) => (
|
|
|
|
|
<TableRow key={index}>
|
|
|
|
|
<TableCell className="whitespace-nowrap">
|
|
|
|
|
{item.itemDate ? format(new Date(item.itemDate), 'MMM d, yyyy') : '-'}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="min-w-[300px] max-w-[400px]">
|
|
|
|
|
<div className="text-sm font-medium whitespace-normal break-words">{item.description}</div>
|
|
|
|
|
{item.purchaseOrderNumber && (
|
|
|
|
|
<div className="text-xs text-muted-foreground">PO: {item.purchaseOrderNumber}</div>
|
|
|
|
|
)}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
{item.extractedSerialNumber && (
|
|
|
|
|
<span className="font-mono text-xs bg-green-100 dark:bg-green-900 px-2 py-1 rounded">
|
|
|
|
|
{item.extractedSerialNumber}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="whitespace-nowrap">
|
|
|
|
|
${(item.ourCost || 0).toFixed(2)}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="whitespace-nowrap font-medium">
|
|
|
|
|
${(item.totalAmount || 0).toFixed(2)}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="whitespace-nowrap">
|
|
|
|
|
<span className={item.profit > 0 ? 'text-green-600' : 'text-red-600'}>
|
|
|
|
|
${(item.profit || 0).toFixed(2)}
|
|
|
|
|
</span>
|
|
|
|
|
<Badge variant="secondary" className="ml-2">
|
|
|
|
|
{item.profitMargin}%
|
|
|
|
|
</Badge>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
{item.invoiceID ? (
|
|
|
|
|
<Button
|
|
|
|
|
variant="link"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-auto p-0 font-mono text-blue-600 hover:text-blue-800"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const invoice = data.invoices?.find((inv: any) => inv.id === item.invoiceID);
|
|
|
|
|
setSelectedInvoice(invoice || { id: item.invoiceID });
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{item.invoiceID}
|
|
|
|
|
</Button>
|
|
|
|
|
) : '-'}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
{item.ticketID ? (
|
|
|
|
|
<Button
|
|
|
|
|
variant="link"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-auto p-0 font-mono text-blue-600 hover:text-blue-800"
|
|
|
|
|
onClick={() => fetchTicketDetails(item.ticketID)}
|
|
|
|
|
>
|
|
|
|
|
{item.ticketID}
|
|
|
|
|
</Button>
|
|
|
|
|
) : '-'}
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
))}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="text-center py-12 text-muted-foreground">
|
|
|
|
|
<AlertCircle className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
|
|
|
|
<p className="font-medium">No purchase records found for this serial number</p>
|
|
|
|
|
<p className="text-sm mt-2">
|
|
|
|
|
Serial number: {serialNumber || 'Not set'}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-sm mt-4">This device may have been:</p>
|
|
|
|
|
<ul className="text-sm mt-2 space-y-1">
|
|
|
|
|
<li>• Purchased more than 90 days ago</li>
|
|
|
|
|
<li>• Added manually without an invoice</li>
|
|
|
|
|
<li>• Serial number not found in invoice line items</li>
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Invoice Details Section */}
|
|
|
|
|
{selectedInvoice && (
|
|
|
|
|
<div className="mt-6 border-t pt-6">
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
|
|
|
|
<FileText className="w-5 h-5" />
|
|
|
|
|
Invoice #{selectedInvoice.id} Details
|
|
|
|
|
</h3>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setSelectedInvoice(null)}
|
|
|
|
|
>
|
|
|
|
|
Close
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">Invoice Date</p>
|
|
|
|
|
<p className="font-medium">
|
|
|
|
|
{selectedInvoice.invoiceDateTime ? format(new Date(selectedInvoice.invoiceDateTime), 'MMM d, yyyy') : '-'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">Total</p>
|
|
|
|
|
<p className="font-medium">${(selectedInvoice.invoiceTotal || 0).toFixed(2)}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">Status</p>
|
|
|
|
|
<Badge variant={selectedInvoice.isPaid ? 'default' : 'secondary'}>
|
|
|
|
|
{selectedInvoice.isPaid ? 'Paid' : 'Unpaid'}
|
|
|
|
|
</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">Due Date</p>
|
|
|
|
|
<p className="font-medium">
|
|
|
|
|
{selectedInvoice.dueDateTime ? format(new Date(selectedInvoice.dueDateTime), 'MMM d, yyyy') : '-'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Invoice Line Items */}
|
|
|
|
|
<div className="mt-4">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (!showInvoiceItems && invoiceLineItems.length === 0) {
|
|
|
|
|
fetchInvoiceLineItems(selectedInvoice.id);
|
|
|
|
|
} else {
|
|
|
|
|
setShowInvoiceItems(!showInvoiceItems);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="w-full"
|
|
|
|
|
>
|
|
|
|
|
{showInvoiceItems ? 'Hide' : 'Show'} Line Items
|
|
|
|
|
{invoiceLineItems.length > 0 && ` (${invoiceLineItems.length})`}
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
{loadingInvoiceItems && (
|
|
|
|
|
<div className="mt-4">
|
|
|
|
|
<Skeleton className="h-32 w-full" />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{showInvoiceItems && invoiceLineItems.length > 0 && (
|
|
|
|
|
<div className="mt-4 overflow-x-auto">
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableHead className="min-w-[300px] max-w-[500px]">Description</TableHead>
|
|
|
|
|
<TableHead className="text-right w-20">Qty</TableHead>
|
|
|
|
|
<TableHead className="text-right w-28">Unit Price</TableHead>
|
|
|
|
|
<TableHead className="text-right w-28">Total</TableHead>
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
|
|
|
|
{invoiceLineItems.map((item: any, index: number) => (
|
|
|
|
|
<TableRow key={index}>
|
|
|
|
|
<TableCell className="min-w-[300px] max-w-[500px]">
|
|
|
|
|
<div className="text-sm font-medium whitespace-normal break-words">{item.description || '-'}</div>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="text-right whitespace-nowrap">{item.quantity || 0}</TableCell>
|
|
|
|
|
<TableCell className="text-right whitespace-nowrap">${(item.unitPrice || 0).toFixed(2)}</TableCell>
|
|
|
|
|
<TableCell className="text-right font-medium whitespace-nowrap">${(item.totalAmount || 0).toFixed(2)}</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
))}
|
|
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{showInvoiceItems && invoiceLineItems.length === 0 && !loadingInvoiceItems && (
|
|
|
|
|
<p className="text-sm text-muted-foreground mt-4 text-center">No line items found</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Ticket Details Section */}
|
|
|
|
|
{selectedTicket && (
|
|
|
|
|
<div className="mt-6 border-t pt-6">
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<h3 className="text-lg font-semibold flex items-center gap-2">
|
|
|
|
|
<FileText className="w-5 h-5" />
|
|
|
|
|
Ticket #{selectedTicket.ticketNumber} Details
|
|
|
|
|
</h3>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setSelectedTicket(null)}
|
|
|
|
|
>
|
|
|
|
|
Close
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
{loadingTicket ? (
|
|
|
|
|
<Skeleton className="h-24 w-full" />
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">Ticket Number</p>
|
|
|
|
|
<p className="font-medium">{selectedTicket.ticketNumber}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="md:col-span-2">
|
|
|
|
|
<p className="text-sm text-muted-foreground">Description</p>
|
|
|
|
|
<p className="font-medium">{selectedTicket.title || '-'}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">Primary Resource</p>
|
|
|
|
|
<p className="font-medium">{selectedTicket.assignedResourceName || '-'}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">Status</p>
|
|
|
|
|
<Badge>{selectedTicket.status || '-'}</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm text-muted-foreground">Created</p>
|
|
|
|
|
<p className="font-medium">
|
|
|
|
|
{selectedTicket.createDate ? format(new Date(selectedTicket.createDate), 'MMM d, yyyy') : '-'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Time Entries Timeline */}
|
|
|
|
|
<div className="mt-4">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (!showTimeEntries && timeEntries.length === 0) {
|
|
|
|
|
fetchTimeEntries(selectedTicket.id);
|
|
|
|
|
} else {
|
|
|
|
|
setShowTimeEntries(!showTimeEntries);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="w-full"
|
|
|
|
|
>
|
|
|
|
|
{showTimeEntries ? 'Hide' : 'Show'} Time Entries Timeline
|
|
|
|
|
{timeEntries.length > 0 && ` (${timeEntries.length})`}
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
{loadingTimeEntries && (
|
|
|
|
|
<div className="mt-4">
|
|
|
|
|
<Skeleton className="h-32 w-full" />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{showTimeEntries && timeEntries.length > 0 && (
|
|
|
|
|
<div className="mt-4 space-y-3">
|
|
|
|
|
{timeEntries.map((entry: any, index: number) => (
|
|
|
|
|
<div key={index} className="flex gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border">
|
|
|
|
|
<div className="flex-shrink-0 w-1 bg-blue-500 rounded"></div>
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<div className="flex items-start justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="font-medium text-sm">{entry.resourceName || 'Unknown Resource'}</p>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
{entry.dateWorked ? format(new Date(entry.dateWorked), 'MMM d, yyyy') : '-'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Badge variant="secondary">{entry.hoursWorked || 0}h</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
{entry.summaryNotes && (
|
|
|
|
|
<p className="text-sm mt-2 text-muted-foreground">{entry.summaryNotes}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{showTimeEntries && timeEntries.length === 0 && !loadingTimeEntries && (
|
|
|
|
|
<p className="text-sm text-muted-foreground mt-4 text-center">No time entries found</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|