wulf-pulse/app/quotes/page.tsx
lorentz 91b876310e feat(07.1-05): user-tz on dashboard, quotes, veeam-analysis
- dashboard/page.tsx: thread tz into PageHeader description's
  toLocaleDateString call.
- quotes/page.tsx: thread tz into formatDate arrow helper inside the
  default export.
- veeam-analysis/page.tsx: thread tz into the summary footer's
  generated-at toLocaleString call.

Migrates 3 of 81 audit leak callsites.
2026-05-07 08:36:09 -04:00

313 lines
10 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
FileText,
ExternalLink,
Calendar,
Building2,
User,
DollarSign,
RefreshCw,
Ticket
} from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
import { TicketDetailModal } from '@/components/quotes/ticket-detail-modal';
interface Quote {
id: string;
title: string;
number: string;
status: string;
createdAt: string;
sentAt?: string;
company?: {
name: string;
};
contact?: {
name: string;
};
owner?: {
name: string;
};
items?: Array<{
price?: number;
quantity: number;
}>;
link?: string;
}
export default function QuotesPage() {
const tz = useUserTimezone();
const [quotes, setQuotes] = useState<Quote[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedTicketNumber, setSelectedTicketNumber] = useState<string | null>(null);
const [ticketModalOpen, setTicketModalOpen] = useState(false);
useEffect(() => {
fetchQuotes();
}, []);
const fetchQuotes = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/salesbldr/quotes?status=open&size=100');
if (!response.ok) {
throw new Error('Failed to fetch quotes');
}
const data = await response.json();
setQuotes(data.results || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
console.error('Error fetching quotes:', err);
} finally {
setLoading(false);
}
};
const getStatusBadge = (status: string) => {
const variants: Record<string, { variant: "default" | "secondary" | "destructive" | "outline", label: string }> = {
draft: { variant: "secondary", label: "Draft" },
sent: { variant: "default", label: "Sent" },
approved: { variant: "outline", label: "Approved" },
declined: { variant: "destructive", label: "Declined" },
};
const config = variants[status] || { variant: "outline", label: status };
return <Badge variant={config.variant}>{config.label}</Badge>;
};
const calculateTotal = (quote: Quote) => {
if (!quote.items || quote.items.length === 0) return 0;
return quote.items.reduce((sum, item) => {
return sum + ((item.price || 0) * item.quantity);
}, 0);
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(amount);
};
const formatDate = (dateString?: string) => {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: tz,
});
};
const extractTicketNumber = (title: string): { ticketNumber: string; ticketId: string } | null => {
// Match ticket numbers in format T20260109.0122
const match = title.match(/T(\d{8})\.(\d{4})/);
if (match) {
return {
ticketNumber: match[0],
ticketId: match[0].substring(1) // Remove the 'T' prefix for the ID
};
}
return null;
};
const handleTicketClick = (ticketNumber: string) => {
setSelectedTicketNumber(ticketNumber);
setTicketModalOpen(true);
};
const renderTitleWithTicketLink = (title: string) => {
const ticketInfo = extractTicketNumber(title);
if (!ticketInfo) {
return <div className="max-w-md truncate">{title}</div>;
}
// Split the title into parts before and after the ticket number
const parts = title.split(ticketInfo.ticketNumber);
return (
<div className="max-w-md flex items-center gap-1 flex-wrap">
<span>{parts[0]}</span>
<Badge
variant="outline"
className="font-mono cursor-pointer hover:bg-blue-50 dark:hover:bg-blue-950 text-blue-600 dark:text-blue-400 border-blue-200 dark:border-blue-800 transition-colors"
onClick={(e) => {
e.stopPropagation();
handleTicketClick(ticketInfo.ticketNumber);
}}
title={`View ticket ${ticketInfo.ticketNumber} in Autotask`}
>
<Ticket className="h-3 w-3 mr-1" />
{ticketInfo.ticketNumber}
</Badge>
<span>{parts[1]}</span>
</div>
);
};
return (
<div className="container mx-auto py-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-bold flex items-center gap-3">
<FileText className="h-8 w-8" />
Open Quotes
</h1>
<p className="text-muted-foreground mt-2">
View and manage quotes from SalesBldr
</p>
</div>
<Button onClick={fetchQuotes} variant="outline" disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Open Quotes</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{quotes.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Sent Quotes</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{quotes.filter(q => q.status === 'sent').length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Draft Quotes</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{quotes.filter(q => q.status === 'draft').length}
</div>
</CardContent>
</Card>
</div>
{/* Quotes Table */}
<Card>
<CardHeader>
<CardTitle>Quotes List</CardTitle>
<CardDescription>
All open quotes from SalesBldr
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="text-center py-8 text-destructive">
<p>Error: {error}</p>
<Button onClick={fetchQuotes} variant="outline" className="mt-4">
Try Again
</Button>
</div>
) : quotes.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No open quotes found</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Quote #</TableHead>
<TableHead>Title</TableHead>
<TableHead>Company</TableHead>
<TableHead>Owner</TableHead>
<TableHead>Status</TableHead>
<TableHead>Date</TableHead>
<TableHead className="text-right">Total</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{quotes.map((quote) => (
<TableRow key={quote.id}>
<TableCell className="font-medium">{quote.number}</TableCell>
<TableCell>
{renderTitleWithTicketLink(quote.title)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
{quote.company?.name || 'N/A'}
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
{quote.owner?.name || 'N/A'}
</div>
</TableCell>
<TableCell>{getStatusBadge(quote.status)}</TableCell>
<TableCell>{formatDate(quote.sentAt || quote.createdAt)}</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(calculateTotal(quote))}
</TableCell>
<TableCell>
{quote.link && (
<Button
variant="ghost"
size="sm"
asChild
>
<a href={quote.link} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Ticket Detail Modal */}
{selectedTicketNumber && (
<TicketDetailModal
ticketNumber={selectedTicketNumber}
open={ticketModalOpen}
onOpenChange={setTicketModalOpen}
/>
)}
</div>
);
}