311 lines
10 KiB
TypeScript
311 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 { 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 [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',
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
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>
|
||
|
|
);
|
||
|
|
}
|