feat: breadcrumb nav, grouped properties sidebar, larger status selector, transitions

This commit is contained in:
Gjermund Høsøien Wiggen
2026-06-07 22:34:31 +02:00
parent 8175b05b23
commit 6f2b0f39f7

View File

@@ -19,6 +19,7 @@ import type {
PreviewResult,
UpdateResult,
} from "@/lib/types";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
const STATUS_COLORS: Record<string, string> = {
@@ -85,7 +86,7 @@ function TransactionBubble({
return (
<div className="flex justify-center py-2">
<span className="text-xs text-[#8a8f98]">
<span className="text-xs text-muted-foreground">
{message} · {timeAgo}
</span>
</div>
@@ -109,28 +110,28 @@ function TransactionBubble({
style={{
backgroundColor: isAgent
? getInitialColor(tx.creator_id)
: "#191a1b",
: "var(--muted)",
}}
>
<span
className="text-[11px] font-semibold"
style={{ color: isAgent ? "#f7f8f8" : "#8a8f98" }}
style={{ color: isAgent ? "#f7f8f8" : "var(--muted-foreground)" }}
>
{getInitial(tx.creator_id)}
</span>
</div>
<div
className={cn(
"max-w-[75%] rounded-lg px-3 py-2",
"max-w-[75%] rounded-lg px-3 py-2 transition-all duration-150",
isAgent
? "bg-[#5e6ad2]/15 text-[#f7f8f8]"
? "bg-primary/15 text-foreground"
: isInternal
? "bg-[#191a1b] border border-[rgba(255,255,255,0.05)] text-[#f7f8f8]"
: "bg-[#191a1b] text-[#f7f8f8]"
? "bg-muted border border-border text-foreground"
: "bg-muted text-foreground"
)}
>
{isInternal && (
<div className="text-[10px] font-semibold text-[#f59e0b] mb-0.5 uppercase tracking-wider">
<div className="text-[10px] font-semibold text-chart-3 mb-0.5 uppercase tracking-wider">
Internal note
</div>
)}
@@ -139,7 +140,7 @@ function TransactionBubble({
? String((tx.data as Record<string, unknown>).body)
: tx.transaction_type}
</p>
<p className="text-[10px] text-[#8a8f98] mt-1">{timeAgo}</p>
<p className="text-[10px] text-muted-foreground mt-1">{timeAgo}</p>
</div>
</div>
);
@@ -262,17 +263,17 @@ export default function TicketDetailPage({
<div className="flex-1 p-4 space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="flex gap-3">
<div className="w-6 h-6 rounded-full bg-[#191a1b] animate-pulse" />
<div className="w-6 h-6 rounded-full bg-muted animate-pulse" />
<div className="flex-1 space-y-2">
<div className="h-3 bg-[#191a1b] rounded animate-pulse w-3/4" />
<div className="h-3 bg-[#191a1b] rounded animate-pulse w-1/2" />
<div className="h-3 bg-muted rounded animate-pulse w-3/4" />
<div className="h-3 bg-muted rounded animate-pulse w-1/2" />
</div>
</div>
))}
</div>
<div className="w-80 bg-[#0f1011] border-l border-[rgba(255,255,255,0.05)] p-4 space-y-3">
<div className="w-80 bg-sidebar border-l border-border p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-6 bg-[#191a1b] rounded animate-pulse" />
<div key={i} className="h-6 bg-muted rounded animate-pulse" />
))}
</div>
</div>
@@ -282,10 +283,10 @@ export default function TicketDetailPage({
if (error && !ticket) {
return (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-red-400 text-sm">{error}</p>
<p className="text-destructive text-sm">{error}</p>
<button
onClick={fetchData}
className="mt-2 text-sm text-[#7170ff] hover:text-[#828fff]"
className="mt-2 text-sm text-primary hover:text-primary/80 transition-all duration-150"
>
Retry
</button>
@@ -296,7 +297,7 @@ export default function TicketDetailPage({
if (!ticket) {
return (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-[#8a8f98] text-sm">Ticket not found</p>
<p className="text-muted-foreground text-sm">Ticket not found</p>
</div>
);
}
@@ -311,28 +312,31 @@ export default function TicketDetailPage({
{/* Left panel — conversation */}
<div className="flex-1 flex flex-col min-w-0">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-[rgba(255,255,255,0.05)]">
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-border">
<Link
href="/"
className="w-7 h-7 flex items-center justify-center rounded-md hover:bg-[rgba(255,255,255,0.05)] text-[#8a8f98] hover:text-[#d0d6e0] transition-colors flex-shrink-0"
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-all duration-150"
>
<ArrowLeftIcon className="w-4 h-4" />
<ArrowLeftIcon className="w-3.5 h-3.5" />
All tickets
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-sm font-semibold text-[#f7f8f8] truncate">
{ticket.subject}
</h1>
<p className="text-xs text-[#8a8f98]">
{ticket.id.slice(0, 8)} · {queue?.name || ticket.queue_id}
</p>
</div>
</div>
{/* Title */}
<div className="px-4 py-3 border-b border-border">
<h1 className="text-sm font-semibold text-foreground truncate">
{ticket.subject}
</h1>
<p className="text-xs text-muted-foreground mt-0.5">
<span className="font-mono">{ticket.id.slice(0, 8)}</span> · {queue?.name || ticket.queue_id}
</p>
</div>
{/* Conversation */}
<div className="flex-1 overflow-y-auto">
{transactions.length === 0 && (
<div className="flex flex-col items-center justify-center py-20">
<p className="text-sm text-[#8a8f98]">
<p className="text-sm text-muted-foreground">
No activity yet
</p>
</div>
@@ -343,16 +347,16 @@ export default function TicketDetailPage({
</div>
{/* Reply box */}
<div className="border-t border-[rgba(255,255,255,0.05)] bg-[#0f1011] p-3">
<div className="border-t border-border bg-sidebar p-3">
{/* Toggle tabs */}
<div className="flex gap-0.5 mb-2 p-0.5 rounded-lg bg-[#08090a] w-fit">
<div className="flex gap-0.5 mb-2 p-0.5 rounded-lg bg-background w-fit">
<button
onClick={() => setReplyMode("public")}
className={cn(
"px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
"px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-150",
replyMode === "public"
? "bg-[#5e6ad2] text-[#f7f8f8]"
: "text-[#8a8f98] hover:text-[#d0d6e0]"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
Reply
@@ -360,10 +364,10 @@ export default function TicketDetailPage({
<button
onClick={() => setReplyMode("internal")}
className={cn(
"px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
"px-2.5 py-1 rounded-md text-xs font-medium transition-all duration-150",
replyMode === "internal"
? "bg-[#5e6ad2] text-[#f7f8f8]"
: "text-[#8a8f98] hover:text-[#d0d6e0]"
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
Internal note
@@ -376,19 +380,19 @@ export default function TicketDetailPage({
onChange={(e) => setReplyText(e.target.value)}
placeholder="Reply to this ticket..."
rows={2}
className="flex-1 px-3 py-2 rounded-lg bg-[#08090a] border border-[rgba(255,255,255,0.08)] text-sm text-[#f7f8f8] placeholder:text-[#8a8f98] outline-none focus:border-[#5e6ad2] focus:ring-1 focus:ring-[#5e6ad2] resize-none"
className="flex-1 px-3 py-2 rounded-lg bg-background border border-border text-sm text-foreground placeholder:text-muted-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary resize-none transition-all duration-150"
/>
<div className="flex items-center gap-1">
<button className="w-8 h-8 flex items-center justify-center rounded-lg text-[#8a8f98] hover:text-[#d0d6e0] hover:bg-[rgba(255,255,255,0.05)] transition-colors" title="Attach file (coming soon)">
<button className="w-8 h-8 flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-accent transition-all duration-150" title="Attach file (coming soon)">
<PaperclipIcon className="w-4 h-4" />
</button>
<button
disabled={!replyText.trim()}
className={cn(
"w-8 h-8 flex items-center justify-center rounded-lg transition-colors",
"w-8 h-8 flex items-center justify-center rounded-lg transition-all duration-150",
replyText.trim()
? "bg-[#5e6ad2] text-[#f7f8f8] hover:bg-[#7170ff]"
: "bg-[#191a1b] text-[#8a8f98] cursor-not-allowed"
? "bg-primary text-primary-foreground hover:bg-primary/80"
: "bg-muted text-muted-foreground cursor-not-allowed"
)}
>
<SendIcon className="w-4 h-4" />
@@ -399,27 +403,27 @@ export default function TicketDetailPage({
</div>
{/* Right panel — properties */}
<div className="w-80 flex-shrink-0 bg-[#0f1011] border-l border-[rgba(255,255,255,0.05)] flex flex-col overflow-y-auto">
<div className="p-4 space-y-4">
{/* Status */}
<div className="w-80 flex-shrink-0 bg-sidebar border-l border-border flex flex-col overflow-y-auto">
<div className="p-4 space-y-5">
{/* Section: Status */}
<div>
<label className="block text-xs font-medium text-[#8a8f98] mb-1.5 uppercase tracking-wider">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Status
</label>
</h3>
<div className="relative">
<button
onClick={() => setStatusSelectOpen(!statusSelectOpen)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-[rgba(255,255,255,0.08)] bg-[#08090a] text-sm hover:border-[rgba(255,255,255,0.15)] transition-colors"
className="w-full flex items-center gap-3 px-4 py-2.5 rounded-lg border border-border bg-background text-sm hover:border-foreground/20 transition-all duration-150"
>
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: currentStatusColor }}
/>
<span className="text-[#f7f8f8] font-medium flex-1 text-left">
<span className="text-foreground font-medium flex-1 text-left">
{currentStatusLabel}
</span>
<svg
className="w-4 h-4 text-[#8a8f98]"
className="w-4 h-4 text-muted-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -434,7 +438,7 @@ export default function TicketDetailPage({
</button>
{statusSelectOpen && (
<div className="absolute top-full left-0 right-0 mt-1 z-10 bg-[#191a1b] border border-[rgba(255,255,255,0.08)] rounded-lg shadow-xl overflow-hidden">
<div className="absolute top-full left-0 right-0 mt-1 z-10 bg-popover border border-border rounded-lg shadow-xl overflow-hidden">
{ALL_STATUSES.map((status) => {
const color = STATUS_COLORS[status];
const label = STATUS_LABELS[status];
@@ -445,19 +449,19 @@ export default function TicketDetailPage({
onClick={() => handleStatusSelect(status)}
disabled={isCurrent}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors",
"w-full flex items-center gap-3 px-4 py-2.5 text-sm text-left transition-all duration-150",
isCurrent
? "bg-[rgba(255,255,255,0.03)] text-[#8a8f98] cursor-default"
: "text-[#d0d6e0] hover:bg-[rgba(255,255,255,0.05)]"
? "bg-accent text-muted-foreground cursor-default"
: "text-foreground hover:bg-accent"
)}
>
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
{label}
{isCurrent && (
<span className="text-xs text-[#8a8f98] ml-auto">
<span className="text-xs text-muted-foreground ml-auto">
current
</span>
)}
@@ -471,10 +475,10 @@ export default function TicketDetailPage({
{/* Status change preview */}
{preview && (
<div className="p-3 rounded-lg bg-[#08090a] border border-[rgba(255,255,255,0.08)]">
<p className="text-xs text-[#8a8f98] mb-2">
<div className="p-3 rounded-lg bg-background border border-border">
<p className="text-xs text-muted-foreground mb-2">
Preview: changing to{" "}
<span className="text-[#f7f8f8] font-medium">
<span className="text-foreground font-medium">
{STATUS_LABELS[pendingStatus || ""]}
</span>
</p>
@@ -483,15 +487,15 @@ export default function TicketDetailPage({
{preview.prepared_scrips.map((scrip) => (
<div
key={scrip.scripId}
className="text-xs text-[#d0d6e0] flex items-center gap-1.5"
className="text-xs text-foreground flex items-center gap-1.5"
>
<span className="w-2 h-2 rounded-full bg-[#f59e0b] flex-shrink-0" />
<span className="w-2 h-2 rounded-full bg-chart-3 flex-shrink-0" />
{scrip.scripName}
</div>
))}
</div>
) : (
<p className="text-xs text-[#8a8f98] mb-3">
<p className="text-xs text-muted-foreground mb-3">
No scrips will fire
</p>
)}
@@ -499,7 +503,7 @@ export default function TicketDetailPage({
<button
onClick={handleApplyStatus}
disabled={applyLoading}
className="px-2.5 py-1 rounded-md text-xs font-medium bg-[#5e6ad2] hover:bg-[#7170ff] text-[#f7f8f8] disabled:opacity-50 transition-colors"
className="px-2.5 py-1 rounded-md text-xs font-medium bg-primary hover:bg-primary/80 text-primary-foreground disabled:opacity-50 transition-all duration-150"
>
{applyLoading
? "Applying..."
@@ -508,7 +512,7 @@ export default function TicketDetailPage({
<button
onClick={handleCancelStatus}
disabled={applyLoading}
className="px-2.5 py-1 rounded-md text-xs font-medium text-[#8a8f98] hover:text-[#d0d6e0] transition-colors"
className="px-2.5 py-1 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground transition-all duration-150"
>
Cancel
</button>
@@ -517,27 +521,27 @@ export default function TicketDetailPage({
)}
{previewError && (
<div className="p-2 rounded-lg bg-red-400/5 border border-red-400/10">
<p className="text-xs text-red-400">{previewError}</p>
<div className="p-2 rounded-lg bg-destructive/5 border border-destructive/10">
<p className="text-xs text-destructive">{previewError}</p>
</div>
)}
{scripResults && (
<div className="p-3 rounded-lg bg-[#08090a] border border-[rgba(255,255,255,0.08)]">
<p className="text-xs text-[#8a8f98] mb-2">Scrip results:</p>
<div className="p-3 rounded-lg bg-background border border-border">
<p className="text-xs text-muted-foreground mb-2">Scrip results:</p>
<div className="space-y-1">
{scripResults.map((result) => (
<div
key={result.scripId}
className={cn(
"text-xs flex items-center gap-1.5",
result.success ? "text-[#22c55e]" : "text-red-400"
result.success ? "text-[#22c55e]" : "text-destructive"
)}
>
<span
className={cn(
"w-2 h-2 rounded-full flex-shrink-0",
result.success ? "bg-[#22c55e]" : "bg-red-400"
result.success ? "bg-[#22c55e]" : "bg-destructive"
)}
/>
{result.message}
@@ -546,119 +550,134 @@ export default function TicketDetailPage({
</div>
<button
onClick={() => setScripResults(null)}
className="mt-2 text-xs text-[#8a8f98] hover:text-[#d0d6e0]"
className="mt-2 text-xs text-muted-foreground hover:text-foreground transition-all duration-150"
>
Dismiss
</button>
</div>
)}
{/* Priority (placeholder) */}
<div>
<label className="block text-xs font-medium text-[#8a8f98] mb-1.5 uppercase tracking-wider">
Priority
</label>
<div className="px-3 py-2 rounded-lg border border-[rgba(255,255,255,0.05)] bg-[#08090a] text-sm text-[#8a8f98]">
Not set
</div>
</div>
<Separator />
{/* Assignee */}
{/* Section: Assignment */}
<div>
<label className="block text-xs font-medium text-[#8a8f98] mb-1.5 uppercase tracking-wider">
Assignee
</label>
{ticket.owner_id ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[rgba(255,255,255,0.08)] bg-[#08090a]">
<div
className="w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0"
style={{
backgroundColor: getInitialColor(ticket.owner_id),
}}
>
<span className="text-[10px] font-semibold text-[#f7f8f8]">
{getInitial(ticket.owner_id)}
</span>
</div>
<span className="text-sm text-[#f7f8f8]">
{ticket.owner_id}
</span>
</div>
) : (
<div className="px-3 py-2 rounded-lg border border-[rgba(255,255,255,0.05)] bg-[#08090a] text-sm text-[#8a8f98]">
Unassigned
</div>
)}
</div>
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Assignment
</h3>
{/* Queue */}
<div>
<label className="block text-xs font-medium text-[#8a8f98] mb-1.5 uppercase tracking-wider">
Queue
</label>
<div className="px-3 py-2 rounded-lg border border-[rgba(255,255,255,0.08)] bg-[#08090a]">
<span className="text-sm text-[#f7f8f8]">
{queue?.name || ticket.queue_id}
</span>
</div>
</div>
{/* Custom fields */}
{ticket.custom_fields && ticket.custom_fields.length > 0 && (
<div>
<label className="block text-xs font-medium text-[#8a8f98] mb-1.5 uppercase tracking-wider">
Custom fields
{/* Assignee */}
<div className="mb-3">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
Assignee
</label>
<div className="space-y-1.5">
{ticket.custom_fields.map((cf) => (
{ticket.owner_id ? (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border bg-background transition-all duration-150 hover:border-foreground/20">
<div
key={cf.id}
className="flex justify-between items-center px-3 py-1.5 rounded-lg border border-[rgba(255,255,255,0.05)] bg-[#08090a]"
className="w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0"
style={{
backgroundColor: getInitialColor(ticket.owner_id),
}}
>
<span className="text-xs text-[#8a8f98]">
{cf.custom_field?.name || cf.custom_field_id}
</span>
<span className="text-xs text-[#f7f8f8] font-medium">
{cf.value}
<span className="text-[10px] font-semibold text-primary-foreground">
{getInitial(ticket.owner_id)}
</span>
</div>
))}
<span className="text-sm text-foreground">
{ticket.owner_id}
</span>
</div>
) : (
<div className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-muted-foreground">
Unassigned
</div>
)}
</div>
{/* Priority (placeholder) */}
<div>
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
Priority
</label>
<div className="px-3 py-2 rounded-lg border border-border bg-background text-sm text-muted-foreground">
Not set
</div>
</div>
)}
</div>
{/* Dates */}
<Separator />
{/* Section: Details */}
<div>
<label className="block text-xs font-medium text-[#8a8f98] mb-1.5 uppercase tracking-wider">
Dates
</label>
<div className="space-y-1 text-xs">
<div className="flex justify-between px-1">
<span className="text-[#8a8f98]">Created</span>
<span className="text-[#d0d6e0] tabular-nums">
{formatDistanceToNow(new Date(ticket.created_at), {
addSuffix: true,
})}
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Details
</h3>
{/* Queue */}
<div className="mb-3">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
Queue
</label>
<div className="px-3 py-2 rounded-lg border border-border bg-background">
<span className="text-sm text-foreground">
{queue?.name || ticket.queue_id}
</span>
</div>
<div className="flex justify-between px-1">
<span className="text-[#8a8f98]">Updated</span>
<span className="text-[#d0d6e0] tabular-nums">
{formatDistanceToNow(new Date(ticket.updated_at), {
addSuffix: true,
})}
</span>
</div>
{/* Custom fields */}
{ticket.custom_fields && ticket.custom_fields.length > 0 && (
<div className="mb-3">
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
Custom fields
</label>
<div className="space-y-1.5">
{ticket.custom_fields.map((cf) => (
<div
key={cf.id}
className="flex justify-between items-center px-3 py-1.5 rounded-lg border border-border bg-background"
>
<span className="text-xs text-muted-foreground">
{cf.custom_field?.name || cf.custom_field_id}
</span>
<span className="text-xs text-foreground font-medium">
{cf.value}
</span>
</div>
))}
</div>
</div>
{ticket.resolved_at && (
<div className="flex justify-between px-1">
<span className="text-[#8a8f98]">Resolved</span>
<span className="text-[#d0d6e0] tabular-nums">
{formatDistanceToNow(new Date(ticket.resolved_at), {
)}
{/* Dates */}
<div>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">Created</span>
<span className="text-foreground tabular-nums">
{formatDistanceToNow(new Date(ticket.created_at), {
addSuffix: true,
})}
</span>
</div>
)}
<div className="flex justify-between">
<span className="text-muted-foreground">Updated</span>
<span className="text-foreground tabular-nums">
{formatDistanceToNow(new Date(ticket.updated_at), {
addSuffix: true,
})}
</span>
</div>
{ticket.resolved_at && (
<div className="flex justify-between">
<span className="text-muted-foreground">Resolved</span>
<span className="text-foreground tabular-nums">
{formatDistanceToNow(new Date(ticket.resolved_at), {
addSuffix: true,
})}
</span>
</div>
)}
</div>
</div>
</div>
</div>