wulf-pulse/components/configuration-items/purchase-history-modal.tsx
Lorentz Hinrichsen 3c3124d8c9 Restructure: rename to Pulse and move app to root
- Renamed project from PSA-Utils to Pulse
- Moved all app files from autotask-app/ to root
- Updated package.json name to 'pulse'
- Updated Docker container names to pulse-app and pulse-redis
- Updated Docker network name to pulse-network
2025-10-28 23:08:54 -04:00

571 lines
24 KiB
TypeScript

'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">
${data.billingItems.reduce((sum: number, item: any) => sum + (item.totalAmount || 0), 0).toFixed(2)}
</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">
${data.billingItems.reduce((sum: number, item: any) => sum + (item.ourCost || 0), 0).toFixed(2)}
</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">
${data.billingItems.reduce((sum: number, item: any) => sum + (item.profit || 0), 0).toFixed(2)}
</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>
);
}