222 lines
8.3 KiB
TypeScript
222 lines
8.3 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState, useMemo } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import Image from "next/image"
|
|
import Link from "next/link"
|
|
import { Button } from "@/components/ui/button"
|
|
import { SearchFilter } from "@/components/dashboard/search-filter"
|
|
import { formatPrice } from "@/lib/calculations"
|
|
import { type StoredQuote, type QuoteStatus } from "@/lib/db"
|
|
import { LogOut, LayoutDashboard, Loader2, ExternalLink } from "lucide-react"
|
|
|
|
const STATUS_LABELS: Record<QuoteStatus, { label: string; className: string }> = {
|
|
new: { label: "Ny", className: "bg-blue-100 text-blue-700" },
|
|
contacted: { label: "Kontaktet", className: "bg-amber-100 text-amber-700" },
|
|
accepted: { label: "Accepteret", className: "bg-green-100 text-green-700" },
|
|
rejected: { label: "Afvist", className: "bg-gray-100 text-gray-500" },
|
|
}
|
|
|
|
function formatDate(dateString: string): string {
|
|
return new Date(dateString).toLocaleDateString("da-DK", {
|
|
day: "numeric",
|
|
month: "short",
|
|
year: "numeric",
|
|
})
|
|
}
|
|
|
|
export default function HistorikPage() {
|
|
const router = useRouter()
|
|
const [quotes, setQuotes] = useState<StoredQuote[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [search, setSearch] = useState("")
|
|
|
|
useEffect(() => {
|
|
fetchQuotes()
|
|
}, [])
|
|
|
|
async function fetchQuotes() {
|
|
try {
|
|
const res = await fetch("/api/quotes")
|
|
if (!res.ok) {
|
|
if (res.status === 401) {
|
|
router.push("/intern/login")
|
|
return
|
|
}
|
|
throw new Error("Failed to fetch quotes")
|
|
}
|
|
const data = await res.json()
|
|
setQuotes(data.quotes)
|
|
} catch (error) {
|
|
console.error("Failed to fetch quotes:", error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
async function handleLogout() {
|
|
try {
|
|
await fetch("/api/auth/logout", { method: "POST" })
|
|
router.push("/intern/login")
|
|
router.refresh()
|
|
} catch (error) {
|
|
console.error("Logout failed:", error)
|
|
}
|
|
}
|
|
|
|
const filteredQuotes = useMemo(() => {
|
|
if (!search.trim()) return quotes
|
|
|
|
const term = search.toLowerCase()
|
|
return quotes.filter(
|
|
(q) =>
|
|
q.customerName.toLowerCase().includes(term) ||
|
|
q.customerEmail.toLowerCase().includes(term) ||
|
|
q.postalCode.includes(term) ||
|
|
q.id.toString().includes(term)
|
|
)
|
|
}, [quotes, search])
|
|
|
|
if (loading) {
|
|
return (
|
|
<main className="flex min-h-screen items-center justify-center bg-muted/30">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</main>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<main className="min-h-screen bg-muted/30">
|
|
{/* Header */}
|
|
<header className="sticky top-0 z-10 border-b bg-white">
|
|
<div className="container mx-auto flex items-center justify-between px-4 py-3">
|
|
<div className="flex items-center gap-4">
|
|
<Link href="/">
|
|
<Image
|
|
src="/foam-king-logo.png"
|
|
alt="Foam King"
|
|
width={120}
|
|
height={48}
|
|
className="h-10 w-auto"
|
|
/>
|
|
</Link>
|
|
<h1 className="hidden text-lg font-semibold sm:block">Historik</h1>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Link href="/intern">
|
|
<Button variant="ghost" size="sm">
|
|
<LayoutDashboard className="mr-2 h-4 w-4" />
|
|
<span className="hidden sm:inline">Dashboard</span>
|
|
</Button>
|
|
</Link>
|
|
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
|
<LogOut className="mr-2 h-4 w-4" />
|
|
<span className="hidden sm:inline">Log ud</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Main content */}
|
|
<div className="container mx-auto px-4 py-6">
|
|
{/* Search and stats */}
|
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="max-w-md flex-1">
|
|
<SearchFilter value={search} onChange={setSearch} />
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{filteredQuotes.length} tilbud
|
|
{search && ` (af ${quotes.length})`}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<div className="overflow-hidden rounded-xl border bg-white shadow-sm">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b bg-muted/50">
|
|
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
|
#
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
|
Kunde
|
|
</th>
|
|
<th className="hidden px-4 py-3 text-left text-sm font-medium text-muted-foreground md:table-cell">
|
|
Lokation
|
|
</th>
|
|
<th className="hidden px-4 py-3 text-left text-sm font-medium text-muted-foreground sm:table-cell">
|
|
Areal
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
|
Pris
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
|
Status
|
|
</th>
|
|
<th className="hidden px-4 py-3 text-left text-sm font-medium text-muted-foreground lg:table-cell">
|
|
Dato
|
|
</th>
|
|
<th className="px-4 py-3 text-right text-sm font-medium text-muted-foreground"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredQuotes.map((quote) => {
|
|
const slug = `${quote.postalCode}-${quote.id}`
|
|
const status = STATUS_LABELS[quote.status]
|
|
return (
|
|
<tr key={quote.id} className="border-b last:border-0 hover:bg-muted/30">
|
|
<td className="px-4 py-3 text-sm text-muted-foreground">{quote.id}</td>
|
|
<td className="px-4 py-3">
|
|
<div className="font-medium">{quote.customerName}</div>
|
|
<div className="text-sm text-muted-foreground">{quote.customerEmail}</div>
|
|
</td>
|
|
<td className="hidden px-4 py-3 text-sm md:table-cell">
|
|
{quote.postalCode}
|
|
{quote.address && (
|
|
<span className="text-muted-foreground">, {quote.address}</span>
|
|
)}
|
|
</td>
|
|
<td className="hidden px-4 py-3 text-sm sm:table-cell">{quote.area} m²</td>
|
|
<td className="px-4 py-3 font-medium">
|
|
{quote.totalInclVat ? formatPrice(Math.round(quote.totalInclVat)) : "—"}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span
|
|
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${status.className}`}
|
|
>
|
|
{status.label}
|
|
</span>
|
|
</td>
|
|
<td className="hidden px-4 py-3 text-sm text-muted-foreground lg:table-cell">
|
|
{formatDate(quote.createdAt)}
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<a
|
|
href={`/tilbud/${slug}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
|
>
|
|
Åbn
|
|
<ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{filteredQuotes.length === 0 && (
|
|
<div className="py-12 text-center text-muted-foreground">
|
|
{search ? "Ingen tilbud matcher søgningen" : "Ingen tilbud endnu"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|