266 lines
8.7 KiB
TypeScript
266 lines
8.7 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("/intern/login")
|
|
return
|
|
}
|
|
throw new Error("Failed to fetch quotes")
|
|
}
|
|
const data = await res.json()
|
|
setQuotes(data.quotes)
|
|
} catch {
|
|
// fetch failed
|
|
} 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("/intern/login")
|
|
router.refresh()
|
|
} catch {
|
|
// logout failed
|
|
}
|
|
}
|
|
|
|
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="/intern/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 på 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>
|
|
)
|
|
}
|