foamking/app/dashboard/page.tsx
mikl0s 3ebb63dc6c Add admin dashboard, authentication, step wizard, and quote management
Expand the calculator with a multi-step wizard flow, admin dashboard with
quote tracking, login/auth system, distance API integration, and history
page. Add new UI components (dialog, progress, select, slider, switch),
update pricing logic, and improve the overall design with new assets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:59:11 +00:00

266 lines
8.8 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { KanbanBoard } from "@/components/dashboard/kanban-board"
import { SearchFilter } from "@/components/dashboard/search-filter"
import { formatPrice } from "@/lib/calculations"
import { type StoredQuote, type QuoteStatus } from "@/lib/db"
import { LogOut, List, Loader2, RotateCcw, ExternalLink } from "lucide-react"
export default function DashboardPage() {
const router = useRouter()
const [quotes, setQuotes] = useState<StoredQuote[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
// Dialogs
const [rejectQuote, setRejectQuote] = useState<StoredQuote | null>(null)
const [showRejected, setShowRejected] = useState(false)
useEffect(() => {
fetchQuotes()
}, [])
async function fetchQuotes() {
try {
const res = await fetch("/api/quotes")
if (!res.ok) {
if (res.status === 401) {
router.push("/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 handleStatusChange(id: number, status: QuoteStatus) {
// Optimistic update
setQuotes((prev) => prev.map((q) => (q.id === id ? { ...q, status } : q)))
try {
const res = await fetch("/api/quotes", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, status }),
})
if (!res.ok) {
fetchQuotes()
}
} catch {
fetchQuotes()
}
}
function handleRejectClick(quote: StoredQuote) {
setRejectQuote(quote)
}
async function confirmReject() {
if (!rejectQuote) return
await handleStatusChange(rejectQuote.id, "rejected")
setRejectQuote(null)
}
async function handleLogout() {
try {
await fetch("/api/auth/logout", { method: "POST" })
router.push("/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)
)
}, [quotes, search])
const activeQuotes = filteredQuotes.filter((q) => q.status !== "rejected")
const rejectedQuotes = filteredQuotes.filter((q) => q.status === "rejected")
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">Dashboard</h1>
</div>
<div className="flex items-center gap-2">
<Link href="/historik">
<Button variant="ghost" size="sm">
<List className="mr-2 h-4 w-4" />
<span className="hidden sm:inline">Historik</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 */}
<div className="mb-6 max-w-md">
<SearchFilter value={search} onChange={setSearch} />
</div>
{/* Kanban board */}
<KanbanBoard
quotes={activeQuotes}
onStatusChange={handleStatusChange}
onReject={handleRejectClick}
rejectedCount={rejectedQuotes.length}
onShowRejected={() => setShowRejected(true)}
/>
{/* Empty state */}
{quotes.length === 0 && !loading && (
<div className="py-12 text-center">
<p className="text-muted-foreground">
Ingen tilbud endnu. Når kunder anmoder om tilbud, vil de vises her.
</p>
</div>
)}
</div>
{/* Reject confirmation dialog */}
<Dialog open={!!rejectQuote} onOpenChange={() => setRejectQuote(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Afvis tilbud?</DialogTitle>
<DialogDescription>
Er du sikker du vil afvise tilbuddet til{" "}
<span className="font-medium text-foreground">{rejectQuote?.customerName}</span>?
</DialogDescription>
</DialogHeader>
{rejectQuote && (
<div className="rounded-lg bg-muted/50 p-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Tilbud #{rejectQuote.id}</span>
<span className="font-medium">
{rejectQuote.totalInclVat
? formatPrice(Math.round(rejectQuote.totalInclVat))
: "—"}
</span>
</div>
<div className="text-muted-foreground">
{rejectQuote.postalCode} · {rejectQuote.area} m²
</div>
</div>
)}
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={() => setRejectQuote(null)}>
Annuller
</Button>
<Button variant="destructive" onClick={confirmReject}>
Afvis tilbud
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Rejected quotes modal */}
<Dialog open={showRejected} onOpenChange={setShowRejected}>
<DialogContent className="max-h-[80vh] overflow-hidden sm:max-w-lg">
<DialogHeader>
<DialogTitle>Afviste tilbud ({rejectedQuotes.length})</DialogTitle>
</DialogHeader>
<div className="max-h-[60vh] space-y-2 overflow-y-auto pr-2">
{rejectedQuotes.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">Ingen afviste tilbud</p>
) : (
rejectedQuotes.map((quote) => {
const slug = `${quote.postalCode}-${quote.id}`
return (
<div
key={quote.id}
className="flex items-center gap-3 rounded-lg border bg-white p-3"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{quote.customerName}</span>
<a
href={`/tilbud/${slug}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-primary"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<div className="text-sm text-muted-foreground">
{quote.postalCode} · {quote.area} m² ·{" "}
{quote.totalInclVat ? formatPrice(Math.round(quote.totalInclVat)) : "—"}
</div>
</div>
<Button
size="sm"
variant="outline"
className="flex-shrink-0"
onClick={() => {
handleStatusChange(quote.id, "new")
}}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Gendan
</Button>
</div>
)
})
)}
</div>
</DialogContent>
</Dialog>
</main>
)
}