feat: implement frontend UI redesign, expand component library, and update case implementation documentation.

This commit is contained in:
ashwin 2026-05-11 17:24:01 +05:30
parent 3825b34189
commit f4f677ebec
84 changed files with 4866 additions and 2173 deletions

View file

@ -66,6 +66,7 @@ class LeadSerializer(serializers.ModelSerializer):
# System
"created_by",
"created_at",
"updated_at",
"is_active",
"company_name",
# Kanban

View file

@ -65,14 +65,17 @@
--chart-5: #fed7aa;
/* Sidebar */
--sidebar: oklch(0.995 0 0);
--sidebar-foreground: oklch(0.25 0.02 60);
--sidebar: oklch(1 0 0);
--sidebar-foreground: oklch(0.18 0 0);
--sidebar-primary: #ea580c;
--sidebar-primary-foreground: oklch(0.995 0 0);
--sidebar-accent: oklch(0.97 0.01 60);
--sidebar-accent-foreground: oklch(0.2 0.02 60);
--sidebar-border: oklch(0.94 0.005 60);
--sidebar-accent: oklch(0.96 0 0);
--sidebar-accent-foreground: oklch(0.18 0 0);
--sidebar-border: oklch(0.94 0 0);
--sidebar-ring: #ea580c;
--sidebar-active-fill: oklch(0.94 0 0);
--sidebar-muted: oklch(0.55 0 0);
--sidebar-subtle: oklch(0.65 0 0);
/* ─────────────────────────────────────────────────────────────────────────
PRIMARY COLOR SCALE
@ -349,9 +352,9 @@
/* ─────────────────────────────────────────────────────────────────────────
LAYOUT DIMENSIONS
───────────────────────────────────────────────────────────────────────── */
--sidebar-width: 260px;
--sidebar-width: 248px;
--sidebar-width-collapsed: 72px;
--sidebar-width-icon: 3rem;
--sidebar-width-icon: 56px;
--drawer-width: 520px;
--header-height: 60px;
--content-padding: 28px;
@ -409,6 +412,63 @@
--status-danger-bg: #fdeaea;
--status-info: #17a2b8;
--status-info-bg: #e3f2fd;
/* ─────────────────────────────────────────────────────────────────────────
REDESIGN TOKENS (2026-05-10 spec) — Phase 1
New design system, additive. Existing tokens above are kept for
back-compat until Phase 2+ migrates components onto these.
Spec: docs/superpowers/specs/2026-05-10-app-redesign-design.md
───────────────────────────────────────────────────────────────────────── */
/* Surface */
--bg: #ffffff;
--bg-page: #fafafa;
--bg-card: #ffffff;
--bg-elevated: #fafafa;
--bg-hover: #f5f5f4;
--bg-active: #f0efed;
--bg-input: #ffffff;
/* Border (NOTE: --border, --border-strong, --focus-ring redefined — see Phase 1 plan §Task 3) */
--border-faint: #f0f0f0;
--border: #e5e5e5;
--border-strong: #c4c4c4;
--focus-ring: rgba(17, 17, 17, 0.10);
/* Text */
--text: #111111;
--text-muted: #525252;
--text-subtle: #999999;
/* Accents — semantic */
--green: #10b981;
--green-soft: rgba(16, 185, 129, 0.12);
--green-soft-text: #047857;
--violet: #7c3aed;
--violet-soft: rgba(124, 58, 237, 0.10);
--violet-soft-text: #6d28d9;
--amber-soft: rgba(217, 119, 6, 0.10);
--amber-soft-text: #92400e;
--red: #dc2626;
--red-soft: rgba(220, 38, 38, 0.08);
--orange: #ea580c;
/* CTA */
--cta-bg: #111111;
--cta-text: #ffffff;
--cta-bg-hover: #000000;
/* Radii */
--r-sm: 5px;
--r-md: 7px;
--r-lg: 10px;
--r-xl: 12px;
--r-full: 9999px;
/* Fonts (override existing aliases at lines ~379–381) */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, ui-sans-serif, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, ui-sans-serif, sans-serif;
}
/* ═══════════════════════════════════════════════════════════════════════════
@ -457,14 +517,17 @@
--chart-5: #fff7ed;
/* Sidebar */
--sidebar: oklch(0.16 0.01 30);
--sidebar-foreground: oklch(0.92 0.005 60);
--sidebar: oklch(0.14 0 0);
--sidebar-foreground: oklch(0.92 0 0);
--sidebar-primary: #fb923c;
--sidebar-primary-foreground: oklch(0.12 0.01 30);
--sidebar-accent: oklch(0.22 0.015 30);
--sidebar-accent-foreground: oklch(0.92 0.005 60);
--sidebar-border: oklch(0.28 0.01 30);
--sidebar-accent: rgba(255, 255, 255, 0.06);
--sidebar-accent-foreground: oklch(0.92 0 0);
--sidebar-border: rgba(255, 255, 255, 0.08);
--sidebar-ring: #fb923c;
--sidebar-active-fill: rgba(255, 255, 255, 0.10);
--sidebar-muted: rgba(255, 255, 255, 0.65);
--sidebar-subtle: rgba(255, 255, 255, 0.45);
/* Primary color scale - Dark mode */
--color-primary-default: #fb923c;
@ -572,6 +635,50 @@
--glass-bg: rgba(18, 19, 24, 0.8);
--glass-border: rgba(255, 255, 255, 0.08);
--glass-blur: 16px;
/* ─────────────────────────────────────────────────────────────────────────
REDESIGN TOKENS (2026-05-10 spec) — Phase 1, dark theme overrides
───────────────────────────────────────────────────────────────────────── */
/* Surface */
--bg: #000000;
--bg-page: #000000;
--bg-card: #0a0a0b;
--bg-elevated: #131316;
--bg-hover: #18181c;
--bg-active: #1f1f23;
--bg-input: #131316;
/* Border */
--border-faint: rgba(255, 255, 255, 0.06);
--border: rgba(255, 255, 255, 0.10);
--border-strong: rgba(255, 255, 255, 0.20);
--focus-ring: rgba(237, 242, 245, 0.14);
/* Text */
--text: #edf2f5;
--text-muted: #828282;
--text-subtle: #555555;
/* Accents — semantic */
--green: #10b981;
--green-soft: rgba(16, 185, 129, 0.14);
--green-soft-text: #6ee7b7;
--violet: #7c7cff;
--violet-soft: rgba(124, 124, 255, 0.14);
--violet-soft-text: #c4c4ff;
--amber-soft: rgba(217, 119, 6, 0.18);
--amber-soft-text: #fcd34d;
--red: #f87171;
--red-soft: rgba(248, 113, 113, 0.10);
--orange: #ea580c;
/* CTA (inverted) */
--cta-bg: #ffffff;
--cta-text: #18181b;
--cta-bg-hover: #ededed;
/* Radii + fonts inherit from :root — no override needed */
}
/* ═══════════════════════════════════════════════════════════════════════════
@ -1013,57 +1120,16 @@ select {
transform 0.2s ease;
}
/* Menu button refinements */
[data-slot='sidebar-menu-button'] {
letter-spacing: -0.01em;
}
/* Active state with left accent bar */
/* Required for the dot-indicator absolute positioning */
[data-slot='sidebar-menu-button'][data-active='true'] {
position: relative;
}
/* Smooth hover transitions */
[data-slot='sidebar-menu-button']:hover {
transform: translateX(1px);
}
[data-slot='sidebar-menu-button'][data-active='true']:hover {
transform: none;
}
/* Group label styling - uppercase sections */
[data-slot='sidebar-group-label'] {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--sidebar-foreground);
opacity: 0.4;
padding: 0 12px;
margin-bottom: 4px;
}
/* Submenu refinements */
[data-slot='sidebar-menu-sub'] {
margin-top: 4px;
}
/* Rail hover effect - Orange accent */
[data-slot='sidebar-rail']:hover {
background: linear-gradient(90deg, transparent, rgba(234, 88, 12, 0.1));
}
/* Collapsed state icon centering */
[data-collapsible='icon'] [data-slot='sidebar-menu-button'] {
justify-content: center;
}
/* Footer divider */
[data-slot='sidebar-footer'] {
background: linear-gradient(180deg, transparent, var(--sidebar) 8px);
}
/* ═══════════════════════════════════════════════════════════════════════════
KANBAN BOARD STYLES
═══════════════════════════════════════════════════════════════════════════ */
@ -1116,3 +1182,90 @@ select {
background: var(--color-primary-light);
border-left: 3px solid var(--color-primary-default);
}
@layer utilities {
/* ─────────────────────────────────────────────────────────────────────────
REDESIGN TYPOGRAPHY UTILITIES (2026-05-10 spec) — Phase 1
See spec §5 for usage. Tabular numerics applied where data-heavy.
───────────────────────────────────────────────────────────────────────── */
.h-display {
font-size: 28px;
font-weight: 500;
letter-spacing: -0.035em;
color: var(--text);
}
.h-page {
font-size: 22px;
font-weight: 500;
letter-spacing: -0.03em;
color: var(--text);
}
.h-section {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text);
}
.t-body {
font-size: 13.5px;
font-weight: 400;
color: var(--text);
}
.t-body-sm {
font-size: 12.5px;
font-weight: 400;
color: var(--text-muted);
}
.t-meta {
font-size: 11.5px;
font-weight: 400;
color: var(--text-subtle);
}
.label-section {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-subtle);
}
.label-tiny {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-subtle);
}
.kbd {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
color: var(--text-muted);
background: var(--bg-elevated);
border: 1px solid var(--border-faint);
border-radius: 3px;
padding: 1px 4px;
}
/* Apply tabular numerics globally on a helper class — useful for tables, KPI cards */
.tabular-nums {
font-variant-numeric: tabular-nums;
}
}
/* Inter variant features (cv11 = sharper digits, ss01 = alternate single-storey 'a').
Applied at root so all descendants inherit.
NOTE: This intentionally overrides the @layer base html { ... } rule earlier in this file.
Geist-specific features (cv02/cv03/cv04) are dropped since we're using Inter, not Geist.
When components fully migrate off Geist in Phase 2+, the older @layer base rule can be removed. */
html {
font-feature-settings: 'cv11', 'ss01';
}

View file

@ -1,11 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<!-- Google Fonts - Geist (via Vercel CDN) -->
<!-- Google Fonts - Geist (kept for back-compat), Inter (new primary), JetBrains Mono (kbd chips) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&display=swap"
href="https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@100..900&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<!-- Google tag (gtag.js) -->

View file

@ -6,11 +6,12 @@
* @typedef {Object} Props
* @property {Object} user - User object with name, email, profilePhoto
* @property {string} [org_name] - Organization name
* @property {Object} [org_settings] - Org settings (tier, currency, etc.)
* @property {import('svelte').Snippet} [children] - Child content
*/
/** @type {Props & { children?: import('svelte').Snippet }} */
let { user = {}, org_name = 'BottleCRM', children } = $props();
let { user = {}, org_name = 'BottleCRM', org_settings = {}, children } = $props();
// Get initial sidebar state from cookie (set by server or previous session)
let defaultOpen = $state(true);
@ -28,7 +29,7 @@
</script>
<Sidebar.Provider open={defaultOpen} class="" style="">
<AppSidebar {user} {org_name} />
<AppSidebar {user} {org_name} {org_settings} />
<Sidebar.Inset class="">
{@render children?.()}
</Sidebar.Inset>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,46 @@
<script>
import { X } from '@lucide/svelte';
/**
* @type {{
* label: string,
* active?: boolean,
* value?: string,
* icon?: import('svelte').Snippet,
* onclick?: (e: MouseEvent) => void,
* onclear?: (e: MouseEvent) => void,
* dashed?: boolean,
* class?: string,
* }}
*/
let { label, active = false, value = '', icon, onclick, onclear, dashed = false, class: className = '' } = $props();
const base =
'inline-flex h-6 items-center gap-1.5 rounded-[var(--r-sm)] px-[9px] text-[11.5px] font-medium leading-none transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[color:var(--ring)]';
const neutral =
'border border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)] hover:bg-[color:var(--bg-hover)]';
const activeCls =
'border border-[color:var(--violet)]/40 bg-[color:var(--violet-soft)] text-[color:var(--violet-soft-text)]';
const dashedCls =
'border border-dashed border-[color:var(--border-faint)] bg-transparent text-[color:var(--text-subtle)] hover:text-[color:var(--text-muted)] hover:border-[color:var(--border)]';
</script>
<button
type="button"
{onclick}
class="{base} {dashed ? dashedCls : active ? activeCls : neutral} {className}"
>
{#if icon}<span class="flex shrink-0 items-center">{@render icon()}</span>{/if}
<span class="truncate">{label}{#if value}<span class="ml-1 opacity-80">: {value}</span>{/if}</span>
{#if active && onclear}
<span
role="button"
tabindex="-1"
class="-mr-1 ml-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-sm hover:bg-[color:var(--violet)]/15"
onclick={(e) => { e.stopPropagation(); onclear(e); }}
onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); onclear(/** @type {any} */ (e)); } }}
aria-label="Clear filter"
>
<X class="size-3 stroke-[2]" />
</span>
{/if}
</button>

View file

@ -0,0 +1,21 @@
<script>
/**
* @type {{
* children?: import('svelte').Snippet,
* meta?: import('svelte').Snippet,
* class?: string,
* }}
*/
let { children, meta, class: className = '' } = $props();
</script>
<div class="flex items-center gap-2 border-b border-[color:var(--border-faint)] px-7 py-2.5 md:px-8 {className}">
<div class="flex flex-wrap items-center gap-2">
{@render children?.()}
</div>
{#if meta}
<div class="ml-auto flex items-center gap-2 text-[11.5px] text-[color:var(--text-subtle)]">
{@render meta()}
</div>
{/if}
</div>

View file

@ -1,60 +1,107 @@
<script>
import { ChevronRight } from '@lucide/svelte';
/**
* @typedef {Object} BreadcrumbItem
* @property {string} label
* @property {string} [href]
*/
/**
* @typedef {Object} Props
* @property {string} title - Page title
* @property {string} [subtitle] - Optional subtitle
* @property {import('svelte').Snippet} [actions] - Optional actions snippet
* @property {string} title
* @property {string} [subtitle]
* @property {'page' | 'display'} [size]
* @property {string} [eyebrow]
* @property {BreadcrumbItem[]} [breadcrumb]
* @property {import('svelte').Snippet} [titleIcon]
* @property {import('svelte').Snippet} [meta]
* @property {import('svelte').Snippet} [amount]
* @property {import('svelte').Snippet} [actions]
* @property {import('svelte').Snippet} [tabs]
*/
/** @type {Props} */
let { title, subtitle = '', actions } = $props();
let {
title,
subtitle = '',
size = 'page',
eyebrow = '',
breadcrumb = [],
titleIcon,
meta,
amount,
actions,
tabs,
} = $props();
</script>
<header
class="border-border/40 from-background via-background to-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-10 border-b bg-gradient-to-r backdrop-blur-2xl"
class="sticky top-0 z-10 flex flex-col gap-3 bg-[color:var(--bg)] px-7 pt-6 md:px-8 {tabs ? 'pb-0' : 'pb-4'}"
>
<!-- Ambient glow effect for dark mode -->
<div class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div
class="bg-primary/5 dark:bg-primary/10 absolute -top-24 left-1/4 h-48 w-96 rounded-full blur-3xl"
></div>
<div
class="bg-accent/5 absolute -top-24 right-1/4 h-48 w-64 rounded-full blur-3xl dark:bg-cyan-500/8"
></div>
</div>
{#if breadcrumb.length > 0}
<nav
aria-label="Breadcrumb"
class="flex items-center gap-1 text-[12px] leading-none text-[color:var(--text-subtle)]"
>
{#each breadcrumb as crumb, i (i)}
{#if i > 0}
<ChevronRight class="size-[14px] shrink-0 stroke-[1.6]" aria-hidden="true" />
{/if}
{#if crumb.href && i < breadcrumb.length - 1}
<a
href={crumb.href}
class="rounded-sm transition-colors hover:text-[color:var(--text-muted)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[color:var(--ring)]"
>{crumb.label}</a>
{:else}
<span class={i === breadcrumb.length - 1 ? 'text-[color:var(--text-muted)]' : ''}>{crumb.label}</span>
{/if}
{/each}
</nav>
{/if}
<div class="relative flex min-h-[4.5rem] items-center justify-between gap-6 px-6 py-4 md:px-8">
<!-- Title Section -->
<div class="flex flex-col gap-1">
<div class="flex items-baseline gap-3">
<h1
class="text-foreground text-2xl font-bold tracking-tight md:text-[1.75rem]"
style="letter-spacing: -0.025em;"
>
{title}
</h1>
<div class="flex min-h-[44px] items-start justify-between gap-6">
<div class="flex min-w-0 flex-1 items-start gap-3">
{#if titleIcon}
<div class="flex size-7 shrink-0 items-center justify-center rounded-[var(--r-md)] bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)]">
{@render titleIcon()}
</div>
{/if}
<div class="flex min-w-0 flex-1 flex-col gap-1">
{#if eyebrow}
<p class="label-tiny">{eyebrow}</p>
{/if}
<h1 class={size === 'display' ? 'h-display truncate' : 'h-page truncate'}>{title}</h1>
{#if subtitle}
<span class="hidden items-center gap-2 sm:flex">
<span class="bg-muted-foreground/30 h-1 w-1 rounded-full"></span>
<p class="text-muted-foreground/80 text-sm font-medium">{subtitle}</p>
</span>
<p class="t-meta truncate">{subtitle}</p>
{/if}
</div>
{#if subtitle}
<p class="text-muted-foreground/80 text-sm font-medium sm:hidden">{subtitle}</p>
{/if}
</div>
<!-- Actions Section -->
{#if actions}
<div class="flex items-center gap-2 md:gap-3">
{@render actions()}
{#if amount || actions}
<div class="flex shrink-0 items-center gap-3">
{#if amount}
<div class="flex flex-col items-end gap-0.5">
{@render amount()}
</div>
{/if}
{#if actions}
<div class="flex items-center gap-2">
{@render actions()}
</div>
{/if}
</div>
{/if}
</div>
<!-- Bottom accent line -->
<div
class="via-border/60 absolute right-0 bottom-0 left-0 h-px bg-gradient-to-r from-transparent to-transparent"
></div>
{#if meta}
<div class="flex flex-wrap items-center gap-2 text-[color:var(--text-muted)]">
{@render meta()}
</div>
{/if}
{#if tabs}
<div class="-mx-7 mt-1 border-t border-[color:var(--border-faint)] px-7 md:-mx-8 md:px-8">
{@render tabs()}
</div>
{/if}
</header>

View file

@ -0,0 +1,40 @@
<script>
/**
* @type {{
* status?: string,
* online?: boolean,
* hints?: { keys: string[], label: string }[],
* class?: string,
* }}
*/
let {
status = 'Ready',
online = true,
hints = [
{ keys: ['↑', '↓'], label: 'navigate' },
{ keys: ['⏎'], label: 'open' },
{ keys: ['⌘K'], label: 'command' },
],
class: className = '',
} = $props();
</script>
<div
class="sticky bottom-0 z-10 flex h-8 items-center justify-between gap-4 border-t border-[color:var(--border-faint)] bg-[color:var(--bg)] px-7 md:px-8 text-[11px] text-[color:var(--text-subtle)] {className}"
>
<div class="flex items-center gap-2">
{#if online}
<span class="size-1.5 rounded-full bg-[color:var(--green)]" aria-hidden="true"></span>
{/if}
<span>{status}</span>
</div>
<div class="hidden items-center gap-3 sm:flex">
{#each hints as h, i (i)}
<span class="flex items-center gap-1">
{#each h.keys as k (k)}<span class="kbd">{k}</span>{/each}
<span class="ml-1">{h.label}</span>
</span>
{#if i < hints.length - 1}<span aria-hidden="true">·</span>{/if}
{/each}
</div>
</div>

View file

@ -0,0 +1,32 @@
<script>
/**
* @type {{
* views: { id: string, label: string, count?: number }[],
* active?: string,
* onSelect?: (id: string) => void,
* }}
*/
let { views, active = views[0]?.id, onSelect } = $props();
</script>
<div class="flex items-center gap-1 overflow-x-auto" role="tablist" aria-label="Views">
{#each views as v (v.id)}
{@const isActive = v.id === active}
<button
type="button"
role="tab"
aria-selected={isActive}
onclick={() => onSelect?.(v.id)}
class="-mb-px inline-flex items-center gap-1.5 px-3 pt-2 pb-[11px] text-[12.5px] leading-none transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[color:var(--ring)] {isActive
? 'font-medium text-[color:var(--text)] border-b-2 border-[color:var(--text)]'
: 'text-[color:var(--text-muted)] border-b-2 border-transparent hover:text-[color:var(--text)]'}"
>
<span>{v.label}</span>
{#if v.count != null}
<span class="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[var(--r-sm)] border border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] px-1.5 text-[10.5px] tabular-nums text-[color:var(--text-muted)]">
{v.count}
</span>
{/if}
</button>
{/each}
</div>

View file

@ -2,3 +2,7 @@ export { default as AppShell } from './AppShell.svelte';
export { default as AppSidebar } from './AppSidebar.svelte';
export { default as FilterPopover } from './FilterPopover.svelte';
export { default as PageHeader } from './PageHeader.svelte';
export { default as FilterPill } from './FilterPill.svelte';
export { default as FilterStrip } from './FilterStrip.svelte';
export { default as ViewTabs } from './ViewTabs.svelte';
export { default as StatusBar } from './StatusBar.svelte';

View file

@ -1,13 +1,40 @@
<script>
import { Avatar as AvatarPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { gradientFor, WORKSPACE_GRADIENT } from './avatar-gradients.js';
let { ref = $bindable(null), class: className, ...restProps } = $props();
/**
* @type {{
* ref?: HTMLElement | null,
* class?: string,
* gradientSeed?: string | number,
* workspace?: boolean,
* style?: string,
* children?: import('svelte').Snippet,
* [key: string]: any
* }}
*/
let {
ref = $bindable(null),
class: className,
gradientSeed = undefined,
workspace = false,
style: callerStyle = '',
...restProps
} = $props();
const bg = $derived(workspace ? WORKSPACE_GRADIENT : gradientFor(gradientSeed));
const styleValue = $derived(`background-image: ${bg};${callerStyle ? ' ' + callerStyle : ''}`);
</script>
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn('bg-muted flex size-full items-center justify-center rounded-full', className)}
class={cn(
'flex size-full items-center justify-center rounded-full',
'text-white font-semibold leading-none',
className
)}
style={styleValue}
{...restProps}
/>

View file

@ -0,0 +1,22 @@
export const AVATAR_GRADIENTS = [
'linear-gradient(135deg, #a78bfa, #7c3aed)', // 0 purple
'linear-gradient(135deg, #f472b6, #db2777)', // 1 pink
'linear-gradient(135deg, #34d399, #059669)', // 2 green
'linear-gradient(135deg, #60a5fa, #2563eb)', // 3 blue
'linear-gradient(135deg, #fbbf24, #ef4444)', // 4 orange-red
'linear-gradient(135deg, #67e8f9, #0891b2)', // 5 cyan
'linear-gradient(135deg, #fcd34d, #d97706)' // 6 amber
];
export const WORKSPACE_GRADIENT = 'linear-gradient(135deg, #ea580c, #c2410c)';
// Stable string hash → bucket [0,6]. djb2-ish; only needs to be deterministic & well-spread.
export function gradientFor(seed) {
if (seed == null) return AVATAR_GRADIENTS[0];
const s = String(seed);
let h = 5381;
for (let i = 0; i < s.length; i++) {
h = ((h << 5) + h + s.charCodeAt(i)) | 0;
}
return AVATAR_GRADIENTS[Math.abs(h) % AVATAR_GRADIENTS.length];
}

View file

@ -4,12 +4,14 @@
/**
* @typedef {'loading' | 'loaded' | 'error'} LoadingStatus
* @typedef {'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'} AvatarSize
*/
/**
* @type {{
* ref?: HTMLElement | null,
* loadingStatus?: LoadingStatus,
* size?: AvatarSize,
* class?: string,
* children?: import('svelte').Snippet,
* [key: string]: any
@ -18,15 +20,29 @@
let {
ref = $bindable(null),
loadingStatus = $bindable(/** @type {LoadingStatus} */ ('loading')),
size = 'md',
class: className,
...restProps
} = $props();
// Spec §6.5: 18 / 22 / 24 / 28 / 32 / 44.
const sizeClass = $derived(
{
xs: 'size-[18px] text-[8px]',
sm: 'size-[22px] text-[9px]',
md: 'size-[24px] text-[10px]',
lg: 'size-[28px] text-[11px]',
xl: 'size-[32px] text-[12px]',
'2xl': 'size-[44px] text-[14px]'
}[size] || 'size-[24px] text-[10px]'
);
</script>
<AvatarPrimitive.Root
bind:ref
bind:loadingStatus
data-slot="avatar"
class={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
data-size={size}
class={cn('relative flex shrink-0 overflow-hidden rounded-full', sizeClass, className)}
{...restProps}
/>

View file

@ -11,3 +11,4 @@ export {
Image as AvatarImage,
Fallback as AvatarFallback
};
export { gradientFor, AVATAR_GRADIENTS, WORKSPACE_GRADIENT } from './avatar-gradients.js';

View file

@ -2,21 +2,44 @@
import { tv } from 'tailwind-variants';
export const badgeVariants = tv({
base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
base: [
'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap',
'rounded-full border text-[10.5px] font-medium leading-none',
'transition-[color,background-color,border-color] duration-150',
'focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]',
'[&>svg]:pointer-events-none [&>svg]:size-3'
].join(' '),
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
// Legacy shadcn variants — kept for back-compat with existing callers.
default: 'bg-primary text-primary-foreground border-transparent px-2 py-0.5 [a&]:hover:bg-primary/90',
secondary:
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
'bg-secondary text-secondary-foreground border-transparent px-2 py-0.5 [a&]:hover:bg-secondary/90',
destructive:
'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white',
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
'bg-destructive text-white border-transparent px-2 py-0.5 [a&]:hover:bg-destructive/90 dark:bg-destructive/70',
outline:
'text-foreground border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] px-2 py-0.5',
// New design-system tag variants — spec §6.4.
customer:
'bg-[color:var(--green-soft)] text-[color:var(--green-soft-text)] border-transparent px-[7px] py-px',
vip:
'bg-[color:var(--violet-soft)] text-[color:var(--violet-soft-text)] border-transparent px-[7px] py-px',
meeting:
'bg-[color:var(--amber-soft)] text-[color:var(--amber-soft-text)] border-transparent px-[7px] py-px',
neutral:
'bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)] border-[color:var(--border-faint)] px-[7px] py-px',
count:
'bg-[color:var(--bg-elevated)] text-[color:var(--text-subtle)] border-[color:var(--border-faint)] px-[5px] py-px text-[10px]'
}
},
defaultVariants: {
variant: 'default'
}
});
// Variants that render the 5px dot prefix (every spec variant except `count`).
const DOT_VARIANTS = new Set(['customer', 'vip', 'meeting', 'neutral']);
</script>
<script>
@ -27,7 +50,7 @@
* ref?: HTMLElement | null,
* href?: string,
* class?: string,
* variant?: 'default' | 'secondary' | 'destructive' | 'outline',
* variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'customer' | 'vip' | 'meeting' | 'neutral' | 'count',
* children?: import('svelte').Snippet,
* [key: string]: any
* }}
@ -40,15 +63,24 @@
children,
...restProps
} = $props();
const showDot = $derived(DOT_VARIANTS.has(variant));
</script>
<svelte:element
this={href ? 'a' : 'span'}
bind:this={ref}
data-slot="badge"
data-variant={variant}
{href}
class={cn(badgeVariants({ variant }), className)}
{...restProps}
>
{#if showDot}
<span
aria-hidden="true"
class="inline-block size-[5px] rounded-full bg-current shrink-0"
></span>
{/if}
{@render children?.()}
</svelte:element>

View file

@ -3,25 +3,53 @@
import { tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
base: [
'inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap',
"rounded-[var(--r-md)] text-[13px] font-medium leading-none tracking-[-0.01em]",
'outline-none transition-[background-color,color,border-color,box-shadow] duration-150',
'disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50',
"[&_svg:not([class*='size-'])]:size-[14px] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:stroke-[1.7]",
'focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]',
'aria-invalid:border-[color:var(--red)] aria-invalid:shadow-[0_0_0_3px_var(--red-soft)]'
].join(' '),
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
outline:
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
default: [
'bg-[color:var(--cta-bg)] text-[color:var(--cta-text)] border border-transparent',
'hover:bg-[color:var(--cta-bg-hover,var(--cta-bg))]'
].join(' '),
ghost: [
'bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)]',
'border border-[color:var(--border-faint)]',
'hover:border-[color:var(--border)] hover:text-[color:var(--text)]'
].join(' '),
outline: [
'bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)]',
'border border-[color:var(--border-faint)]',
'hover:border-[color:var(--border)] hover:text-[color:var(--text)]'
].join(' '),
secondary: [
'bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)]',
'border border-[color:var(--border-faint)]',
'hover:border-[color:var(--border)] hover:text-[color:var(--text)]'
].join(' '),
destructive: [
'bg-[color:var(--bg-elevated)] text-[color:var(--red)]',
'border border-[color:var(--border-faint)]',
'hover:border-[color:var(--red)]'
].join(' '),
link: [
'bg-transparent text-[color:var(--text)] border border-transparent px-0',
'underline-offset-4 hover:underline'
].join(' ')
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10'
default: 'h-8 px-3 has-[>svg]:px-2.5',
sm: 'h-7 gap-1.5 px-2.5 has-[>svg]:px-2 text-[12.5px]',
lg: 'h-9 px-4 has-[>svg]:px-3.5',
icon: 'size-8',
'icon-sm': 'size-7',
'icon-lg': 'size-9'
}
},
defaultVariants: {

View file

@ -1,17 +1,17 @@
<script>
import { cn } from '$lib/utils.js';
/**
* @type {{
* ref?: HTMLElement | null,
* class?: string,
* children?: import('svelte').Snippet,
* [key: string]: any
* }}
*/
/** @type {{ ref?: HTMLElement | null, class?: string, children?: import('svelte').Snippet, [key: string]: any }} */
let { ref = $bindable(null), class: className, children, ...restProps } = $props();
</script>
<div bind:this={ref} data-slot="card-content" class={cn('px-6', className)} {...restProps}>
<div
bind:this={ref}
data-slot="card-content"
class={cn(
'px-4 py-3.5 text-[13px] leading-[1.55] text-[color:var(--text-muted)]',
className
)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -7,7 +7,9 @@
bind:this={ref}
data-slot="card-header"
class={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
'@container/card-header flex items-center justify-between gap-2',
'px-4 py-3 border-b border-[color:var(--border-faint)]',
'has-data-[slot=card-action]:grid-cols-[1fr_auto]',
className
)}
{...restProps}

View file

@ -1,13 +1,15 @@
<script>
import { cn } from '$lib/utils.js';
let { ref = $bindable(null), class: className = undefined, children, ...restProps } = $props();
</script>
<div
bind:this={ref}
data-slot="card-title"
class={cn('leading-none font-semibold', className)}
class={cn(
'text-[10.5px] font-medium uppercase tracking-[0.06em] leading-none text-[color:var(--text-muted)]',
className
)}
{...restProps}
>
{@render children?.()}

View file

@ -16,7 +16,9 @@
bind:this={ref}
data-slot="card"
class={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
'flex flex-col overflow-hidden',
'rounded-[var(--r-lg)] border border-[color:var(--border-faint)]',
'bg-[color:var(--bg-card)] text-[color:var(--text)]',
className
)}
{...restProps}

View file

@ -197,7 +197,8 @@
const MENTION_TYPING_RE = /(?:^|[^A-Za-z0-9])@([A-Za-z0-9._-]*)$/;
/** @type {HTMLTextAreaElement | undefined} */
let textareaEl = $state(undefined);
/** @type {HTMLTextAreaElement | null} */
let textareaEl = $state(null);
let mentionOpen = $state(false);
let mentionQuery = $state('');
let mentionStart = $state(-1);

View file

@ -1,5 +1,5 @@
<script>
import { X, Trash2, ChevronDown, ChevronUp } from '@lucide/svelte';
import { X, Trash2, Maximize2 } from '@lucide/svelte';
import * as Sheet from '$lib/components/ui/sheet/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
@ -22,8 +22,10 @@
* onClose?: () => void,
* loading?: boolean,
* mode?: 'view' | 'create',
* enableProgressiveDisclosure?: boolean,
* fullPageHref?: string,
* class?: string,
* metaSection?: import('svelte').Snippet,
* banner?: import('svelte').Snippet,
* activitySection?: import('svelte').Snippet,
* footerActions?: import('svelte').Snippet,
* }}
@ -42,8 +44,10 @@
onClose,
loading = false,
mode = 'view',
enableProgressiveDisclosure = true,
fullPageHref = '',
class: className,
metaSection,
banner,
activitySection,
footerActions
} = $props();
@ -97,77 +101,25 @@
// Get title value
const titleValue = $derived(data?.[titleKey] || '');
// Progressive disclosure state
let showAllFields = $state(false);
// Stable id for ARIA labelling — Sheet.Content references this so screen readers
// announce the lead title when the dialog opens.
const titleId = $derived(`crm-drawer-title-${data?.id ?? 'new'}`);
/**
* Check if a field has a value
* @param {any} col
*/
function fieldHasValue(col) {
if (!data) return false;
const value = getFieldValue(col);
if (value === null || value === undefined || value === '') return false;
if (Array.isArray(value) && value.length === 0) return false;
return true;
}
/**
* Check if field should be visible based on progressive disclosure rules
* @param {any} col
*/
function shouldShowField(col) {
if (!enableProgressiveDisclosure) return true;
if (col.essential === true) return true;
if (mode === 'create') return showAllFields;
// In view/edit mode, show fields that have values OR if "show all" is toggled
return fieldHasValue(col) || showAllFields;
}
// Filter out columns hidden in create mode
const availableColumns = $derived(
mode === 'create' ? columns.filter((col) => !col.hideOnCreate) : columns
// All non-title fields to render. In create mode, hide fields marked hideOnCreate
// (typically read-only system fields like created/closed dates).
const visibleColumns = $derived(
columns.filter((col) => {
if (col.key === titleKey) return false;
if (mode === 'create' && col.hideOnCreate) return false;
return true;
})
);
// Split columns into essential and additional
const essentialColumns = $derived(
availableColumns.filter((col) => col.key !== titleKey && col.essential === true)
);
const additionalColumns = $derived(
availableColumns.filter((col) => col.key !== titleKey && col.essential !== true)
);
// Get visible additional columns based on mode
const visibleAdditionalColumns = $derived(
additionalColumns.filter((col) => shouldShowField(col))
);
// Count of hidden additional fields
const hiddenFieldsCount = $derived(
mode === 'create'
? showAllFields
? 0
: additionalColumns.length
: additionalColumns.filter((col) => !fieldHasValue(col) && !showAllFields).length
);
// Toggle show all fields
function toggleShowAllFields() {
showAllFields = !showAllFields;
}
// Reset show all fields when drawer closes
$effect(() => {
if (!open) {
showAllFields = false;
}
});
</script>
<Sheet.Root bind:open onOpenChange={(value) => onOpenChange?.(value)}>
<Sheet.Content
side="right"
aria-labelledby={titleId}
class={cn(
'border-border/50 bg-background/95 w-[480px] overflow-hidden border-l p-0 backdrop-blur-xl sm:max-w-[480px]',
className
@ -224,127 +176,86 @@
<span class="text-muted-foreground text-xs">New</span>
{/if}
</div>
<button
onclick={closeDrawer}
class="text-muted-foreground hover:bg-muted/60 hover:text-foreground flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150"
>
<X class="h-4 w-4" />
</button>
<div class="flex items-center gap-1">
{#if fullPageHref && mode !== 'create'}
<a
href={fullPageHref}
aria-label="Open full page"
title="Open full page"
class="text-muted-foreground hover:bg-muted/60 hover:text-foreground flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150"
>
<Maximize2 class="h-3.5 w-3.5" aria-hidden="true" />
</a>
{/if}
<button
type="button"
onclick={closeDrawer}
aria-label="Close"
class="text-muted-foreground hover:bg-muted/60 hover:text-foreground flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150"
>
<X class="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
<!-- Scrollable content -->
<div class="flex-1 overflow-x-hidden overflow-y-auto">
{#if banner}
{@render banner()}
{/if}
<!-- Title section with elegant styling -->
<div class="px-6 pt-8 pb-6">
{#if titleEditable}
<input
id={titleId}
type="text"
value={titleValue}
oninput={handleTitleChange}
placeholder={titlePlaceholder}
class="text-foreground placeholder:text-muted-foreground/30 w-full border-0 bg-transparent text-2xl font-bold tracking-tight outline-none"
aria-label="{headerLabel} title"
class="text-foreground placeholder:text-muted-foreground/30 hover:bg-muted/40 focus:bg-muted/40 focus:ring-primary/20 -mx-2 w-[calc(100%+1rem)] rounded-md border-0 bg-transparent px-2 py-1 text-2xl font-bold tracking-tight transition-colors outline-none focus:ring-1"
style="letter-spacing: -0.025em;"
/>
{:else}
<h2
id={titleId}
class="text-foreground text-2xl font-bold tracking-tight"
style="letter-spacing: -0.025em;"
>
{titleValue || titlePlaceholder}
</h2>
{/if}
{#if metaSection && mode !== 'create'}
<div class="mt-3">
{@render metaSection()}
</div>
{/if}
</div>
<!-- Properties section with refined spacing -->
<div class="px-6 pb-8">
{#if enableProgressiveDisclosure && essentialColumns.length > 0}
<!-- Essential fields section -->
<div class="space-y-1">
{#each essentialColumns as col (col.key)}
<CrmPropertyRow
label={col.label}
value={getFieldValue(col)}
type={col.type || 'text'}
icon={col.icon}
options={col.options}
placeholder={col.placeholder}
emptyText={col.emptyText}
editable={col.editable !== false}
prefix={col.prefix}
onchange={(value) => handleFieldChange(col.key, value)}
/>
{/each}
</div>
<!-- Progressive disclosure toggle with refined styling -->
{#if hiddenFieldsCount > 0 || showAllFields}
<button
type="button"
onclick={toggleShowAllFields}
class="group text-muted-foreground hover:bg-muted/40 hover:text-foreground -mx-3 my-4 flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-[13px] font-medium transition-all duration-150"
>
<div
class="bg-muted/50 group-hover:bg-muted flex h-6 w-6 items-center justify-center rounded-md transition-colors"
>
{#if showAllFields}
<ChevronUp class="h-3.5 w-3.5" />
{:else}
<ChevronDown class="h-3.5 w-3.5" />
{/if}
</div>
{#if showAllFields}
<span>Hide additional fields</span>
{:else}
<span
>Show {hiddenFieldsCount} more {hiddenFieldsCount === 1
? 'field'
: 'fields'}</span
>
{/if}
</button>
{/if}
<!-- Additional fields -->
{#if visibleAdditionalColumns.length > 0}
<div class="space-y-1">
{#each visibleAdditionalColumns as col (col.key)}
<CrmPropertyRow
label={col.label}
value={getFieldValue(col)}
type={col.type || 'text'}
icon={col.icon}
options={col.options}
placeholder={col.placeholder}
emptyText={col.emptyText}
editable={col.editable !== false}
prefix={col.prefix}
onchange={(value) => handleFieldChange(col.key, value)}
/>
{/each}
</div>
{/if}
{:else}
<!-- Original behavior: show all fields when no essential fields defined -->
<div class="space-y-1">
{#each columns as col (col.key)}
{#if col.key !== titleKey}
<CrmPropertyRow
label={col.label}
value={getFieldValue(col)}
type={col.type || 'text'}
icon={col.icon}
options={col.options}
placeholder={col.placeholder}
emptyText={col.emptyText}
editable={col.editable !== false}
prefix={col.prefix}
onchange={(value) => handleFieldChange(col.key, value)}
/>
{/if}
{/each}
</div>
{/if}
<div class="space-y-1">
{#each visibleColumns as col (col.key)}
<CrmPropertyRow
label={col.label}
value={getFieldValue(col)}
type={col.type || 'text'}
icon={col.icon}
options={col.options}
placeholder={col.placeholder}
emptyText={col.emptyText}
editable={col.editable !== false}
required={col.required === true}
prefix={col.prefix}
loading={col.loading === true}
loadingError={col.loadingError || ''}
onRetry={col.onRetry}
onchange={(value) => handleFieldChange(col.key, value)}
/>
{/each}
</div>
</div>
<!-- Activity section with refined styling -->

View file

@ -12,13 +12,19 @@
* @type {{
* label: string,
* value?: any,
* type?: 'text' | 'email' | 'number' | 'date' | 'select' | 'checkbox' | 'boolean' | 'multiselect' | 'textarea' | 'color',
* type?: 'text' | 'email' | 'number' | 'date' | 'select' | 'checkbox' | 'boolean' | 'multiselect' | 'textarea' | 'color' | 'readonly',
* icon?: import('svelte').Component,
* options?: any[],
* placeholder?: string,
* emptyText?: string,
* editable?: boolean,
* required?: boolean,
* error?: string,
* id?: string,
* prefix?: string,
* loading?: boolean,
* loadingError?: string,
* onRetry?: () => void,
* onchange?: (value: any) => void,
* class?: string,
* }}
@ -32,11 +38,35 @@
placeholder = '',
emptyText = '',
editable = true,
required = false,
error = '',
id = '',
prefix = '',
loading = false,
loadingError = '',
onRetry,
onchange,
class: className
} = $props();
// Stable id for ARIA wiring; falls back to a slug of the label
const inputId = $derived(
id || `prop-${label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`
);
const labelId = $derived(`${inputId}-label`);
const errorId = $derived(`${inputId}-error`);
// Plain-text rendering for read-only text-like fields
const isPlainReadonly = $derived(
!editable && (type === 'text' || type === 'email' || type === 'number' || type === 'textarea')
);
// Read-only date renders as plain formatted text instead of a disabled-looking button
const isReadonlyDate = $derived(!editable && type === 'date');
// Read-only select renders the colored badge as a static span (no dropdown chrome)
const isReadonlySelect = $derived(!editable && type === 'select');
/**
* Get option style for select
* @param {string} val
@ -87,13 +117,16 @@
}
/**
* Format date for display
* Format date for display. Accepts a YYYY-MM-DD string or an ISO datetime.
* @param {string} dateStr
*/
function formatDateDisplay(dateStr) {
if (!dateStr) return placeholder || 'Pick a date';
try {
const date = new Date(dateStr + 'T00:00:00');
// If we got just YYYY-MM-DD, pin it to local midnight; otherwise let Date parse the ISO string.
const isoLike = /T|\s/.test(dateStr) ? dateStr : `${dateStr}T00:00:00`;
const date = new Date(isoLike);
if (Number.isNaN(date.getTime())) return dateStr;
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
@ -182,159 +215,261 @@
}
</script>
<div
class={cn(
'group hover:bg-muted/40 -mx-3 flex min-h-[44px] items-center rounded-lg px-3 transition-all duration-150',
className
)}
>
<!-- Label with icon -->
<div class="flex flex-col">
<div
class="text-muted-foreground flex w-32 shrink-0 items-center gap-2.5 text-[13px] font-medium"
class={cn(
'group hover:bg-muted/40 -mx-3 flex min-h-[44px] items-center rounded-lg px-3 transition-all duration-150',
className
)}
>
{#if Icon}
<div class="bg-muted/50 flex h-6 w-6 items-center justify-center rounded-md">
<Icon class="text-muted-foreground/70 h-3.5 w-3.5" />
</div>
{/if}
{label}
</div>
<!-- Value -->
<div class="min-w-0 flex-1">
{#if type === 'text' || type === 'email'}
<input
{type}
{value}
oninput={handleInput}
{placeholder}
disabled={!editable}
class="text-foreground placeholder:text-muted-foreground/40 focus:bg-muted/30 focus:ring-primary/20 w-full rounded-lg border-0 bg-transparent px-2.5 py-1.5 text-sm font-medium transition-all outline-none focus:ring-1"
/>
{:else if type === 'number'}
<div class="flex items-center">
{#if prefix}
<span class="text-muted-foreground mr-1.5 text-sm font-medium">{prefix}</span>
{/if}
<input
type="number"
value={value || 0}
oninput={handleNumberInput}
{placeholder}
disabled={!editable}
class="text-foreground placeholder:text-muted-foreground/40 focus:bg-muted/30 focus:ring-primary/20 w-full rounded-lg border-0 bg-transparent px-2 py-1.5 text-sm font-medium tabular-nums transition-all outline-none focus:ring-1"
/>
</div>
{:else if type === 'date'}
<Popover.Root bind:open={datePopoverOpen}>
<Popover.Trigger disabled={!editable} class="w-full">
{#snippet child({ props })}
<button
{...props}
type="button"
class={cn(
'hover:bg-muted/30 inline-flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all',
!value ? 'text-muted-foreground/50' : 'text-foreground'
)}
>
<CalendarIcon class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" />
{formatDateDisplay(value)}
</button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-auto p-0" align="start">
<Calendar value={parseDateValue(value)} onValueChange={handleCalendarChange} />
</Popover.Content>
</Popover.Root>
{:else if type === 'textarea'}
<textarea
oninput={handleInput}
{placeholder}
disabled={!editable}
rows={3}
class="text-foreground placeholder:text-muted-foreground/40 focus:bg-muted/30 focus:ring-primary/20 w-full resize-none rounded-lg border-0 bg-transparent px-2.5 py-1.5 text-sm transition-all outline-none focus:ring-1"
>{value || ''}</textarea
>
{:else if type === 'select'}
<DropdownMenu.Root>
<DropdownMenu.Trigger disabled={!editable}>
{#snippet child({ props })}
<button
{...props}
type="button"
class="inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-semibold shadow-sm ring-1 ring-black/5 transition-all duration-150 ring-inset hover:shadow dark:ring-white/10 {getOptionStyle(
value
)}"
>
<span
class="h-2 w-2 rounded-full ring-1 ring-black/10 ring-inset dark:ring-white/20 {getOptionBgColor(
value
)}"
></span>
{getOptionLabel(value)}
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="min-w-48 p-1.5">
{#each options as option (option.value)}
<DropdownMenu.Item
onclick={() => handleSelectChange(option.value)}
class="flex items-center gap-2.5 rounded-lg px-2.5 py-2"
>
<span
class="h-2.5 w-2.5 rounded-full ring-1 ring-black/10 ring-inset dark:ring-white/20 {option.color?.split(
' '
)[0] || 'bg-gray-400'}"
></span>
<span class="font-medium">{option.label}</span>
{#if value === option.value}
<Check class="text-primary ml-auto h-4 w-4" />
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else if type === 'checkbox' || type === 'boolean'}
<button
type="button"
onclick={handleCheckboxToggle}
disabled={!editable}
class="flex h-5 w-5 items-center justify-center rounded-md border-2 transition-all duration-150 {value
? 'border-primary bg-primary shadow-primary/25 shadow-sm'
: 'border-muted-foreground/30 hover:border-muted-foreground/50'}"
>
{#if value}
<Check class="text-primary-foreground h-3.5 w-3.5" />
{/if}
</button>
{:else if type === 'color'}
<div class="flex items-center gap-3 px-2 py-1">
<input
type="color"
value={value || '#3B82F6'}
oninput={handleColorInput}
disabled={!editable}
class="border-border/50 h-8 w-10 cursor-pointer rounded-lg border bg-transparent p-0.5 shadow-sm"
/>
<span class="text-muted-foreground font-mono text-xs">{value || '#3B82F6'}</span>
</div>
{:else if type === 'multiselect'}
<EditableMultiSelect
value={Array.isArray(value) ? value : []}
{options}
{placeholder}
emptyText={emptyText || 'None selected'}
disabled={!editable}
onchange={handleMultiSelectChange}
/>
{:else if type === 'readonly'}
<span class="text-foreground px-2.5 py-1.5 text-sm font-medium">
{#if value}
{value}
{:else}
<span class="text-muted-foreground/40 italic">{emptyText || '—'}</span>
<!-- Label with icon — uses <label for> so clicking focuses the input -->
<label
for={inputId}
id={labelId}
class="text-muted-foreground flex w-32 shrink-0 items-center gap-2.5 text-[13px] font-medium"
>
{#if Icon}
<div class="bg-muted/50 flex h-6 w-6 items-center justify-center rounded-md">
<Icon class="text-muted-foreground/70 h-3.5 w-3.5" aria-hidden="true" />
</div>
{/if}
<span>
{label}
{#if required}
<span class="text-destructive ml-0.5" aria-hidden="true">*</span>
{/if}
</span>
{/if}
</label>
<!-- Value -->
<div class="min-w-0 flex-1">
{#if isPlainReadonly}
<span
class={cn(
'block px-2.5 py-1.5 text-sm',
type === 'textarea' && 'whitespace-pre-wrap'
)}
aria-labelledby={labelId}
>
{#if value !== null && value !== undefined && value !== ''}
<span class="text-foreground font-medium">
{type === 'number' ? formatNumber(value) : value}
</span>
{:else}
<span class="text-muted-foreground/40 italic">{emptyText || '—'}</span>
{/if}
</span>
{:else if type === 'text' || type === 'email'}
<input
id={inputId}
{type}
{value}
oninput={handleInput}
{placeholder}
disabled={!editable}
aria-labelledby={labelId}
aria-required={required ? 'true' : undefined}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
class="text-foreground placeholder:text-muted-foreground/40 focus:bg-muted/30 focus:ring-primary/20 w-full rounded-lg border-0 bg-transparent px-2.5 py-1.5 text-sm font-medium transition-all outline-none focus:ring-1 aria-[invalid=true]:ring-1 aria-[invalid=true]:ring-destructive/40"
/>
{:else if type === 'number'}
<div class="flex items-center">
{#if prefix}
<span class="text-muted-foreground mr-1.5 text-sm font-medium" aria-hidden="true"
>{prefix}</span
>
{/if}
<input
id={inputId}
type="number"
value={value || 0}
oninput={handleNumberInput}
{placeholder}
disabled={!editable}
aria-labelledby={labelId}
aria-required={required ? 'true' : undefined}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
class="text-foreground placeholder:text-muted-foreground/40 focus:bg-muted/30 focus:ring-primary/20 w-full rounded-lg border-0 bg-transparent px-2 py-1.5 text-sm font-medium tabular-nums transition-all outline-none focus:ring-1 aria-[invalid=true]:ring-1 aria-[invalid=true]:ring-destructive/40"
/>
</div>
{:else if isReadonlyDate}
<span class="text-foreground block px-2.5 py-1.5 text-sm font-medium" aria-labelledby={labelId}>
{#if value}
{formatDateDisplay(value)}
{:else}
<span class="text-muted-foreground/40 italic">{emptyText || '—'}</span>
{/if}
</span>
{:else if type === 'date'}
<Popover.Root bind:open={datePopoverOpen}>
<Popover.Trigger disabled={!editable} class="w-full">
{#snippet child({ props })}
<button
{...props}
type="button"
id={inputId}
aria-labelledby={labelId}
aria-describedby={error ? errorId : undefined}
class={cn(
'hover:bg-muted/30 inline-flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm font-medium transition-all',
!value ? 'text-muted-foreground/50' : 'text-foreground',
!editable && 'cursor-default hover:bg-transparent',
error && 'ring-1 ring-destructive/40'
)}
>
<CalendarIcon class="text-muted-foreground/60 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{formatDateDisplay(value)}
</button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="w-auto p-0" align="start">
<Calendar value={parseDateValue(value)} onValueChange={handleCalendarChange} />
</Popover.Content>
</Popover.Root>
{:else if type === 'textarea'}
<textarea
id={inputId}
oninput={handleInput}
{placeholder}
disabled={!editable}
rows={3}
aria-labelledby={labelId}
aria-required={required ? 'true' : undefined}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
class="text-foreground placeholder:text-muted-foreground/40 focus:bg-muted/30 focus:ring-primary/20 w-full resize-none rounded-lg border-0 bg-transparent px-2.5 py-1.5 text-sm transition-all outline-none focus:ring-1 aria-[invalid=true]:ring-1 aria-[invalid=true]:ring-destructive/40"
>{value || ''}</textarea
>
{:else if isReadonlySelect}
<span
aria-labelledby={labelId}
class="inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-semibold ring-1 ring-inset ring-black/5 dark:ring-white/10 {getOptionStyle(
value
)}"
>
<span
aria-hidden="true"
class="h-2 w-2 rounded-full ring-1 ring-inset ring-black/10 dark:ring-white/20 {getOptionBgColor(
value
)}"
></span>
{getOptionLabel(value)}
</span>
{:else if type === 'select'}
<DropdownMenu.Root>
<DropdownMenu.Trigger disabled={!editable}>
{#snippet child({ props })}
<button
{...props}
type="button"
id={inputId}
aria-labelledby={labelId}
aria-describedby={error ? errorId : undefined}
class={cn(
'inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-semibold shadow-sm ring-1 transition-all duration-150 ring-inset hover:shadow',
error ? 'ring-destructive/40' : 'ring-black/5 dark:ring-white/10',
getOptionStyle(value)
)}
>
<span
aria-hidden="true"
class="h-2 w-2 rounded-full ring-1 ring-black/10 ring-inset dark:ring-white/20 {getOptionBgColor(
value
)}"
></span>
{getOptionLabel(value)}
</button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="min-w-48 p-1.5">
{#each options as option (option.value)}
<DropdownMenu.Item
onclick={() => handleSelectChange(option.value)}
class="flex items-center gap-2.5 rounded-lg px-2.5 py-2"
>
<span
aria-hidden="true"
class="h-2.5 w-2.5 rounded-full ring-1 ring-black/10 ring-inset dark:ring-white/20 {option.color?.split(
' '
)[0] || 'bg-gray-400'}"
></span>
<span class="font-medium">{option.label}</span>
{#if value === option.value}
<Check class="text-primary ml-auto h-4 w-4" aria-hidden="true" />
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else if type === 'checkbox' || type === 'boolean'}
<button
type="button"
id={inputId}
role="checkbox"
aria-checked={value ? 'true' : 'false'}
aria-labelledby={labelId}
aria-required={required ? 'true' : undefined}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
onclick={handleCheckboxToggle}
disabled={!editable}
class="flex h-5 w-5 items-center justify-center rounded-md border-2 transition-all duration-150 {value
? 'border-primary bg-primary shadow-primary/25 shadow-sm'
: 'border-muted-foreground/30 hover:border-muted-foreground/50'}"
>
{#if value}
<Check class="text-primary-foreground h-3.5 w-3.5" aria-hidden="true" />
{/if}
</button>
{:else if type === 'color'}
<div class="flex items-center gap-3 px-2 py-1">
<input
id={inputId}
type="color"
value={value || '#3B82F6'}
oninput={handleColorInput}
disabled={!editable}
aria-labelledby={labelId}
aria-required={required ? 'true' : undefined}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? errorId : undefined}
class="border-border/50 h-8 w-10 cursor-pointer rounded-lg border bg-transparent p-0.5 shadow-sm"
/>
<span class="text-muted-foreground font-mono text-xs">{value || '#3B82F6'}</span>
</div>
{:else if type === 'multiselect'}
<EditableMultiSelect
value={Array.isArray(value) ? value : []}
{options}
{placeholder}
emptyText={emptyText || 'None selected'}
disabled={!editable}
{loading}
error={loadingError}
{onRetry}
onchange={handleMultiSelectChange}
/>
{:else if type === 'readonly'}
<span class="text-foreground px-2.5 py-1.5 text-sm font-medium" aria-labelledby={labelId}>
{#if value}
{value}
{:else}
<span class="text-muted-foreground/40 italic">{emptyText || '—'}</span>
{/if}
</span>
{/if}
</div>
</div>
{#if error}
<p
id={errorId}
class="text-destructive ml-32 px-2.5 pt-0.5 pb-1 text-[11.5px] font-medium"
role="alert"
>
{error}
</p>
{/if}
</div>

View file

@ -1,8 +1,7 @@
<script>
import { tick } from 'svelte';
import { Check, ChevronDown, Expand, Building2, User } from '@lucide/svelte';
import { Check, ChevronDown, Building2, User } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { expandButtonClasses } from '$lib/utils/drag-drop.js';
import { getOptionStyle, getOptionLabel, getOptionBgColor } from '$lib/utils/table-helpers.js';
import { formatCurrency } from '$lib/utils/formatting.js';
@ -34,7 +33,8 @@
* cellSuffix?: import('svelte').Snippet<[any, ColumnDef]>,
* selectable?: boolean,
* selectedIds?: string[],
* rowIdKey?: string
* rowIdKey?: string,
* activeRowId?: string | null
* }}
*/
let {
@ -48,7 +48,8 @@
cellSuffix,
selectable = false,
selectedIds = $bindable(/** @type {string[]} */ ([])),
rowIdKey = 'id'
rowIdKey = 'id',
activeRowId = $bindable(/** @type {string | null} */ (null))
} = $props();
// Initialize visibleColumns from columns if not provided
@ -213,10 +214,10 @@
{/if}
{:else}
<table class="w-full border-collapse">
<thead>
<tr class="border-b border-gray-100/60 dark:border-gray-800/60">
<thead class="sticky top-0 z-[1] bg-[color:var(--bg)]">
<tr class="border-b border-[color:var(--border-faint)]">
{#if selectable}
<th class="w-10 px-3 py-2">
<th class="w-10 px-3 py-2.5">
<input
type="checkbox"
aria-label="Select all rows"
@ -229,15 +230,9 @@
/>
</th>
{/if}
{#if onRowClick}
<th class="w-8 px-1"></th>
{/if}
{#each visibleColumnDefs as column, colIndex (column.key)}
<th
class="py-3 pr-4 text-left text-[13px] font-normal text-gray-400 dark:text-gray-500 {colIndex ===
0
? 'pl-0'
: 'pl-4'} {column.width || ''}"
class="px-[14px] py-[9px] text-left text-[10.5px] font-medium uppercase tracking-[0.06em] text-[color:var(--text-subtle)] {colIndex === 0 ? 'pl-7 md:pl-8' : ''} {column.width || ''}"
>
{column.label}
</th>
@ -247,12 +242,11 @@
<tbody>
{#each data as row (row.id)}
<tr
class="group transition-all duration-100 ease-out hover:bg-gray-50/30 dark:hover:bg-gray-900/30 {onRowClick
? 'cursor-pointer'
: ''}"
class="border-b border-[color:var(--border-faint)] transition-colors {onRowClick ? 'cursor-pointer' : ''} {activeRowId === row[rowIdKey]
? 'bg-[color:var(--bg-active)]'
: onRowClick ? 'hover:bg-[color:var(--bg-hover)]' : ''}"
onclick={(e) => {
if (!onRowClick) return;
// Don't trigger row click if clicking on interactive elements
const target = /** @type {HTMLElement} */ (e.target);
if (
target.closest('button') ||
@ -262,6 +256,7 @@
) {
return;
}
activeRowId = row[rowIdKey];
onRowClick(row);
}}
>
@ -281,18 +276,12 @@
/>
</td>
{/if}
{#if onRowClick}
<td class="w-8 px-1 py-3">
<button type="button" onclick={() => onRowClick(row)} class={expandButtonClasses}>
<Expand class="h-3.5 w-3.5 text-gray-500" />
</button>
</td>
{/if}
{#each visibleColumnDefs as column, colIndex (column.key)}
{@const value = getCellValue(row, column)}
{@const formattedValue = formatValue(value, column, row)}
<td class="py-3 pr-4 {colIndex === 0 ? 'pl-0' : 'pl-4'} {column.width || ''}">
<td
class="h-[37px] px-[14px] text-[12.5px] {colIndex === 0 ? 'pl-7 md:pl-8 font-medium text-[color:var(--text)]' : 'text-[color:var(--text-muted)]'} {colIndex === 0 && activeRowId === row[rowIdKey] ? 'shadow-[inset_2.5px_0_0_var(--text)]' : ''} {column.width || ''}"
>
{#if cellContent}
{@render cellContent(row, column)}
{:else if column.type === 'text' || column.type === 'email' || column.type === 'number' || !column.type}
@ -313,20 +302,20 @@
<button
type="button"
onclick={() => startEditing(row.id, column.key)}
class="-mx-2 -my-1.5 w-full cursor-text rounded px-2 py-1.5 text-left text-sm text-gray-900 transition-colors duration-75 hover:bg-gray-100/50 dark:text-gray-100 dark:hover:bg-gray-800/50"
class="-mx-2 -my-1.5 w-full cursor-text rounded px-2 py-1.5 text-left text-sm transition-colors duration-75 hover:bg-[color:var(--bg-elevated)]"
>
{#if formattedValue}
{formattedValue}
{:else}
<span class="text-gray-400">{column.emptyText || 'Empty'}</span>
<span class="text-[color:var(--text-subtle)]">{column.emptyText || 'Empty'}</span>
{/if}
</button>
{:else}
<span class="text-sm {column.cellClass ? column.cellClass(row) : 'text-gray-900 dark:text-gray-100'}">
<span class="text-sm {column.cellClass ? column.cellClass(row) : ''}">
{#if formattedValue}
{formattedValue}
{:else}
<span class="text-gray-400">{column.emptyText || 'Empty'}</span>
<span class="text-[color:var(--text-subtle)]">{column.emptyText || 'Empty'}</span>
{/if}
</span>
{/if}
@ -344,20 +333,20 @@
<button
type="button"
onclick={() => startEditing(row.id, column.key)}
class="-mx-2 -my-1.5 w-full cursor-text rounded px-2 py-1.5 text-left text-sm text-gray-900 transition-colors duration-75 hover:bg-gray-100/50 dark:text-gray-100 dark:hover:bg-gray-800/50"
class="-mx-2 -my-1.5 w-full cursor-text rounded px-2 py-1.5 text-left text-sm transition-colors duration-75 hover:bg-[color:var(--bg-elevated)]"
>
{#if formattedValue}
{formattedValue}
{:else}
<span class="text-gray-400">{column.emptyText || 'Empty'}</span>
<span class="text-[color:var(--text-subtle)]">{column.emptyText || 'Empty'}</span>
{/if}
</button>
{:else}
<span class="text-sm {column.cellClass ? column.cellClass(row) : 'text-gray-900 dark:text-gray-100'}">
<span class="text-sm {column.cellClass ? column.cellClass(row) : ''}">
{#if formattedValue}
{formattedValue}
{:else}
<span class="text-gray-400">{column.emptyText || 'Empty'}</span>
<span class="text-[color:var(--text-subtle)]">{column.emptyText || 'Empty'}</span>
{/if}
</span>
{/if}
@ -435,22 +424,22 @@
{:else if column.type === 'relation'}
<button
type="button"
onclick={() => onRowClick?.(row)}
class="-mx-2 -my-1.5 w-full cursor-pointer rounded px-2 py-1.5 text-left text-sm text-gray-900 transition-colors duration-75 hover:bg-gray-100/50 dark:text-gray-100 dark:hover:bg-gray-800/50"
onclick={() => { activeRowId = row[rowIdKey]; onRowClick?.(row); }}
class="-mx-2 -my-1.5 w-full cursor-pointer rounded px-2 py-1.5 text-left text-sm transition-colors duration-75 hover:bg-[color:var(--bg-elevated)]"
>
{#if value}
<div class="flex items-center gap-1.5">
{#if column.relationIcon === 'user'}
<User class="h-3.5 w-3.5 text-gray-400" />
<User class="h-3.5 w-3.5 text-[color:var(--text-subtle)]" />
{:else}
<Building2 class="h-3.5 w-3.5 text-gray-400" />
<Building2 class="h-3.5 w-3.5 text-[color:var(--text-subtle)]" />
{/if}
<span class="truncate"
>{typeof value === 'object' ? value.name || value.email : value}</span
>
</div>
{:else}
<span class="text-gray-400">-</span>
<span class="text-[color:var(--text-subtle)]">-</span>
{/if}
</button>
{/if}

View file

@ -1,7 +1,8 @@
<script>
import { ChevronDown, X } from '@lucide/svelte';
import { ChevronDown, X, Search, AlertCircle, RotateCw } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
import { cn } from '$lib/utils.js';
/**
@ -20,9 +21,13 @@
* placeholder?: string,
* emptyText?: string,
* disabled?: boolean,
* loading?: boolean,
* error?: string,
* onRetry?: () => void,
* onchange?: (value: string[]) => void,
* class?: string,
* maxDisplay?: number,
* searchThreshold?: number,
* }}
*/
let {
@ -31,12 +36,22 @@
placeholder = 'Select...',
emptyText = 'None selected',
disabled = false,
loading = false,
error = '',
onRetry,
onchange,
class: className,
maxDisplay = 3
maxDisplay = 3,
searchThreshold = 6
} = $props();
let isOpen = $state(false);
let query = $state('');
// Reset the search query whenever the dropdown closes
$effect(() => {
if (!isOpen) query = '';
});
/**
* Toggle an option
@ -78,6 +93,19 @@
.filter(Boolean)
);
// Show the search input once there are enough options to make it useful
const showSearch = $derived(!loading && !error && normalizedOptions.length >= searchThreshold);
const filteredOptions = $derived.by(() => {
const q = query.trim().toLowerCase();
if (!q) return normalizedOptions;
return normalizedOptions.filter((opt) => {
const name = (opt.name || '').toLowerCase();
const email = (opt.email || '').toLowerCase();
return name.includes(q) || email.includes(q);
});
});
/**
* Get selected options
*/
@ -117,10 +145,11 @@
{#if !disabled}
<button
type="button"
aria-label="Remove {opt.name}"
class="hover:bg-muted rounded-sm p-0.5"
onclick={(e) => removeOption(e, opt.id)}
>
<X class="h-3 w-3" />
<X class="h-3 w-3" aria-hidden="true" />
</button>
{/if}
</Badge>
@ -131,6 +160,7 @@
{/if}
</div>
<ChevronDown
aria-hidden="true"
class={cn(
'text-muted-foreground h-4 w-4 shrink-0 transition-transform',
isOpen && 'rotate-180'
@ -138,27 +168,83 @@
/>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Content class="max-h-64 w-64 overflow-y-auto" align="start">
{#if normalizedOptions.length === 0}
<div class="text-muted-foreground px-2 py-4 text-center text-sm">No options available</div>
{:else}
<DropdownMenu.CheckboxGroup value={value}>
{#each normalizedOptions as opt (opt.id)}
<DropdownMenu.CheckboxItem
checked={value.includes(opt.id)}
onCheckedChange={() => toggleOption(opt.id)}
class=""
>
<div class="flex flex-col">
<span>{opt.name}</span>
{#if opt.email}
<span class="text-muted-foreground text-xs">{opt.email}</span>
{/if}
</div>
</DropdownMenu.CheckboxItem>
{/each}
</DropdownMenu.CheckboxGroup>
<DropdownMenu.Content class="max-h-72 w-64 overflow-hidden p-0" align="start">
{#if showSearch}
<div class="border-border/40 bg-background sticky top-0 z-10 border-b p-1.5">
<div class="relative">
<Search
aria-hidden="true"
class="text-muted-foreground/60 pointer-events-none absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2"
/>
<input
type="search"
bind:value={query}
placeholder="Search…"
aria-label="Search options"
class="focus:ring-primary/30 placeholder:text-muted-foreground/50 w-full rounded-md border-0 bg-transparent py-1.5 pr-2 pl-7 text-xs outline-none focus:ring-1"
/>
</div>
</div>
{/if}
<div class="max-h-56 overflow-y-auto p-1">
{#if loading}
<div class="space-y-1.5 px-1 py-1.5" aria-busy="true" aria-live="polite">
{#each { length: 3 } as _, i (i)}
<div class="flex items-center gap-2 px-1.5 py-1">
<Skeleton class="h-3.5 w-3.5 rounded" />
<Skeleton class="h-3.5 flex-1 rounded" />
</div>
{/each}
</div>
{:else if error}
<div class="flex items-start gap-2 px-2 py-3 text-xs" role="alert">
<AlertCircle
class="text-destructive mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div class="flex-1 space-y-1.5">
<p class="text-destructive font-medium">{error}</p>
{#if onRetry}
<button
type="button"
onclick={onRetry}
class="text-primary hover:bg-primary/10 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11.5px] font-medium"
>
<RotateCw class="h-3 w-3" aria-hidden="true" />
Retry
</button>
{/if}
</div>
</div>
{:else if normalizedOptions.length === 0}
<div class="text-muted-foreground px-2 py-4 text-center text-sm">
No options available
</div>
{:else if filteredOptions.length === 0}
<div class="text-muted-foreground px-2 py-4 text-center text-xs">
No matches for "{query}"
</div>
{:else}
<DropdownMenu.CheckboxGroup value={value}>
{#each filteredOptions as opt (opt.id)}
<DropdownMenu.CheckboxItem
checked={value.includes(opt.id)}
onCheckedChange={() => toggleOption(opt.id)}
closeOnSelect={false}
class=""
>
<div class="flex flex-col">
<span>{opt.name}</span>
{#if opt.email && opt.email !== opt.name}
<span class="text-muted-foreground text-xs">{opt.email}</span>
{/if}
</div>
</DropdownMenu.CheckboxItem>
{/each}
</DropdownMenu.CheckboxGroup>
{/if}
</div>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>

View file

@ -19,7 +19,7 @@
let {
startDate = $bindable(''),
endDate = $bindable(''),
label = '',
label: _label = '',
placeholder = 'Select dates',
class: className,
onchange
@ -167,13 +167,7 @@
const hasValue = $derived(!!startDate || !!endDate);
</script>
<div class={cn('flex flex-col gap-1.5', className)}>
{#if label}
<span class="text-muted-foreground/70 text-[11px] font-semibold tracking-wider uppercase">
{label}
</span>
{/if}
<Popover.Root
<Popover.Root
bind:open
onOpenChange={(o) => {
if (!o) selectingEnd = false;
@ -183,87 +177,31 @@
{#snippet child({ props })}
<button
type="button"
class={cn(
'date-filter-trigger group',
'relative flex h-9 w-full items-center justify-between gap-2',
'rounded-lg border px-3 py-2',
'bg-background/60 backdrop-blur-sm',
'text-sm transition-all duration-200',
'outline-none',
// Default state
'border-input/60',
'hover:border-input hover:bg-background/80',
// Focus state
'focus:border-primary/50 focus:bg-background',
'focus:ring-primary/20 focus:ring-2',
// Dark mode
'dark:border-white/[0.08] dark:bg-white/[0.03]',
'dark:hover:border-white/[0.12] dark:hover:bg-white/[0.05]',
'dark:focus:border-primary/40 dark:focus:bg-white/[0.06]',
// Active state
hasValue &&
'border-primary/40 bg-primary/[0.03] dark:border-primary/30 dark:bg-primary/[0.08]',
// Open state
open && 'ring-primary/20 dark:ring-primary/30 ring-2'
)}
{...props}
class={cn(
'inline-flex h-7 items-center gap-1.5 rounded-[var(--r-sm)] border px-2.5 text-[11.5px] font-medium leading-none transition-colors focus:outline-none focus:ring-1 focus:ring-[color:var(--ring)]',
hasValue
? 'border-[color:var(--violet)]/40 bg-[color:var(--violet-soft)] text-[color:var(--violet-soft-text)]'
: 'border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)] hover:bg-[color:var(--bg-hover)]',
className
)}
>
<div class="flex items-center gap-2">
<CalendarDays
class={cn(
'h-4 w-4 transition-colors duration-200',
hasValue ? 'text-primary' : 'text-muted-foreground'
)}
/>
<CalendarDays class="size-3.5 shrink-0" />
<span class="truncate">{displayText}</span>
{#if hasValue}
<span
class={cn(
'truncate transition-colors duration-150',
hasValue ? 'text-foreground font-medium' : 'text-muted-foreground'
)}
role="button"
tabindex="0"
onclick={(e) => { e.stopPropagation(); e.preventDefault(); handleClear(); }}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); handleClear(); } }}
class="-mr-1 ml-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-sm hover:bg-[color:var(--violet)]/15"
aria-label="Clear filter"
>
{displayText}
<X class="size-3" />
</span>
</div>
<div class="flex shrink-0 items-center gap-1">
{#if hasValue}
<!-- Active indicator -->
<div
class="bg-primary animate-in fade-in zoom-in h-1.5 w-1.5 rounded-full duration-200"
></div>
<!-- Clear button -->
<span
role="button"
tabindex="0"
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
handleClear();
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
e.preventDefault();
handleClear();
}
}}
class={cn(
'flex h-5 w-5 items-center justify-center rounded-md',
'text-muted-foreground/60 transition-all duration-150',
'hover:bg-muted hover:text-foreground',
'active:scale-90'
)}
>
<X class="h-3 w-3" />
</span>
{/if}
<ChevronDown
class={cn(
'text-muted-foreground/50 h-4 w-4 transition-transform duration-200',
open && 'rotate-180'
)}
/>
</div>
{:else}
<ChevronDown class="size-3.5 shrink-0 opacity-60" />
{/if}
</button>
{/snippet}
</Popover.Trigger>
@ -271,15 +209,7 @@
<Popover.Content
align="start"
sideOffset={4}
class={cn(
'date-filter-content',
'w-auto overflow-hidden rounded-xl p-0',
'border-border/60 bg-popover/95 border backdrop-blur-md',
'shadow-lg shadow-black/5',
'dark:bg-popover/90 dark:border-white/[0.08]',
'dark:shadow-[0_8px_32px_-4px_rgba(0,0,0,0.5)]',
'animate-in fade-in-0 zoom-in-95 duration-150'
)}
class="overflow-hidden rounded-[var(--r-md)] border border-[color:var(--border)] bg-[color:var(--bg-card)] p-0 shadow-lg shadow-black/5"
>
<div class="flex">
<!-- Presets sidebar -->
@ -379,57 +309,3 @@
</div>
</Popover.Content>
</Popover.Root>
</div>
<style>
.date-filter-trigger {
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.03);
}
:global(.dark) .date-filter-trigger {
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
}
.date-filter-trigger:focus {
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.02),
0 0 0 3px var(--ring);
}
:global(.dark) .date-filter-trigger:focus {
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.1),
0 0 0 3px var(--ring),
0 0 20px -4px var(--primary);
}
/* Calendar wrapper refinements */
.date-calendar-wrapper :global([data-calendar-root]) {
--calendar-cell-size: 32px;
}
.date-calendar-wrapper :global(button[data-today]) {
position: relative;
}
.date-calendar-wrapper :global(button[data-today])::after {
content: '';
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--primary);
}
.date-calendar-wrapper :global(button[data-selected]) {
background: var(--primary) !important;
color: var(--primary-foreground) !important;
}
:global(.dark) .date-calendar-wrapper :global(button[data-selected]) {
box-shadow: 0 0 12px -2px var(--primary);
}
</style>

View file

@ -1,14 +1,10 @@
<script>
import { Search, X, Command } from '@lucide/svelte';
import { cn } from '$lib/utils.js';
import { Search, X } from '@lucide/svelte';
/**
* @type {{
* value?: string,
* placeholder?: string,
* label?: string,
* showLabel?: boolean,
* showShortcut?: boolean,
* class?: string,
* onchange?: (value: string) => void,
* [key: string]: any
@ -17,9 +13,6 @@
let {
value = $bindable(''),
placeholder = 'Search...',
label = 'Search',
showLabel = true,
showShortcut = false,
class: className,
onchange,
...restProps
@ -77,137 +70,28 @@
}
</script>
<div class={cn('flex flex-col gap-1.5', className)} {...restProps}>
{#if showLabel && label}
<label
for="search-input"
class="text-muted-foreground/70 text-[11px] font-semibold tracking-wider uppercase"
<div class="relative inline-flex items-center {className || ''}" {...restProps}>
<Search class="pointer-events-none absolute left-2.5 size-3.5 text-[color:var(--text-subtle)]" />
<input
id="search-input"
bind:this={inputEl}
type="text"
value={inputValue}
oninput={handleInput}
onfocus={handleFocus}
onblur={handleBlur}
onkeydown={handleKeydown}
{placeholder}
class="h-7 w-full rounded-[var(--r-sm)] border border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] py-0 pl-8 pr-7 text-[11.5px] text-[color:var(--text)] placeholder:text-[color:var(--text-subtle)] outline-none transition-colors focus:border-[color:var(--violet)]/50 focus:ring-1 focus:ring-[color:var(--violet)]/30"
/>
{#if inputValue}
<button
type="button"
onclick={handleClear}
class="absolute right-1.5 flex size-4 items-center justify-center rounded-sm text-[color:var(--text-subtle)] hover:bg-[color:var(--bg-hover)] hover:text-[color:var(--text-muted)]"
aria-label="Clear search"
>
{label}
</label>
<X class="size-3" />
</button>
{/if}
<div class="search-input-wrapper group relative">
<!-- Search icon with pulse on focus -->
<div
class={cn(
'absolute top-1/2 left-3 -translate-y-1/2',
'transition-all duration-300 ease-out',
isFocused ? 'text-primary scale-110' : 'text-muted-foreground'
)}
>
<Search class="h-4 w-4" />
</div>
<!-- Input field -->
<input
id="search-input"
bind:this={inputEl}
type="text"
value={inputValue}
oninput={handleInput}
onfocus={handleFocus}
onblur={handleBlur}
onkeydown={handleKeydown}
{placeholder}
class={cn(
'search-input',
'flex h-9 w-full rounded-lg',
'bg-background/60 backdrop-blur-sm',
'border-input/60 border',
'pr-9 pl-9 text-sm',
'placeholder:text-muted-foreground/50',
'transition-all duration-200 ease-out',
'outline-none',
'hover:border-input hover:bg-background/80',
'focus:border-primary/50 focus:bg-background',
'focus:ring-primary/20 focus:ring-2',
'dark:border-white/[0.08] dark:bg-white/[0.03]',
'dark:hover:border-white/[0.12] dark:hover:bg-white/[0.05]',
'dark:focus:border-primary/40 dark:focus:bg-white/[0.06]',
'dark:focus:ring-primary/30',
inputValue && 'pr-16'
)}
/>
<!-- Right side: shortcut hint or clear button -->
<div class="absolute top-1/2 right-2 flex -translate-y-1/2 items-center gap-1">
{#if inputValue}
<!-- Clear button -->
<button
type="button"
onclick={handleClear}
class={cn(
'flex h-6 w-6 items-center justify-center rounded-md',
'text-muted-foreground/70',
'transition-all duration-150',
'hover:bg-muted hover:text-foreground',
'active:scale-95'
)}
>
<X class="h-3.5 w-3.5" />
</button>
{:else if showShortcut}
<!-- Keyboard shortcut hint -->
<div
class={cn(
'hidden items-center gap-0.5 sm:flex',
'border-border/60 bg-muted/50 rounded-md border px-1.5 py-0.5',
'text-muted-foreground/60 text-[10px] font-medium',
'dark:border-white/[0.06] dark:bg-white/[0.03]'
)}
>
<Command class="h-2.5 w-2.5" />
<span>K</span>
</div>
{/if}
</div>
<!-- Focus glow effect (dark mode) -->
<div
class={cn(
'pointer-events-none absolute inset-0 -z-10 rounded-lg',
'opacity-0 transition-opacity duration-300',
'dark:bg-[radial-gradient(ellipse_at_center,var(--primary)_0%,transparent_70%)]',
'dark:blur-xl',
isFocused && 'dark:opacity-[0.15]'
)}
></div>
</div>
</div>
<style>
.search-input-wrapper {
--glow-color: var(--primary);
}
.search-input::placeholder {
transition: color 0.2s ease;
}
.search-input:focus::placeholder {
color: transparent;
}
/* Subtle inner shadow for depth */
.search-input {
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.03);
}
:global(.dark) .search-input {
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
}
.search-input:focus {
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.02),
0 0 0 3px var(--ring);
}
:global(.dark) .search-input:focus {
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.1),
0 0 0 3px var(--ring),
0 0 20px -4px var(--glow-color);
}
</style>

View file

@ -1,5 +1,5 @@
<script>
import { ChevronDown, Check, X, CircleDot } from '@lucide/svelte';
import { ChevronDown, Check, X } from '@lucide/svelte';
import { cn } from '$lib/utils.js';
import * as Popover from '$lib/components/ui/popover/index.js';
@ -28,7 +28,7 @@
value = $bindable(''),
multiple = false,
placeholder = 'Select...',
label = '',
label: _label = '',
allLabel = 'All',
class: className,
onchange
@ -85,90 +85,45 @@
const hasValue = $derived(multiple ? selectedValues.length > 0 : value && value !== 'ALL');
</script>
<div class={cn('flex flex-col gap-1.5', className)}>
{#if label}
<span class="text-muted-foreground/70 text-[11px] font-semibold tracking-wider uppercase">
{label}
</span>
{/if}
<Popover.Root bind:open>
<Popover.Root bind:open>
<Popover.Trigger asChild class="">
{#snippet child({ props })}
<button
type="button"
class={cn(
'select-filter-trigger group',
'relative flex h-9 w-full items-center justify-between gap-2',
'rounded-lg border px-3 py-2',
'bg-background/60 backdrop-blur-sm',
'text-sm transition-all duration-200',
'outline-none',
// Default state
'border-input/60',
'hover:border-input hover:bg-background/80',
// Focus state
'focus:border-primary/50 focus:bg-background',
'focus:ring-primary/20 focus:ring-2',
// Dark mode
'dark:border-white/[0.08] dark:bg-white/[0.03]',
'dark:hover:border-white/[0.12] dark:hover:bg-white/[0.05]',
'dark:focus:border-primary/40 dark:focus:bg-white/[0.06]',
// Active state
hasValue &&
'border-primary/40 bg-primary/[0.03] dark:border-primary/30 dark:bg-primary/[0.08]',
// Open state
open && 'ring-primary/20 dark:ring-primary/30 ring-2'
)}
{...props}
class={cn(
'inline-flex h-7 items-center gap-1.5 rounded-[var(--r-sm)] border px-2.5 text-[11.5px] font-medium leading-none transition-colors focus:outline-none focus:ring-1 focus:ring-[color:var(--ring)]',
hasValue
? 'border-[color:var(--violet)]/40 bg-[color:var(--violet-soft)] text-[color:var(--violet-soft-text)]'
: 'border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)] hover:bg-[color:var(--bg-hover)]',
className
)}
>
<span
class={cn(
'truncate transition-colors duration-150',
hasValue ? 'text-foreground font-medium' : 'text-muted-foreground'
)}
>
{displayText}
</span>
<div class="flex shrink-0 items-center gap-1">
{#if hasValue}
<!-- Active indicator dot -->
<div
class="bg-primary animate-in fade-in zoom-in h-1.5 w-1.5 rounded-full duration-200"
></div>
<!-- Clear button -->
<span
role="button"
tabindex="0"
onclick={(e) => {
<span class="truncate">{displayText}</span>
{#if hasValue}
<span
role="button"
tabindex="0"
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
handleClear();
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
e.preventDefault();
handleClear();
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
e.preventDefault();
handleClear();
}
}}
class={cn(
'flex h-5 w-5 items-center justify-center rounded-md',
'text-muted-foreground/60 transition-all duration-150',
'hover:bg-muted hover:text-foreground',
'active:scale-90'
)}
>
<X class="h-3 w-3" />
</span>
{/if}
<ChevronDown
class={cn(
'text-muted-foreground/50 h-4 w-4 transition-transform duration-200',
open && 'rotate-180'
)}
/>
</div>
}
}}
class="-mr-1 ml-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-sm hover:bg-[color:var(--violet)]/15"
aria-label="Clear filter"
>
<X class="size-3" />
</span>
{:else}
<ChevronDown class="size-3.5 shrink-0 opacity-60" />
{/if}
</button>
{/snippet}
</Popover.Trigger>
@ -176,15 +131,7 @@
<Popover.Content
align="start"
sideOffset={4}
class={cn(
'select-filter-content',
'w-[220px] overflow-hidden rounded-xl p-1',
'border-border/60 bg-popover/95 border backdrop-blur-md',
'shadow-lg shadow-black/5',
'dark:bg-popover/90 dark:border-white/[0.08]',
'dark:shadow-[0_8px_32px_-4px_rgba(0,0,0,0.5)]',
'animate-in fade-in-0 zoom-in-95 duration-150'
)}
class="w-[220px] overflow-hidden rounded-[var(--r-md)] border border-[color:var(--border)] bg-[color:var(--bg-card)] p-1 shadow-lg shadow-black/5"
>
<!-- All option -->
{#if !multiple}
@ -263,77 +210,3 @@
</div>
</Popover.Content>
</Popover.Root>
</div>
<!-- Multi-select badges (outside dropdown) -->
{#if multiple && selectedValues.length > 0}
<div class="mt-2 flex flex-wrap gap-1.5">
{#each selectedValues as val, i}
{@const opt = options.find((o) => o.value === val)}
<span
class={cn(
'inline-flex items-center gap-1 rounded-full',
'bg-primary/10 text-primary px-2.5 py-1 text-xs font-medium',
'dark:bg-primary/20',
'animate-in fade-in zoom-in duration-200'
)}
style="animation-delay: {i * 50}ms"
>
{opt?.label || val}
<button
type="button"
onclick={() => handleSelect(val)}
class={cn(
'flex h-4 w-4 items-center justify-center rounded-full',
'transition-all duration-150',
'hover:bg-primary/20 dark:hover:bg-primary/30',
'active:scale-90'
)}
>
<X class="h-2.5 w-2.5" />
</button>
</span>
{/each}
</div>
{/if}
<style>
.select-filter-trigger {
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.03);
}
:global(.dark) .select-filter-trigger {
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
}
.select-filter-trigger:focus {
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.02),
0 0 0 3px var(--ring);
}
:global(.dark) .select-filter-trigger:focus {
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.1),
0 0 0 3px var(--ring),
0 0 20px -4px var(--primary);
}
.select-option {
position: relative;
}
.select-option::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
opacity: 0;
background: linear-gradient(90deg, transparent 0%, var(--primary) 50%, transparent 100%);
transition: opacity 0.3s ease;
}
:global(.dark) .select-option:hover::before {
opacity: 0.03;
}
</style>

View file

@ -26,7 +26,7 @@
tags = [],
value = $bindable([]),
placeholder = 'Filter by tags...',
label = 'Tags',
label: _label = '',
class: className,
onchange
} = $props();
@ -87,104 +87,51 @@
}
</script>
<div class={cn('flex flex-col gap-1.5', className)}>
{#if label}
<span class="text-muted-foreground/70 text-[11px] font-semibold tracking-wider uppercase">
{label}
</span>
{/if}
<Popover.Root bind:open>
<Popover.Root bind:open>
<Popover.Trigger asChild class="">
{#snippet child({ props })}
<button
type="button"
class={cn(
'tag-filter-trigger group',
'relative flex h-9 w-full items-center justify-between gap-2',
'rounded-lg border px-3 py-2',
'bg-background/60 backdrop-blur-sm',
'text-sm transition-all duration-200',
'outline-none',
// Default state
'border-input/60',
'hover:border-input hover:bg-background/80',
// Focus state
'focus:border-primary/50 focus:bg-background',
'focus:ring-primary/20 focus:ring-2',
// Dark mode
'dark:border-white/[0.08] dark:bg-white/[0.03]',
'dark:hover:border-white/[0.12] dark:hover:bg-white/[0.05]',
'dark:focus:border-primary/40 dark:focus:bg-white/[0.06]',
// Active state
hasSelection &&
'border-primary/40 bg-primary/[0.03] dark:border-primary/30 dark:bg-primary/[0.08]',
// Open state
open && 'ring-primary/20 dark:ring-primary/30 ring-2'
)}
{...props}
class={cn(
'inline-flex h-7 items-center gap-1.5 rounded-[var(--r-sm)] border px-2.5 text-[11.5px] font-medium leading-none transition-colors focus:outline-none focus:ring-1 focus:ring-[color:var(--ring)]',
hasSelection
? 'border-[color:var(--violet)]/40 bg-[color:var(--violet-soft)] text-[color:var(--violet-soft-text)]'
: 'border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)] hover:bg-[color:var(--bg-hover)]',
className
)}
>
<div class="flex items-center gap-2">
<Tag
class={cn(
'h-4 w-4 transition-colors duration-200',
hasSelection ? 'text-primary' : 'text-muted-foreground'
)}
/>
<Tag class="size-3.5 shrink-0" />
<span class="truncate">{displayText}</span>
{#if hasSelection}
<span
class={cn(
'truncate transition-colors duration-150',
hasSelection ? 'text-foreground font-medium' : 'text-muted-foreground'
)}
class="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-[var(--r-sm)] px-1.5 text-[10.5px] font-medium tabular-nums text-[color:var(--violet-soft-text)] opacity-90"
>
{displayText}
{selectedIds.length}
</span>
</div>
<div class="flex shrink-0 items-center gap-1">
{#if hasSelection}
<!-- Count badge -->
<span
class={cn(
'inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5',
'bg-primary text-primary-foreground text-[10px] font-bold',
'animate-in fade-in zoom-in duration-200'
)}
>
{selectedIds.length}
</span>
<!-- Clear button -->
<span
role="button"
tabindex="0"
onclick={(e) => {
<span
role="button"
tabindex="0"
onclick={(e) => {
e.stopPropagation();
e.preventDefault();
clearAll();
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
e.preventDefault();
clearAll();
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
e.preventDefault();
clearAll();
}
}}
class={cn(
'flex h-5 w-5 items-center justify-center rounded-md',
'text-muted-foreground/60 transition-all duration-150',
'hover:bg-muted hover:text-foreground',
'active:scale-90'
)}
>
<X class="h-3 w-3" />
</span>
{/if}
<ChevronDown
class={cn(
'text-muted-foreground/50 h-4 w-4 transition-transform duration-200',
open && 'rotate-180'
)}
/>
</div>
}
}}
class="-mr-1 ml-0.5 flex size-3.5 shrink-0 items-center justify-center rounded-sm hover:bg-[color:var(--violet)]/15"
aria-label="Clear filter"
>
<X class="size-3" />
</span>
{:else}
<ChevronDown class="size-3.5 shrink-0 opacity-60" />
{/if}
</button>
{/snippet}
</Popover.Trigger>
@ -192,15 +139,7 @@
<Popover.Content
align="start"
sideOffset={4}
class={cn(
'tag-filter-content',
'w-[280px] overflow-hidden rounded-xl p-1',
'border-border/60 bg-popover/95 border backdrop-blur-md',
'shadow-lg shadow-black/5',
'dark:bg-popover/90 dark:border-white/[0.08]',
'dark:shadow-[0_8px_32px_-4px_rgba(0,0,0,0.5)]',
'animate-in fade-in-0 zoom-in-95 duration-150'
)}
class="w-[280px] overflow-hidden rounded-[var(--r-md)] border border-[color:var(--border)] bg-[color:var(--bg-card)] p-1 shadow-lg shadow-black/5"
>
{#if tags.length === 0}
<div class="flex flex-col items-center justify-center py-6 text-center">
@ -292,78 +231,3 @@
{/if}
</Popover.Content>
</Popover.Root>
</div>
<!-- Selected tags as inline badges -->
{#if selectedTags.length > 0}
<div class="mt-2 flex flex-wrap gap-1.5">
{#each selectedTags as tag, i (tag.id)}
<span
class={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1',
'text-xs font-semibold',
'transition-all duration-150',
getTagStyle(tag.color),
'animate-in fade-in zoom-in slide-in-from-bottom-1'
)}
style="animation-delay: {i * 40}ms"
>
<Hash class="h-3 w-3 opacity-60" />
{tag.name}
<button
type="button"
onclick={() => toggleTag(tag.id)}
class={cn(
'flex h-4 w-4 items-center justify-center rounded-full',
'transition-all duration-150',
'hover:bg-black/10 dark:hover:bg-white/20',
'active:scale-90'
)}
>
<X class="h-2.5 w-2.5" />
</button>
</span>
{/each}
</div>
{/if}
<style>
.tag-filter-trigger {
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.03);
}
:global(.dark) .tag-filter-trigger {
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
}
.tag-filter-trigger:focus {
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.02),
0 0 0 3px var(--ring);
}
:global(.dark) .tag-filter-trigger:focus {
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.1),
0 0 0 3px var(--ring),
0 0 20px -4px var(--primary);
}
.tag-option {
position: relative;
}
.tag-option::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
opacity: 0;
background: linear-gradient(90deg, transparent 0%, var(--primary) 50%, transparent 100%);
transition: opacity 0.3s ease;
}
:global(.dark) .tag-option:hover::before {
opacity: 0.02;
}
</style>

View file

@ -0,0 +1,83 @@
<script>
import { enhance } from '$app/forms';
import { cn } from '$lib/utils.js';
/**
* @typedef {Object} Props
* @property {'GET' | 'POST' | 'get' | 'post' | 'dialog' | 'DIALOG'} [method]
* @property {string} [action]
* @property {'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'} [enctype]
* @property {(this: HTMLFormElement, event: SubmitEvent) => void} [onsubmit]
* @property {import('@sveltejs/kit').SubmitFunction} [useEnhance]
* @property {string} [class]
* @property {string} [errorMessage]
* @property {import('svelte').Snippet} children
* @property {import('svelte').Snippet} actions
*/
/** @type {Props} */
let {
method = 'POST',
action = '',
enctype,
onsubmit,
useEnhance = undefined,
class: className,
errorMessage = '',
children,
actions
} = $props();
</script>
{#snippet body()}
<div class="flex flex-col gap-6">
{@render children()}
</div>
<div
class="fixed inset-x-0 bottom-0 z-20 border-t border-[color:var(--border-faint)] bg-[color:var(--bg)]/95 backdrop-blur-[1px]"
>
<div class="mx-auto flex max-w-[680px] items-center justify-between gap-3 px-7 py-3 md:px-8">
{#if errorMessage}
<p class="truncate text-[12px] text-[color:var(--red)]">{errorMessage}</p>
{:else}
<span></span>
{/if}
<div class="flex items-center gap-2">
{@render actions()}
</div>
</div>
</div>
{/snippet}
{#if useEnhance}
<form
{method}
{action}
{enctype}
{onsubmit}
use:enhance={useEnhance}
class={cn(
'mx-auto flex w-full max-w-[680px] flex-col',
'px-7 pt-4 pb-[88px] md:px-8',
className
)}
>
{@render body()}
</form>
{:else}
<form
{method}
{action}
{enctype}
{onsubmit}
class={cn(
'mx-auto flex w-full max-w-[680px] flex-col',
'px-7 pt-4 pb-[88px] md:px-8',
className
)}
>
{@render body()}
</form>
{/if}

View file

@ -0,0 +1 @@
export { default as FormShell } from './FormShell.svelte';

View file

@ -21,18 +21,27 @@
'data-slot': dataSlot = 'input',
...restProps
} = $props();
const baseClass = [
'flex h-9 w-full min-w-0 px-3',
'rounded-[var(--r-md)] border border-[color:var(--border)]',
'bg-[color:var(--bg-input)] text-[13.5px] text-[color:var(--text)]',
'leading-none outline-none',
'placeholder:text-[color:var(--text-subtle)]',
'transition-[border-color,box-shadow] duration-150',
'hover:border-[color:var(--border-strong)]',
'focus-visible:border-[color:var(--text)] focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]',
'aria-invalid:border-[color:var(--red)] aria-invalid:shadow-[0_0_0_3px_var(--red-soft)]',
'disabled:cursor-not-allowed disabled:opacity-50',
'selection:bg-[color:var(--text)] selection:text-[color:var(--bg)]'
].join(' ');
</script>
{#if type === 'file'}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
'selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
class={cn(baseClass, 'pt-1.5 text-[13px] font-medium', className)}
type="file"
bind:files
bind:value
@ -42,12 +51,7 @@
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
'border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
class={cn(baseClass, className)}
{type}
bind:value
{...restProps}

View file

@ -49,11 +49,13 @@
emptyMessage = 'No data available'
} = $props();
// Local mutable copy of columns for optimistic updates
// Local mutable copy of columns for optimistic updates.
// Initialize synchronously from props so SSR renders the actual kanban
// instead of flashing the empty state before $effect runs on hydration.
/** @type {Column[]} */
let localColumns = $state([]);
let localColumns = $state(data?.columns ? structuredClone(data.columns) : []);
// Sync local columns when data changes from server
// Sync local columns when data changes from server (filter/refresh/navigation)
$effect(() => {
if (data?.columns) {
localColumns = structuredClone(data.columns);

View file

@ -22,7 +22,20 @@
{preventScroll}
data-slot="select-content"
class={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
[
'relative z-50 min-w-[8rem] overflow-hidden overflow-x-hidden overflow-y-auto',
'rounded-[var(--r-md)] border border-[color:var(--border-faint)]',
'bg-[color:var(--bg-card)] text-[13.5px] text-[color:var(--text)]',
'shadow-[0_8px_24px_-12px_rgba(0,0,0,0.16)]',
'max-h-(--bits-select-content-available-height) origin-(--bits-select-content-transform-origin)',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2',
'data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2',
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1',
'data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1'
].join(' '),
className
)}
{...restProps}

View file

@ -18,7 +18,17 @@
{value}
data-slot="select-item"
class={cn(
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
[
'relative flex w-full cursor-default select-none items-center gap-2',
'py-1.5 ps-2 pe-8 text-[13.5px] text-[color:var(--text-muted)]',
'rounded-[var(--r-sm)] outline-hidden',
'data-[highlighted]:bg-[color:var(--bg-elevated)] data-[highlighted]:text-[color:var(--text)]',
'data-[state=checked]:text-[color:var(--text)]',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
"[&_svg:not([class*='text-'])]:text-[color:var(--text-subtle)]",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-[14px]",
'*:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2'
].join(' '),
className
)}
{...restProps}
@ -26,7 +36,7 @@
{#snippet children({ selected, highlighted })}
<span class="absolute end-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
<CheckIcon class="size-[14px]" />
{/if}
</span>
{#if childrenProp}

View file

@ -17,11 +17,25 @@
data-slot="select-trigger"
data-size={size}
class={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
[
'flex w-fit items-center justify-between gap-2 px-3 pr-2.5',
'rounded-[var(--r-md)] border border-[color:var(--border)]',
'bg-[color:var(--bg-input)] text-[13.5px] text-[color:var(--text)]',
'whitespace-nowrap outline-none select-none leading-none',
'transition-[border-color,box-shadow] duration-150',
'hover:border-[color:var(--border-strong)]',
'focus-visible:border-[color:var(--text)] focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]',
'aria-invalid:border-[color:var(--red)] aria-invalid:shadow-[0_0_0_3px_var(--red-soft)]',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[placeholder]:text-[color:var(--text-subtle)]',
'data-[size=default]:h-9 data-[size=sm]:h-8',
'*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2',
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-[14px] [&_svg]:stroke-[1.7]"
].join(' '),
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
<ChevronDownIcon class="size-[14px] text-[color:var(--text-subtle)]" />
</SelectPrimitive.Trigger>

View file

@ -0,0 +1,73 @@
<script>
import { X, ChevronLeft } from '@lucide/svelte';
import { cn } from '$lib/utils.js';
import { sidePanelHidden } from './index.js';
/**
* @typedef {Object} Props
* @property {string} [class]
* @property {import('svelte').Snippet} [head]
* @property {import('svelte').Snippet} [body]
* @property {import('svelte').Snippet} [empty]
* @property {boolean} [hasActive]
*/
/** @type {Props} */
let { class: className, head, body, empty, hasActive = true } = $props();
function close() {
sidePanelHidden.set(true);
}
function reopen() {
sidePanelHidden.set(false);
}
</script>
{#if $sidePanelHidden}
<button
type="button"
onclick={reopen}
aria-label="Show side panel"
class="fixed right-0 top-1/2 z-30 hidden h-16 w-3 -translate-y-1/2 items-center justify-center rounded-l-md border border-r-0 border-[color:var(--border)] bg-[color:var(--bg-card)] text-[color:var(--text-subtle)] hover:text-[color:var(--text)] hover:bg-[color:var(--bg-hover)] xl:flex"
>
<ChevronLeft class="size-3.5" />
</button>
{:else}
<aside
class={cn(
'fixed right-0 top-0 z-20 hidden h-screen w-[380px] flex-col border-l border-[color:var(--border-faint)] bg-[color:var(--bg-card)] xl:flex',
className
)}
>
<div class="flex items-start justify-between gap-2 border-b border-[color:var(--border-faint)] px-[22px] pt-5 pb-4">
<div class="min-w-0 flex-1">
{#if hasActive && head}
{@render head()}
{:else}
<p class="text-[13px] text-[color:var(--text-subtle)]">No row selected</p>
{/if}
</div>
<button
type="button"
onclick={close}
aria-label="Hide side panel"
class="-mr-1 inline-flex size-7 shrink-0 items-center justify-center rounded-md text-[color:var(--text-subtle)] hover:bg-[color:var(--bg-hover)] hover:text-[color:var(--text)]"
>
<X class="size-4" />
</button>
</div>
<div class="flex-1 overflow-y-auto px-[22px] py-[18px]">
{#if hasActive && body}
<div class="flex flex-col gap-5">
{@render body()}
</div>
{:else if empty}
{@render empty()}
{:else}
<p class="text-[12.5px] text-[color:var(--text-subtle)]">Click a row to peek.</p>
{/if}
</div>
</aside>
{/if}

View file

@ -0,0 +1,24 @@
<script>
import { cn } from '$lib/utils.js';
/**
* @typedef {Object} Props
* @property {string} label
* @property {string} [class]
* @property {import('svelte').Snippet} children
*/
/** @type {Props} */
let { label, class: className, children } = $props();
</script>
<section class={cn('flex flex-col gap-2', className)}>
<h4
class="text-[10.5px] font-semibold uppercase tracking-[0.06em] text-[color:var(--text-subtle)]"
>
{label}
</h4>
<div class="flex flex-col gap-1.5 text-[13px] text-[color:var(--text)]">
{@render children()}
</div>
</section>

View file

@ -0,0 +1,27 @@
import { writable } from 'svelte/store';
const STORAGE_KEY = 'bottlecrm:side-panel-hidden';
function readInitial() {
if (typeof sessionStorage === 'undefined') return false;
try {
return sessionStorage.getItem(STORAGE_KEY) === '1';
} catch {
return false;
}
}
export const sidePanelHidden = writable(readInitial());
if (typeof window !== 'undefined') {
sidePanelHidden.subscribe((v) => {
try {
sessionStorage.setItem(STORAGE_KEY, v ? '1' : '0');
} catch {
/* private mode / storage disabled */
}
});
}
export { default as SidePanel } from './SidePanel.svelte';
export { default as SidePanelSection } from './SidePanelSection.svelte';

View file

@ -1,6 +1,6 @@
export const SIDEBAR_COOKIE_NAME = 'sidebar:state';
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = '16rem';
export const SIDEBAR_WIDTH = '248px';
export const SIDEBAR_WIDTH_MOBILE = '18rem';
export const SIDEBAR_WIDTH_ICON = '3rem';
export const SIDEBAR_WIDTH_ICON = '56px';
export const SIDEBAR_KEYBOARD_SHORTCUT = 'b';

View file

@ -23,10 +23,7 @@
const mergedProps = $derived({
class: cn(
'text-sidebar-foreground/70 ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-foreground active:bg-sidebar-accent active:text-sidebar-foreground [&>svg]:text-sidebar-foreground/60 outline-hidden flex h-7 min-w-0 items-center gap-2.5 overflow-hidden rounded-md px-2 text-[12px] font-medium transition-all duration-150 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-3.5 [&>svg]:shrink-0',
'data-[active=true]:bg-[#fff1ee] data-[active=true]:text-[#ff5c35] dark:data-[active=true]:bg-[#ff7a59]/10 dark:data-[active=true]:text-[#ff7a59]',
size === 'sm' && 'text-[11px] h-6',
size === 'md' && 'text-[12px]',
'ring-sidebar-ring outline-hidden flex min-w-0 items-center gap-2.5 overflow-hidden rounded-md font-medium transition-colors duration-150 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate',
'group-data-[collapsible=icon]:hidden',
className
),

View file

@ -0,0 +1,74 @@
<script>
import { cn } from '$lib/utils.js';
/**
* @typedef {Object} Stage
* @property {string} value
* @property {string} label
* @property {string} [meta] - date or dwell text shown under the name (e.g. "Mar 12 · 3d")
*/
/**
* @type {{
* stages: Stage[],
* current: string,
* class?: string,
* }}
*/
let { stages = [], current = '', class: className } = $props();
// Index of the current stage; -1 means "no current" → all rendered as future.
const currentIndex = $derived(stages.findIndex((s) => s.value === current));
/**
* @param {number} i
* @returns {'done' | 'current' | 'future'}
*/
function stateAt(i) {
if (currentIndex === -1) return 'future';
if (i < currentIndex) return 'done';
if (i === currentIndex) return 'current';
return 'future';
}
</script>
<div
role="list"
aria-label="Stage progress"
class={cn(
'grid w-full gap-1.5',
className
)}
style="grid-template-columns: repeat({stages.length}, minmax(0, 1fr));"
>
{#each stages as stage, i (stage.value)}
{@const state = stateAt(i)}
<div
role="listitem"
aria-current={state === 'current' ? 'step' : undefined}
class={cn(
'flex h-10 flex-col justify-center gap-0.5 rounded-[var(--r-md)] border px-2.5 py-1.5',
state === 'current' && 'border-[color:var(--text)] bg-[color:var(--text)] text-[color:var(--bg)]',
state === 'done' && 'border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)]',
state === 'future' && 'border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] text-[color:var(--text-subtle)]'
)}
>
<span
class={cn(
'truncate text-[10.5px] font-semibold uppercase tracking-[0.04em] leading-none',
state === 'current' ? '' : state === 'done' ? 'text-[color:var(--text-muted)]' : 'text-[color:var(--text-subtle)]'
)}
>
{stage.label}
</span>
<span
class={cn(
'truncate text-[10px] leading-none',
state === 'current' ? 'text-[color:var(--bg)] opacity-80' : 'text-[color:var(--text-subtle)]'
)}
>
{state === 'future' ? '—' : (stage.meta || '—')}
</span>
</div>
{/each}
</div>

View file

@ -0,0 +1 @@
export { default as StageStepper } from './StageStepper.svelte';

View file

@ -0,0 +1,6 @@
import Root from './switch.svelte';
export {
Root,
Root as Switch
};

View file

@ -0,0 +1,48 @@
<script>
import { Switch as SwitchPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
/**
* @type {{
* ref?: HTMLButtonElement | null,
* checked?: boolean,
* class?: string,
* children?: import('svelte').Snippet,
* [key: string]: any
* }}
*/
let {
ref = $bindable(null),
checked = $bindable(false),
class: className,
...restProps
} = $props();
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
data-slot="switch"
class={cn(
[
'relative inline-flex h-[18px] w-8 shrink-0 cursor-pointer items-center',
'rounded-full border border-transparent',
'bg-[color:var(--border)] data-[state=checked]:bg-[color:var(--text)]',
'transition-colors duration-150',
'focus-visible:shadow-[0_0_0_3px_var(--focus-ring)] outline-none',
'disabled:cursor-not-allowed disabled:opacity-50'
].join(' '),
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
class={cn(
'pointer-events-none block size-[14px] rounded-full bg-[color:var(--bg)]',
'shadow-[0_1px_2px_rgba(0,0,0,0.15)]',
'transition-transform duration-150',
'translate-x-0.5 data-[state=checked]:translate-x-[16px]'
)}
/>
</SwitchPrimitive.Root>

View file

@ -9,7 +9,9 @@
bind:ref
data-slot="tabs-list"
class={cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
'inline-flex w-full items-center gap-4 px-0',
'border-b border-[color:var(--border-faint)]',
'text-[color:var(--text-muted)]',
className
)}
{...restProps}

View file

@ -10,7 +10,19 @@
data-slot="tabs-trigger"
{value}
class={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
[
'relative inline-flex items-center justify-center gap-1.5',
'px-0 pb-2.5 -mb-px',
'text-[13px] font-medium leading-none whitespace-nowrap',
'text-[color:var(--text-muted)]',
'border-b-2 border-transparent',
'transition-colors duration-150',
'hover:text-[color:var(--text)]',
'data-[state=active]:text-[color:var(--text)] data-[state=active]:border-[color:var(--text)]',
'focus-visible:outline-none focus-visible:shadow-[0_0_0_3px_var(--focus-ring)] rounded-[var(--r-sm)]',
'disabled:pointer-events-none disabled:opacity-50',
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
].join(' '),
className
)}
{...restProps}

View file

@ -13,7 +13,18 @@
bind:this={ref}
data-slot={dataSlot}
class={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
[
'flex w-full min-w-0 min-h-[92px] py-[9px] px-3 resize-y',
'rounded-[var(--r-md)] border border-[color:var(--border)]',
'bg-[color:var(--bg-input)] text-[13.5px] text-[color:var(--text)]',
'leading-[1.55] outline-none field-sizing-content',
'placeholder:text-[color:var(--text-subtle)]',
'transition-[border-color,box-shadow] duration-150',
'hover:border-[color:var(--border-strong)]',
'focus-visible:border-[color:var(--text)] focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]',
'aria-invalid:border-[color:var(--red)] aria-invalid:shadow-[0_0_0_3px_var(--red-soft)]',
'disabled:cursor-not-allowed disabled:opacity-50'
].join(' '),
className
)}
bind:value

View file

@ -0,0 +1,27 @@
<script>
import { cn } from '$lib/utils.js';
/**
* @type {{
* children?: import('svelte').Snippet,
* empty?: import('svelte').Snippet,
* isEmpty?: boolean,
* class?: string,
* }}
*/
let { children, empty, isEmpty = false, class: className } = $props();
</script>
{#if isEmpty}
<div class={cn('px-4 py-6 text-center text-[12.5px] text-[color:var(--text-subtle)]', className)}>
{#if empty}
{@render empty()}
{:else}
No activity yet.
{/if}
</div>
{:else}
<ol class={cn('divide-y divide-[color:var(--border-faint)]', className)}>
{@render children?.()}
</ol>
{/if}

View file

@ -0,0 +1,55 @@
<script>
import { cn } from '$lib/utils.js';
/**
* @type {{
* variant?: 'default' | 'success' | 'violet' | 'amber',
* time?: string,
* quote?: string,
* text: import('svelte').Snippet,
* icon?: import('svelte').Snippet,
* class?: string,
* }}
*/
let { variant = 'default', time = '', quote = '', text, icon, class: className } = $props();
// Map variant → icon background classes. Default is the neutral chip.
const iconBg = $derived(
variant === 'success'
? 'bg-[color:var(--green)]/12 border-[color:var(--green)]/30 text-[color:var(--green)]'
: variant === 'violet'
? 'bg-[color:var(--violet-soft)] border-[color:var(--violet)]/30 text-[color:var(--violet-soft-text)]'
: variant === 'amber'
? 'bg-[color:var(--amber)]/12 border-[color:var(--amber)]/30 text-[color:var(--amber)]'
: 'bg-[color:var(--bg-elevated)] border-[color:var(--border-faint)] text-[color:var(--text-muted)]'
);
</script>
<li class={cn('flex items-start gap-3 py-3.5 first:pt-0 last:pb-0', className)}>
<div
aria-hidden="true"
class={cn(
'flex size-7 shrink-0 items-center justify-center rounded-full border',
iconBg
)}
>
{#if icon}
{@render icon()}
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="text-[13px] leading-[1.55] text-[color:var(--text-muted)] [&_strong]:font-medium [&_strong]:text-[color:var(--text)]">
{@render text()}
</div>
{#if quote}
<blockquote
class="mt-2 rounded-[var(--r-md)] border border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] px-3 py-2 text-[12.5px] leading-[1.5] text-[color:var(--text)]"
>
{quote}
</blockquote>
{/if}
{#if time}
<p class="mt-1.5 text-[11px] leading-none text-[color:var(--text-subtle)]">{time}</p>
{/if}
</div>
</li>

View file

@ -0,0 +1,2 @@
export { default as Timeline } from './Timeline.svelte';
export { default as TimelineItem } from './TimelineItem.svelte';

View file

@ -1,7 +1,6 @@
<script>
import '../../app.css';
import { AppShell } from '$lib/components/layout/index.js';
import { Bell } from '$lib/components/notifications/index.js';
import { Toaster } from '$lib/components/ui/sonner/index.js';
import { initOrgSettings } from '$lib/stores/org.js';
@ -15,13 +14,8 @@
});
</script>
<AppShell user={data.user} org_name={data.org_name}>
<main class="relative flex-1 pr-16">
<div class="pointer-events-none absolute top-3 right-4 z-30">
<div class="pointer-events-auto">
<Bell />
</div>
</div>
<AppShell user={data.user} org_name={data.org_name} org_settings={data.org_settings}>
<main class="relative flex-1">
{@render children()}
</main>
</AppShell>

View file

@ -1,5 +1,5 @@
<script>
import { DollarSign, TrendingUp, Target, Percent, AlertCircle, Sparkles } from '@lucide/svelte';
import { DollarSign, TrendingUp, Target, Percent, AlertCircle } from '@lucide/svelte';
import {
KPICard,
FocusBar,
@ -14,6 +14,12 @@
import { formatCurrency } from '$lib/utils/formatting.js';
import { orgSettings } from '$lib/stores/org.js';
const today = new Date().toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric'
});
/** @type {{ data: any }} */
let { data } = $props();
@ -34,51 +40,18 @@
: `${orgCurrency} only`
);
// Get current time of day for greeting
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 17) return 'Good afternoon';
return 'Good evening';
};
</script>
<svelte:head>
<title>Dashboard - BottleCRM</title>
</svelte:head>
<div class="relative min-h-screen">
<!-- Subtle gradient mesh background with orange tint -->
<div
class="gradient-mesh noise-overlay pointer-events-none fixed inset-0 -z-10 opacity-50 dark:opacity-30"
></div>
<div class="min-h-screen">
<div class="px-7 pt-6 md:px-8">
<p class="label-tiny">Today · {today}</p>
</div>
<div class="space-y-8 p-6 md:p-8">
<!-- Header Section with orange gradient accent -->
<header>
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-end">
<div class="space-y-2">
<div class="flex items-center gap-3">
<div
class="flex size-10 items-center justify-center rounded-[var(--radius-lg)] bg-gradient-to-br from-[var(--color-primary-default)] to-[var(--stage-contacted)] shadow-lg dark:shadow-[var(--color-primary-default)]/20"
>
<Sparkles class="size-5 text-white" />
</div>
<div>
<p class="text-sm font-medium text-[var(--text-secondary)]">{getGreeting()}</p>
<h1 class="text-2xl font-bold tracking-tight text-[var(--text-primary)] md:text-3xl">
Dashboard
</h1>
</div>
</div>
<p class="max-w-md text-sm text-[var(--text-secondary)]">
Here's what's happening with your CRM today. Stay on top of your pipeline and close more
deals.
</p>
</div>
</div>
</header>
{#if data.error}
<div
class="flex items-center gap-4 rounded-[var(--radius-lg)] border border-[var(--color-negative-default)]/20 bg-[var(--color-negative-light)] p-5 backdrop-blur-sm dark:border-[var(--color-negative-default)]/30 dark:bg-[var(--color-negative-default)]/10"

View file

@ -26,10 +26,9 @@
UserPlus,
Contact,
Banknote,
Filter,
CheckSquare
} from '@lucide/svelte';
import { PageHeader } from '$lib/components/layout';
import { PageHeader, FilterStrip, ViewTabs, StatusBar, FilterPill } from '$lib/components/layout';
import { CrmDrawer } from '$lib/components/ui/crm-drawer';
import { CommentSection } from '$lib/components/ui/comment-section';
import { getCurrentUser } from '$lib/api.js';
@ -37,7 +36,6 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { CrmTable } from '$lib/components/ui/crm-table';
import {
FilterBar,
SearchInput,
SelectFilter,
DateRangeFilter,
@ -45,7 +43,7 @@
} from '$lib/components/ui/filter';
import { Pagination } from '$lib/components/ui/pagination';
import { formatRelativeDate, formatCurrency, getInitials } from '$lib/utils/formatting.js';
import { COUNTRIES, getCountryName } from '$lib/constants/countries.js';
import { COUNTRIES } from '$lib/constants/countries.js';
import { CURRENCY_CODES } from '$lib/constants/filters.js';
import { orgSettings } from '$lib/stores/org.js';
@ -299,12 +297,21 @@
}
]);
// Read the URL synchronously at init so deep links (?view=ID / ?action=create)
// prime the drawer state on the first frame instead of waiting for a $effect tick.
const initialUrlParams = $page.url.searchParams;
const initialViewId = initialUrlParams.get('view');
const initialAction = initialUrlParams.get('action');
const initialAccount = initialViewId
? (data.accounts || []).find((/** @type {any} */ a) => a.id === initialViewId) || null
: null;
// Drawer state - simplified for unified drawer
let drawerOpen = $state(false);
let drawerOpen = $state(initialAction === 'create' || !!initialAccount);
/** @type {'view' | 'create'} */
let drawerMode = $state('view');
let drawerMode = $state(initialAction === 'create' ? 'create' : 'view');
/** @type {any} */
let selectedAccount = $state(null);
let selectedAccount = $state(initialAccount);
let drawerLoading = $state(false);
let isSubmitting = $state(false);
@ -349,16 +356,17 @@
// Check if account is closed (inactive)
const isClosed = $derived(selectedAccount?.isActive === false);
// URL sync for drawer state
// URL sync for drawer state — handles client-side navigation changes after first paint.
// The initial deep link is already handled synchronously above.
$effect(() => {
const viewId = $page.url.searchParams.get('view');
const action = $page.url.searchParams.get('action');
if (action === 'create') {
if (action === 'create' && !drawerOpen) {
selectedAccount = null;
drawerMode = 'create';
drawerOpen = true;
} else if (viewId && accounts.length > 0) {
} else if (viewId && accounts.length > 0 && !drawerOpen) {
const account = accounts.find((a) => a.id === viewId);
if (account) {
selectedAccount = account;
@ -508,9 +516,6 @@
// Accounts are already filtered server-side, apply chip filter for active/closed
let statusChipFilter = $state('ALL');
// Filter panel expansion state
let filtersExpanded = $state(false);
const filteredAccounts = $derived.by(() => {
if (statusChipFilter === 'active') {
return accounts.filter((a) => a.isActive !== false);
@ -520,6 +525,11 @@
return accounts;
});
// Active row (highlighted in the table) — seeded from ?view= so the deep-linked row
// is highlighted immediately on first paint.
/** @type {string | null} */
let activeRowId = $state(initialViewId || null);
// Visible column count for the toggle button
const visibleColumnCount = $derived(visibleColumns.length);
const totalColumnCount = $derived(columns.length);
@ -738,6 +748,7 @@
<title>Accounts - BottleCRM</title>
</svelte:head>
<div class="flex flex-col">
<PageHeader title="Accounts" subtitle="{filteredAccounts.length} of {accounts.length} accounts">
{#snippet actions()}
<div class="flex items-center gap-2">
@ -796,26 +807,6 @@
</button>
</div>
<div class="bg-border mx-1 h-6 w-px"></div>
<!-- Filter Toggle Button -->
<Button
variant={filtersExpanded ? 'secondary' : 'outline'}
size="sm"
class="gap-2"
onclick={() => (filtersExpanded = !filtersExpanded)}
>
<Filter class="h-4 w-4" />
Filters
{#if activeFiltersCount > 0}
<span
class="rounded-full bg-[var(--color-primary-light)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-primary-default)]"
>
{activeFiltersCount}
</span>
{/if}
</Button>
<!-- Column Visibility Dropdown -->
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
@ -854,17 +845,13 @@
</Button>
</div>
{/snippet}
{#snippet tabs()}
<ViewTabs views={[{ id: 'all', label: 'All', count: pagination.total }]} active="all" />
{/snippet}
</PageHeader>
<div class="flex-1">
<!-- Collapsible Filter Bar -->
<FilterBar
minimal={true}
expanded={filtersExpanded}
activeCount={activeFiltersCount}
onClear={clearFilters}
class="pb-4"
>
<FilterStrip>
<SearchInput
value={filters.search}
onchange={(value) => updateFilters({ ...filters, search: value })}
@ -888,7 +875,13 @@
value={filters.tags}
onchange={(ids) => updateFilters({ ...filters, tags: ids })}
/>
</FilterBar>
{#if activeFiltersCount > 0}
<FilterPill label="Clear all" dashed onclick={clearFilters} />
{/if}
{#snippet meta()}
<span>{filteredAccounts.length} of {pagination.total} accounts</span>
{/snippet}
</FilterStrip>
<!-- Accounts Table -->
{#if filteredAccounts.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
@ -909,6 +902,7 @@
data={filteredAccounts}
{columns}
bind:visibleColumns
bind:activeRowId
onRowClick={(row) => openAccount(row)}
>
{#snippet emptyState()}
@ -995,6 +989,9 @@
/>
</div>
<StatusBar status="{filteredAccounts.length} of {pagination.total} accounts" />
</div>
<!-- Account Drawer -->
<CrmDrawer
bind:open={drawerOpen}

View file

@ -16,22 +16,21 @@
FileText,
PhoneOff,
Calendar,
Tag,
Filter
Tag
} from '@lucide/svelte';
import { LinkedinIcon as Linkedin } from '$lib/components/icons';
import { PageHeader } from '$lib/components/layout';
import { PageHeader, FilterStrip, ViewTabs, StatusBar, FilterPill } from '$lib/components/layout';
import { CrmDrawer } from '$lib/components/ui/crm-drawer';
import { CommentSection } from '$lib/components/ui/comment-section';
import { getCurrentUser } from '$lib/api.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { CrmTable } from '$lib/components/ui/crm-table';
import { FilterBar, SearchInput, DateRangeFilter, TagFilter } from '$lib/components/ui/filter';
import { SearchInput, DateRangeFilter, TagFilter } from '$lib/components/ui/filter';
import { Pagination } from '$lib/components/ui/pagination';
import { formatRelativeDate, formatPhone, getNameInitials } from '$lib/utils/formatting.js';
import { goto } from '$app/navigation';
import { COUNTRIES, getCountryName } from '$lib/constants/countries.js';
import { COUNTRIES } from '$lib/constants/countries.js';
import { browser } from '$app/environment';
// Column visibility configuration
@ -288,8 +287,28 @@
const pagination = $derived(data.pagination || { page: 1, limit: 10, total: 0, totalPages: 0 });
const owners = $derived(loadedOwners);
// Read the URL synchronously at init so deep links (?view=ID / ?action=create)
// prime the drawer state on the first frame instead of waiting for a $effect tick.
const initialUrlParams = $page.url.searchParams;
const initialViewId = initialUrlParams.get('view');
const initialAction = initialUrlParams.get('action');
const initialAccountId = initialUrlParams.get('accountId');
const initialContact = initialViewId
? (data.contacts || []).find((/** @type {any} */ c) => c.id === initialViewId) || null
: null;
// If we're entering create mode pre-filled from an account, mirror what the
// $effect URL sync would normally do — but synchronously, so the first paint
// already shows the read-only Account row.
if (initialAction === 'create' && initialAccountId) {
accountId = initialAccountId;
accountFromUrl = true;
const acct = (data.accounts || []).find((/** @type {any} */ a) => a.id === initialAccountId);
accountName = acct ? acct.name : 'Unknown Account';
}
// Drawer state (simplified - single drawer)
let drawerOpen = $state(false);
let drawerOpen = $state(initialAction === 'create' || !!initialContact);
// Load dropdown options when drawer opens (lazy load)
$effect(() => {
@ -298,9 +317,9 @@
}
});
/** @type {'view' | 'create'} */
let drawerMode = $state('view');
let drawerMode = $state(initialAction === 'create' ? 'create' : 'view');
/** @type {any} */
let selectedContact = $state(null);
let selectedContact = $state(initialContact);
let drawerLoading = $state(false);
let isSubmitting = $state(false);
@ -347,7 +366,8 @@
`${drawerFormData.firstName || ''} ${drawerFormData.lastName || ''}`.trim() || ''
);
// URL sync
// URL sync — handles client-side navigation changes after first paint.
// Initial deep links are seeded synchronously above.
$effect(() => {
const viewId = $page.url.searchParams.get('view');
const action = $page.url.searchParams.get('action');
@ -364,7 +384,7 @@
selectedContact = null;
drawerMode = 'create';
drawerOpen = true;
} else if (viewId && contacts.length > 0) {
} else if (viewId && contacts.length > 0 && !drawerOpen) {
const contact = contacts.find((c) => c.id === viewId);
if (contact) {
selectedContact = contact;
@ -510,8 +530,10 @@
// Contacts are already filtered server-side
const filteredContacts = $derived(contacts);
// Filter panel expansion state
let filtersExpanded = $state(false);
// Active row (highlighted in the table) — seeded from ?view= so the deep-linked
// row is highlighted immediately on first paint.
/** @type {string | null} */
let activeRowId = $state(initialViewId || null);
// Form references for server actions
/** @type {HTMLFormElement} */
@ -724,82 +746,60 @@
<title>Contacts - BottleCRM</title>
</svelte:head>
<PageHeader title="Contacts" subtitle="{filteredContacts.length} of {contacts.length} contacts">
<div class="flex flex-col">
<PageHeader
title="Contacts"
subtitle="{filteredContacts.length} of {contacts.length} contacts"
>
{#snippet actions()}
<div class="flex items-center gap-2">
<!-- Filter Toggle Button -->
<Button
variant={filtersExpanded ? 'secondary' : 'outline'}
size="sm"
class="gap-2"
onclick={() => (filtersExpanded = !filtersExpanded)}
>
<Filter class="h-4 w-4" />
Filters
{#if activeFiltersCount > 0}
<span
class="rounded-full bg-[var(--color-primary-light)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-primary-default)] dark:bg-[var(--color-primary-default)]/15"
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" size="sm" class="gap-2">
<Eye class="h-4 w-4" />
Columns
{#if visibleColumns.length < columns.length}
<span
class="rounded-full bg-[var(--color-primary-light)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-primary-default)] dark:bg-[var(--color-primary-default)]/15"
>
{visibleColumns.length}/{columns.length}
</span>
{/if}
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-48">
<DropdownMenu.Label>Toggle columns</DropdownMenu.Label>
<DropdownMenu.Separator />
{#each columns as column (column.key)}
<DropdownMenu.CheckboxItem
class=""
checked={visibleColumns.includes(column.key)}
onCheckedChange={() => toggleColumn(column.key)}
disabled={column.canHide === false}
>
{activeFiltersCount}
</span>
{/if}
</Button>
<!-- Column Visibility Dropdown -->
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline" size="sm" class="gap-2">
<Eye class="h-4 w-4" />
Columns
{#if visibleColumns.length < columns.length}
<span
class="rounded-full bg-[var(--color-primary-light)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-primary-default)] dark:bg-[var(--color-primary-default)]/15"
>
{visibleColumns.length}/{columns.length}
</span>
{/if}
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-48">
<DropdownMenu.Label>Toggle columns</DropdownMenu.Label>
<DropdownMenu.Separator />
{#each columns as column (column.key)}
<DropdownMenu.CheckboxItem
class=""
checked={visibleColumns.includes(column.key)}
onCheckedChange={() => toggleColumn(column.key)}
disabled={column.canHide === false}
>
{column.label}
</DropdownMenu.CheckboxItem>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button onclick={openCreate}>
<Plus class="mr-2 h-4 w-4" />
New Contact
</Button>
</div>
{column.label}
</DropdownMenu.CheckboxItem>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button onclick={openCreate}>
<Plus class="mr-2 h-4 w-4" />
New Contact
</Button>
{/snippet}
{#snippet tabs()}
<ViewTabs views={[{ id: 'all', label: 'All', count: pagination.total }]} active="all" />
{/snippet}
</PageHeader>
<div class="flex-1">
<!-- Collapsible Filter Bar -->
<FilterBar
minimal={true}
expanded={filtersExpanded}
activeCount={activeFiltersCount}
onClear={clearFilters}
class="pb-4"
>
<FilterStrip>
<SearchInput
value={filters.search}
placeholder="Search contacts..."
onchange={(value) => updateFilters({ ...filters, search: value })}
class="w-64"
class="w-56"
/>
<DateRangeFilter
label="Created"
@ -807,14 +807,19 @@
endDate={filters.created_at_lte}
onchange={(start, end) =>
updateFilters({ ...filters, created_at_gte: start, created_at_lte: end })}
class="w-56"
/>
<TagFilter
tags={data.allTags || []}
value={filters.tags}
onchange={(ids) => updateFilters({ ...filters, tags: ids })}
/>
</FilterBar>
{#if activeFiltersCount > 0}
<FilterPill label="Clear all" dashed onclick={clearFilters} />
{/if}
{#snippet meta()}
<span>{filteredContacts.length} of {contacts.length} contacts</span>
{/snippet}
</FilterStrip>
<!-- Table -->
{#if filteredContacts.length === 0}
@ -832,6 +837,7 @@
data={filteredContacts}
{columns}
bind:visibleColumns
bind:activeRowId
onRowChange={handleRowChange}
onRowClick={(row) => openContact(row)}
>
@ -858,6 +864,9 @@
/>
</div>
<StatusBar status="{filteredContacts.length} of {pagination.total} contacts" />
</div>
<!-- Contact Drawer -->
<CrmDrawer
bind:open={drawerOpen}

View file

@ -4,6 +4,7 @@
import { page } from '$app/stores';
import { toast } from 'svelte-sonner';
import { Plus, Trophy, Target, User, Users, Trash2, X } from '@lucide/svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { SearchInput } from '$lib/components/ui/filter';
import { Pagination } from '$lib/components/ui/pagination';
@ -322,31 +323,24 @@
<title>Sales Goals - BottleCRM</title>
</svelte:head>
<div class="space-y-6 p-6 md:p-8">
<!-- Header -->
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div class="flex items-center gap-3">
<div
class="flex size-10 items-center justify-center rounded-[var(--radius-lg)] bg-gradient-to-br from-amber-500 to-orange-600 shadow-lg"
>
<Trophy class="size-5 text-white" />
</div>
<div>
<h1 class="text-2xl font-bold tracking-tight text-[var(--text-primary)]">Sales Goals</h1>
<p class="text-sm text-[var(--text-secondary)]">
Track targets and measure team performance
</p>
</div>
</div>
<PageHeader
title="Sales Goals"
subtitle="Track targets and measure team performance"
>
{#snippet titleIcon()}
<Trophy class="size-4" />
{/snippet}
{#snippet actions()}
{#if isAdmin}
<Button onclick={openCreateDrawer} class="gap-2">
<Plus class="size-4" />
New Goal
</Button>
{/if}
</div>
{/snippet}
</PageHeader>
<div class="space-y-6 p-6 md:p-8">
<!-- Filter Chips -->
<div class="flex items-center gap-2">
{#each [

View file

@ -2,15 +2,15 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { PageHeader } from '$lib/components/layout';
import { PageHeader, FilterStrip, ViewTabs, StatusBar, FilterPill } from '$lib/components/layout';
import { CrmTable } from '$lib/components/ui/crm-table';
import { FilterBar, SearchInput, DateRangeFilter, SelectFilter } from '$lib/components/ui/filter';
import { SearchInput, DateRangeFilter, SelectFilter } from '$lib/components/ui/filter';
import { Pagination } from '$lib/components/ui/pagination';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { InvoiceFromTimeEntriesDialog } from '$lib/components/invoices';
import { formatCurrency, formatDate } from '$lib/utils/formatting.js';
import { Plus, Filter, Columns3, Clock } from '@lucide/svelte';
import { Plus, Columns3, Clock } from '@lucide/svelte';
let fromTimeEntriesOpen = $state(false);
@ -154,7 +154,6 @@
];
// State
let filtersExpanded = $state(false);
let visibleColumns = $state([...DEFAULT_VISIBLE_COLUMNS]);
let statusChipFilter = $state('ALL');
@ -334,24 +333,6 @@
<div class="bg-border mx-1 h-6 w-px"></div>
<!-- Filters Toggle -->
<Button
variant="outline"
size="sm"
onclick={() => (filtersExpanded = !filtersExpanded)}
class="gap-2"
>
<Filter class="size-4" />
Filters
{#if activeFiltersCount > 0}
<span
class="rounded-full bg-[var(--color-primary-light)] px-2 py-0.5 text-xs text-[var(--color-primary-default)]"
>
{activeFiltersCount}
</span>
{/if}
</Button>
<!-- Column Visibility -->
<DropdownMenu.Root>
<DropdownMenu.Trigger>
@ -392,17 +373,15 @@
</Button>
</div>
{/snippet}
{#snippet tabs()}
<ViewTabs views={[{ id: 'all', label: 'All', count: pagination.total }]} active="all" />
{/snippet}
</PageHeader>
<InvoiceFromTimeEntriesDialog bind:open={fromTimeEntriesOpen} />
<!-- Filter Bar -->
<FilterBar
minimal
expanded={filtersExpanded}
activeCount={activeFiltersCount}
onClear={clearFilters}
>
<!-- Filter Strip -->
<FilterStrip>
<SearchInput
value={filters.search}
placeholder="Search invoices..."
@ -431,7 +410,13 @@
onchange={(start, end) =>
updateFilters({ ...filters, due_date_gte: start, due_date_lte: end })}
/>
</FilterBar>
{#if activeFiltersCount > 0}
<FilterPill label="Clear all" dashed onclick={clearFilters} />
{/if}
{#snippet meta()}
<span>{invoices.length} of {pagination.total} invoices</span>
{/snippet}
</FilterStrip>
<!-- Invoice Table -->
<CrmTable data={invoices} {columns} bind:visibleColumns onRowClick={handleRowClick}>
@ -491,3 +476,5 @@
/>
{/if}
</div>
<StatusBar status="{invoices.length} of {pagination.total} invoices" />

View file

@ -5,6 +5,7 @@
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
@ -18,7 +19,6 @@
import { CURRENCY_CODES } from '$lib/constants/filters.js';
import { orgSettings } from '$lib/stores/org.js';
import {
ArrowLeft,
Plus,
Trash2,
Ban,
@ -239,10 +239,6 @@
a.remove();
}
// Go back
function goBack() {
goto('/invoices');
}
// Change invoice status
async function handleStatusChange(newStatus) {
@ -358,110 +354,112 @@
}}
></form>
<div class="bg-muted/30 min-h-screen print:bg-white">
<!-- Header (hidden when printing) -->
<div class="bg-background sticky top-0 z-10 border-b print:hidden">
<div class="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<div class="flex items-center gap-4">
<Button variant="ghost" size="icon" onclick={goBack}>
<ArrowLeft class="size-5" />
<div class="print:hidden">
<PageHeader
title={invoice.invoiceNumber || 'Invoice'}
subtitle={invoice.clientName || 'No client name'}
breadcrumb={[
{ label: 'Invoices', href: '/invoices' },
{ label: invoice.invoiceNumber || 'Invoice' }
]}
>
{#snippet meta()}
<Select.Root type="single" value={invoice.status} onValueChange={handleStatusChange}>
<Select.Trigger
class="h-7 w-auto gap-1 border-0 px-2 text-xs font-medium {STATUS_COLORS[
invoice.status
] || STATUS_COLORS.Draft}"
>
{(invoice.status || 'Draft').replace('_', ' ')}
</Select.Trigger>
<Select.Content>
{#each INVOICE_STATUS_OPTIONS as option}
<Select.Item value={option.value}>
<span class="flex items-center gap-2">
<span class="size-2 rounded-full {STATUS_COLORS[option.value]?.split(' ')[0]}"
></span>
{option.label}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/snippet}
{#snippet amount()}
<span class="text-lg font-semibold text-[color:var(--text-primary)]">
{formatCurrency(Number(invoice.total || total), invoice.currency)}
</span>
{/snippet}
{#snippet actions()}
{#if isEditing}
<Button variant="outline" onclick={cancelEdit} disabled={isSaving}>Cancel</Button>
<Button onclick={saveChanges} disabled={isSaving}>
<Save class="mr-2 size-4" />
Save Changes
</Button>
<div>
<div class="flex items-center gap-3">
<h1 class="text-xl font-semibold">{invoice.invoiceNumber || 'Invoice'}</h1>
<Select.Root type="single" value={invoice.status} onValueChange={handleStatusChange}>
<Select.Trigger
class="h-7 w-auto gap-1 border-0 px-2 text-xs font-medium {STATUS_COLORS[
invoice.status
] || STATUS_COLORS.Draft}"
>
{(invoice.status || 'Draft').replace('_', ' ')}
</Select.Trigger>
<Select.Content>
{#each INVOICE_STATUS_OPTIONS as option}
<Select.Item value={option.value}>
<span class="flex items-center gap-2">
<span class="size-2 rounded-full {STATUS_COLORS[option.value]?.split(' ')[0]}"
></span>
{option.label}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<p class="text-muted-foreground text-sm">{invoice.clientName || 'No client name'}</p>
</div>
</div>
<div class="flex items-center gap-3">
{#if isEditing}
<Button variant="outline" onclick={cancelEdit} disabled={isSaving}>Cancel</Button>
<Button onclick={saveChanges} disabled={isSaving}>
<Save class="mr-2 size-4" />
Save Changes
{:else}
<Button variant="outline" onclick={printInvoice}>
<Printer class="mr-2 size-4" />
Print
</Button>
<Button variant="outline" onclick={downloadPDF} disabled={isDownloadingPdf}>
<Download class="mr-2 size-4" />
{isDownloadingPdf ? 'Downloading...' : 'PDF'}
</Button>
{#if invoice.status === 'Draft'}
<Button variant="outline" onclick={handleSend} disabled={isSaving}>
<Send class="mr-2 size-4" />
Send
</Button>
{:else}
<Button variant="outline" onclick={printInvoice}>
<Printer class="mr-2 size-4" />
Print
</Button>
<Button variant="outline" onclick={downloadPDF} disabled={isDownloadingPdf}>
<Download class="mr-2 size-4" />
{isDownloadingPdf ? 'Downloading...' : 'PDF'}
</Button>
{#if invoice.status === 'Draft'}
<Button variant="outline" onclick={handleSend} disabled={isSaving}>
<Send class="mr-2 size-4" />
Send
</Button>
{/if}
<Button onclick={() => (currentTab = 'edit')}>
<Pencil class="mr-2 size-4" />
Edit
</Button>
{#if invoice.status !== 'Cancelled' && invoice.status !== 'Paid'}
<AlertDialog.Root bind:open={cancelDialogOpen}>
<AlertDialog.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
size="icon"
class="text-destructive"
title="Cancel Invoice"
>
<Ban class="size-4" />
</Button>
{/snippet}
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Cancel Invoice?</AlertDialog.Title>
<AlertDialog.Description>
This will cancel invoice {invoice.invoiceNumber}. Cancelled invoices cannot be
sent or edited.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Keep Invoice</AlertDialog.Cancel>
<Button
variant="destructive"
onclick={() => {
cancelDialogOpen = false;
cancelForm.requestSubmit();
}}
>
Cancel Invoice
</Button>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
{/if}
{/if}
</div>
</div>
</div>
<Button onclick={() => (currentTab = 'edit')}>
<Pencil class="mr-2 size-4" />
Edit
</Button>
{#if invoice.status !== 'Cancelled' && invoice.status !== 'Paid'}
<AlertDialog.Root bind:open={cancelDialogOpen}>
<AlertDialog.Trigger>
{#snippet child({ props })}
<Button
{...props}
variant="outline"
size="icon"
class="text-destructive"
title="Cancel Invoice"
>
<Ban class="size-4" />
</Button>
{/snippet}
</AlertDialog.Trigger>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Cancel Invoice?</AlertDialog.Title>
<AlertDialog.Description>
This will cancel invoice {invoice.invoiceNumber}. Cancelled invoices cannot be
sent or edited.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Keep Invoice</AlertDialog.Cancel>
<Button
variant="destructive"
onclick={() => {
cancelDialogOpen = false;
cancelForm.requestSubmit();
}}
>
Cancel Invoice
</Button>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
{/if}
{/if}
{/snippet}
</PageHeader>
</div>
<div class="bg-muted/30 min-h-screen print:bg-white">
<!-- Invoice Content -->
<div class="mx-auto max-w-6xl px-6 py-8 print:max-w-none print:px-0 print:py-0">
{#if currentTab === 'view'}

View file

@ -5,6 +5,7 @@
import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { Textarea } from '$lib/components/ui/textarea';
@ -15,7 +16,6 @@
import { CURRENCY_CODES } from '$lib/constants/filters.js';
import { orgSettings } from '$lib/stores/org.js';
import {
ArrowLeft,
Plus,
Trash2,
Send,
@ -227,10 +227,6 @@
createForm.requestSubmit();
}
// Go back to list
function goBack() {
goto('/invoices');
}
</script>
<svelte:head>
@ -287,32 +283,24 @@
/>
</form>
<div class="bg-muted/30 min-h-screen">
<!-- Header -->
<div class="bg-background sticky top-0 z-10 border-b">
<div class="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<div class="flex items-center gap-4">
<Button variant="ghost" size="icon" onclick={goBack}>
<ArrowLeft class="size-5" />
</Button>
<div>
<h1 class="text-xl font-semibold">New Invoice</h1>
<p class="text-muted-foreground text-sm">Create a new invoice for your client</p>
</div>
</div>
<div class="flex items-center gap-3">
<Button variant="outline" onclick={saveDraft} disabled={isSaving}>
<Save class="mr-2 size-4" />
Save Draft
</Button>
<Button onclick={sendInvoice} disabled={isSaving}>
<Send class="mr-2 size-4" />
Create Invoice
</Button>
</div>
</div>
</div>
<PageHeader
title="New Invoice"
subtitle="Create a new invoice for your client"
breadcrumb={[{ label: 'Invoices', href: '/invoices' }, { label: 'New Invoice' }]}
>
{#snippet actions()}
<Button variant="outline" onclick={saveDraft} disabled={isSaving}>
<Save class="mr-2 size-4" />
Save Draft
</Button>
<Button onclick={sendInvoice} disabled={isSaving}>
<Send class="mr-2 size-4" />
Create Invoice
</Button>
{/snippet}
</PageHeader>
<div class="bg-muted/30 min-h-screen">
<!-- Invoice Editor -->
<div class="mx-auto max-w-6xl px-6 py-8">
<div class="bg-background rounded-lg border shadow-sm">

View file

@ -229,11 +229,10 @@ export async function load({ url, cookies, locals }) {
},
// Pre-loaded form options for drawer (avoids client-side auth issues)
formOptions: {
users: users.map((/** @type {any} */ u) => ({
id: u.id,
email: u.user?.email || u.email,
name: u.user?.email || u.email
})),
users: users.map((/** @type {any} */ u) => {
const email = u.user_details?.email || u.user?.email || u.email;
return { id: u.id, email, name: email };
}),
teams: teams.map((/** @type {any} */ t) => ({
id: t.id,
name: t.name

View file

@ -26,12 +26,12 @@
Loader2,
ArrowRightCircle,
Banknote,
Filter
AlertTriangle,
RotateCw
} from '@lucide/svelte';
import { LinkedinIcon as Linkedin } from '$lib/components/icons';
import { page } from '$app/stores';
import {
FilterBar,
SearchInput,
SelectFilter,
DateRangeFilter,
@ -39,11 +39,11 @@
} from '$lib/components/ui/filter';
import { Pagination } from '$lib/components/ui/pagination';
import { Button } from '$lib/components/ui/button/index.js';
import { PageHeader } from '$lib/components/layout';
import { PageHeader, FilterStrip, ViewTabs, StatusBar, FilterPill } from '$lib/components/layout';
import { INDUSTRIES, COUNTRIES } from '$lib/constants/lead-choices.js';
import { CURRENCY_CODES } from '$lib/constants/filters.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { formatRelativeDate, formatDate, getNameInitials } from '$lib/utils/formatting.js';
import { formatRelativeDate, formatDate, getNameInitials, getInitials } from '$lib/utils/formatting.js';
import {
leadStatusOptions,
leadRatingOptions,
@ -76,6 +76,7 @@
type: 'text',
width: 'w-[200px]',
canHide: false,
editable: false,
emptyText: 'Untitled'
},
{
@ -102,6 +103,7 @@
label: 'Email',
type: 'email',
width: 'w-52',
editable: false,
emptyText: ''
},
{
@ -132,6 +134,7 @@
type: 'text',
width: 'w-36',
canHide: true,
editable: false,
emptyText: ''
},
{
@ -140,6 +143,7 @@
type: 'text',
width: 'w-36',
canHide: true,
editable: false,
emptyText: ''
},
{
@ -579,6 +583,7 @@
);
let formOptionsLoaded = $state(false);
let formOptionsLoading = $state(false);
let formOptionsError = $state('');
/**
* Load form options for drawer - uses pre-loaded server data
@ -587,6 +592,7 @@
if (formOptionsLoaded || formOptionsLoading) return;
formOptionsLoading = true;
formOptionsError = '';
try {
// Use pre-loaded data from server (avoids client-side auth issues)
const serverFormOptions = data.formOptions || { users: [], teams: [], contacts: [] };
@ -616,17 +622,46 @@
formOptionsLoaded = true;
} catch (err) {
console.error('Failed to load form options:', err);
formOptionsError = 'Couldn’t load options';
} finally {
formOptionsLoading = false;
}
}
function retryLoadFormOptions() {
formOptionsLoaded = false;
formOptionsLoading = false;
formOptionsError = '';
loadFormOptions();
}
// Read the URL synchronously at init so deep links (?view=ID / ?action=create)
// prime the drawer state on the first frame instead of waiting for a $effect tick.
const initialUrlParams = $page.url.searchParams;
const initialViewId = initialUrlParams.get('view');
const initialAction = initialUrlParams.get('action');
const initialLead = initialViewId
? (data.leads || []).find((/** @type {any} */ l) => l.id === initialViewId) || null
: null;
/** @type {string | null} */
let activeRowId = $state(initialViewId || null);
// Drawer state (NotionDrawer for view/create)
let drawerOpen = $state(false);
let drawerOpen = $state(initialAction === 'create' || !!initialLead);
/** @type {'view' | 'create'} */
let drawerMode = $state(/** @type {'view' | 'create'} */ ('view'));
let drawerMode = $state(initialAction === 'create' ? 'create' : 'view');
let drawerLoadError = $state('');
/** @type {any} */
let drawerData = $state(null);
let drawerLoadErrorLead = $state(null);
/** @type {any} */
let drawerData = $state(initialLead);
// If we're opening the drawer from a deep link, kick off the form-option load
// up front so multi-selects have data when the drawer mounts.
if (drawerOpen) {
loadFormOptions();
}
let drawerLoading = $state(false);
let isSaving = $state(false);
let currentUser = $state(null);
@ -672,10 +707,19 @@
// Drawer columns with dynamic options for multi-selects
const drawerColumnsWithOptions = $derived(
drawerColumns.map((col) => {
if (col.key === 'assignedTo') return { ...col, options: formOptions.users || [] };
if (col.key === 'teams') return { ...col, options: formOptions.teamsList || [] };
if (col.key === 'contacts') return { ...col, options: formOptions.contactsList || [] };
if (col.key === 'tags') return { ...col, options: formOptions.tagsList || [] };
const multiselectExtras = {
loading: formOptionsLoading,
loadingError: formOptionsError,
onRetry: retryLoadFormOptions
};
if (col.key === 'assignedTo')
return { ...col, options: formOptions.users || [], ...multiselectExtras };
if (col.key === 'teams')
return { ...col, options: formOptions.teamsList || [], ...multiselectExtras };
if (col.key === 'contacts')
return { ...col, options: formOptions.contactsList || [], ...multiselectExtras };
if (col.key === 'tags')
return { ...col, options: formOptions.tagsList || [], ...multiselectExtras };
return col;
})
);
@ -791,6 +835,8 @@
async function openLead(lead, fromKanban = false) {
drawerMode = 'view';
drawerOpen = true;
drawerLoadError = '';
drawerLoadErrorLead = null;
updateUrl(lead.id, null);
// Load form options (uses pre-loaded server data)
loadFormOptions();
@ -829,7 +875,8 @@
} catch (err) {
console.error('Failed to fetch lead details:', err);
toast.error('Failed to load lead details');
// Fall back to what we have (minimal kanban data transformed)
// Fall back to what we have (minimal kanban data transformed) and
// surface a banner so the user knows fields below are incomplete
drawerData = {
id: lead.id,
title: lead.title || lead.full_name,
@ -840,6 +887,8 @@
currency: lead.currency,
status: lead.status ? lead.status.toUpperCase().replace(/ /g, '_') : 'ASSIGNED'
};
drawerLoadError = 'Couldn’t load full details. Showing limited data.';
drawerLoadErrorLead = lead;
} finally {
drawerLoading = false;
}
@ -1175,9 +1224,6 @@
// Status chip filter state (quick filter from UI)
let statusChipFilter = $state('ALL');
// Filter panel expansion state
let filtersExpanded = $state(false);
// Leads are already filtered server-side, just apply chip filter if active
const filteredLeads = $derived.by(() => {
let filtered = leads;
@ -1439,7 +1485,8 @@
<title>Leads - BottleCRM</title>
</svelte:head>
<PageHeader
<div class="flex flex-col">
<PageHeader
title="Leads"
subtitle="{viewMode === 'kanban'
? totalLeadCount
@ -1509,24 +1556,6 @@
<div class="mx-1 h-6 w-px bg-[var(--border-default)]"></div>
<!-- Filter Toggle Button -->
<Button
variant={filtersExpanded ? 'secondary' : 'outline'}
size="sm"
class="gap-2"
onclick={() => (filtersExpanded = !filtersExpanded)}
>
<Filter class="h-4 w-4" />
Filters
{#if activeFiltersCount > 0}
<span
class="rounded-full bg-[var(--color-primary-light)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-primary-default)] dark:bg-[var(--color-primary-default)]/15"
>
{activeFiltersCount}
</span>
{/if}
</Button>
<!-- Column Visibility (only for table view) -->
{#if viewMode === 'table'}
<DropdownMenu.Root>
@ -1568,17 +1597,13 @@
</Button>
</div>
{/snippet}
{#snippet tabs()}
<ViewTabs views={[{ id: 'all', label: 'All', count: totalLeadCount }]} active="all" />
{/snippet}
</PageHeader>
<div class="flex-1">
<!-- Collapsible Filter Bar -->
<FilterBar
minimal={true}
expanded={filtersExpanded}
activeCount={activeFiltersCount}
onClear={clearFilters}
class="pb-4"
>
<FilterStrip>
<SearchInput
value={filters.search}
placeholder="Search leads..."
@ -1613,7 +1638,13 @@
value={filters.tags}
onchange={(ids) => updateFilters({ ...filters, tags: ids })}
/>
</FilterBar>
{#if activeFiltersCount > 0}
<FilterPill label="Clear all" dashed onclick={clearFilters} />
{/if}
{#snippet meta()}
<span>{filteredLeads.length} of {pagination.total} leads</span>
{/snippet}
</FilterStrip>
<!-- Content: Table or Kanban -->
{#if viewMode === 'kanban'}
@ -1642,6 +1673,7 @@
data={filteredLeads}
{columns}
bind:visibleColumns
bind:activeRowId
onRowChange={handleRowChange}
onRowClick={(row) => openLead(row)}
>
@ -1729,6 +1761,9 @@
{/if}
</div>
<StatusBar status="{filteredLeads.length} of {pagination.total} leads" />
</div>
<!-- Lead Drawer -->
<CrmDrawer
bind:open={drawerOpen}
@ -1740,10 +1775,67 @@
headerLabel={drawerMode === 'create' ? 'New Lead' : 'Lead'}
mode={drawerMode}
loading={drawerLoading}
fullPageHref={drawerMode !== 'create' && drawerData?.id ? `/leads/${drawerData.id}` : ''}
onFieldChange={handleDrawerFieldChange}
onDelete={drawerMode !== 'create' ? handleDrawerDelete : undefined}
onClose={closeDrawer}
>
{#snippet banner()}
{#if drawerLoadError}
<div
role="alert"
class="mx-4 mt-3 flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-[12px] text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/40 dark:text-amber-200"
>
<AlertTriangle class="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
<div class="flex-1">
<p class="font-medium">{drawerLoadError}</p>
{#if drawerLoadErrorLead}
<button
type="button"
onclick={() => drawerLoadErrorLead && openLead(drawerLoadErrorLead, true)}
class="mt-1 inline-flex items-center gap-1 text-[11.5px] font-semibold underline-offset-2 hover:underline"
>
<RotateCw class="size-3" aria-hidden="true" />
Retry
</button>
{/if}
</div>
</div>
{/if}
{/snippet}
{#snippet metaSection()}
{#if drawerData}
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-[12px] text-[color:var(--text-muted)]">
{#if drawerData.owner}
<div class="flex items-center gap-1.5">
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-[color:var(--color-primary-light)] text-[10px] font-semibold text-[color:var(--color-primary-default)]"
aria-hidden="true"
>
{getInitials(drawerData.owner.name || drawerData.owner.email) || '?'}
</span>
<span class="text-[color:var(--text)] font-medium">
{drawerData.owner.name || drawerData.owner.email}
</span>
</div>
{/if}
{#if drawerData.createdAt}
<span aria-hidden="true">·</span>
<span title={new Date(drawerData.createdAt).toLocaleString()}>
Created {formatDate(drawerData.createdAt)}
</span>
{/if}
{#if drawerData.updatedAt && drawerData.updatedAt !== drawerData.createdAt}
<span aria-hidden="true">·</span>
<span title={new Date(drawerData.updatedAt).toLocaleString()}>
Updated {formatRelativeDate(drawerData.updatedAt)}
</span>
{/if}
</div>
{/if}
{/snippet}
{#snippet activitySection()}
{#if drawerMode !== 'create' && drawerData}
<CommentSection

View file

@ -0,0 +1,43 @@
/**
* Lead Detail Page - Server Load
*
* Django endpoint: GET /api/leads/<id>/
* Response shape: { lead_obj, attachments, comments, users_mention, assigned_data,
* users, users_excluding_team, source, status, teams, countries }
* (see backend/leads/views/lead_views.py LeadDetailView.get_context_data)
*/
import { error } from '@sveltejs/kit';
import { apiRequest } from '$lib/api-helpers.js';
/** @type {import('./$types').PageServerLoad} */
export async function load({ params, locals, cookies }) {
const org = locals.org;
if (!org) {
throw error(401, 'Organization context required');
}
try {
const response = await apiRequest(`/leads/${params.id}/`, {}, { cookies, org });
if (response?.error) {
throw error(404, response.errors || 'Lead not found');
}
// Django LeadDetailView returns the lead under `lead_obj` (see backend/leads/views/lead_views.py).
const lead = response.lead_obj || response.lead || response;
return {
lead,
comments: response.comments || [],
attachments: response.attachments || [],
tags: response.tags || lead?.tags || [],
users: response.users || [],
commentPermission: response.comment_permission || false
};
} catch (err) {
if (/** @type {any} */ (err)?.status) throw err;
console.error('Failed to load lead detail:', err);
throw error(500, 'Failed to load lead');
}
}

View file

@ -0,0 +1,732 @@
<script>
import { goto } from '$app/navigation';
import {
Pencil,
Mail,
Phone,
MoreHorizontal,
Paperclip,
MessageSquare,
Calendar,
ArrowRightCircle,
Globe,
Briefcase,
Building2,
DollarSign,
Target,
Star,
MapPin,
Users,
UserCheck,
FileText,
ExternalLink
} from '@lucide/svelte';
import { LinkedinIcon as Linkedin } from '$lib/components/icons';
import { PageHeader } from '$lib/components/layout';
import { StageStepper } from '$lib/components/ui/stage-stepper';
import { Timeline, TimelineItem } from '$lib/components/ui/timeline';
import * as Card from '$lib/components/ui/card/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import {
formatRelativeDate,
formatDate,
formatCurrency,
getNameInitials
} from '$lib/utils/formatting.js';
import {
leadStatusOptions,
leadRatingOptions,
getOptionLabel,
getOptionStyle
} from '$lib/utils/table-helpers.js';
import { INDUSTRIES } from '$lib/constants/lead-choices.js';
import { getCountryName } from '$lib/constants/countries.js';
/** @type {{ data: { lead: any, comments: any[], attachments: any[], tags: any[], users: any[], commentPermission: boolean } }} */
let { data } = $props();
const lead = $derived(data.lead || {});
const comments = $derived(data.comments || []);
const attachments = $derived(data.attachments || []);
const tags = $derived(data.tags || []);
let tab = $state('overview');
// Normalize backend status ("in process", "assigned") to leadStatusOptions value format ("IN_PROCESS")
const normalizedStatus = $derived(
(lead?.status || '').toString().toUpperCase().replace(/\s+/g, '_')
);
const normalizedRating = $derived((lead?.rating || '').toString().toUpperCase());
const stepperStages = $derived(
leadStatusOptions.map((s) => ({ value: s.value, label: s.label }))
);
const fullName = $derived(
[lead?.salutation, lead?.first_name, lead?.last_name]
.filter(Boolean)
.join(' ')
.trim() ||
lead?.email ||
'Lead'
);
const sourceLabel = $derived(
lead?.source
? lead.source.replace(/\b\w/g, (/** @type {string} */ c) => c.toUpperCase())
: ''
);
const industryLabel = $derived(
INDUSTRIES.find((i) => i.value === lead?.industry)?.label || lead?.industry || ''
);
/** @typedef {{ id: string, email: string }} AssignedUser */
/** @type {AssignedUser[]} */
const assignedUsers = $derived(
Array.isArray(lead?.assigned_to)
? lead.assigned_to
.map((/** @type {any} */ p) => ({
id: p?.id,
email: p?.user_details?.email || p?.user?.email || ''
}))
.filter((/** @type {AssignedUser} */ u) => u.email)
: []
);
/** @type {{ id: string, name: string }[]} */
const teams = $derived(
Array.isArray(lead?.teams)
? lead.teams.map((/** @type {any} */ t) => ({ id: t?.id, name: t?.name || '' }))
: []
);
const probability = $derived(
typeof lead?.probability === 'number' ? Math.max(0, Math.min(100, lead.probability)) : null
);
const hasDeal = $derived(
lead?.opportunity_amount != null || probability !== null || !!lead?.close_date
);
const hasContactLinks = $derived(
!!(lead?.email || lead?.phone || lead?.website || lead?.linkedin_url || lead?.job_title)
);
const hasAddress = $derived(
!!(lead?.address_line || lead?.city || lead?.state || lead?.postcode || lead?.country)
);
const addressLines = $derived(
[
lead?.address_line,
[lead?.city, lead?.state, lead?.postcode].filter(Boolean).join(', '),
getCountryName(lead?.country)
].filter(Boolean)
);
function normalizeUrl(/** @type {string} */ raw) {
if (!raw) return '';
return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
}
// Timeline items: comments + attachments + created event, sorted DESC by timestamp.
const timelineItems = $derived.by(() => {
/** @type {Array<{ id: string, ts: string, kind: 'comment' | 'attachment' | 'created', payload: any }>} */
const items = [];
for (const c of comments) {
items.push({ id: `comment-${c.id}`, ts: c.commented_on || '', kind: 'comment', payload: c });
}
for (const a of attachments) {
items.push({
id: `attachment-${a.id}`,
ts: a.created_on || a.created_at || '',
kind: 'attachment',
payload: a
});
}
if (lead?.created_at || lead?.created_on) {
items.push({
id: `created-${lead.id}`,
ts: lead.created_at || lead.created_on,
kind: 'created',
payload: lead
});
}
return items.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
});
</script>
<svelte:head>
<title>{fullName} · BottleCRM</title>
</svelte:head>
<PageHeader
title={fullName}
subtitle={lead?.title || ''}
breadcrumb={[{ label: 'Leads', href: '/leads' }, { label: fullName }]}
>
{#snippet meta()}
<div
class="flex flex-wrap items-center gap-3 text-[12px] leading-none text-[color:var(--text-subtle)]"
>
{#if lead?.created_at || lead?.created_on}
<span>Created {formatRelativeDate(lead.created_at || lead.created_on)}</span>
{/if}
{#if lead?.company_name}
<span>·</span>
<span class="flex items-center gap-1">
<Building2 class="size-3" />
{lead.company_name}
</span>
{/if}
{#if assignedUsers.length > 0}
<span>·</span>
<span class="flex items-center gap-1.5">
<span
class="flex size-4 items-center justify-center rounded-full bg-[color:var(--bg-elevated)] text-[9px] font-medium text-[color:var(--text-muted)]"
>
{getNameInitials(assignedUsers[0].email, '')}
</span>
{assignedUsers[0].email}{#if assignedUsers.length > 1}
<span class="text-[color:var(--text-subtle)]">+{assignedUsers.length - 1}</span>
{/if}
</span>
{/if}
</div>
{/snippet}
{#snippet actions()}
<div class="flex items-center gap-1.5">
{#if lead?.email}
<Button variant="ghost" size="icon" aria-label="Email" href="mailto:{lead.email}">
<Mail class="size-4" />
</Button>
{/if}
{#if lead?.phone}
<Button variant="ghost" size="icon" aria-label="Call" href="tel:{lead.phone}">
<Phone class="size-4" />
</Button>
{/if}
<Button variant="ghost" size="icon" aria-label="More"><MoreHorizontal class="size-4" /></Button>
<Button
variant="outline"
size="sm"
onclick={() => lead?.id && goto(`/leads/${lead.id}/edit`)}
>
<Pencil class="mr-1.5 size-3.5" /> Edit
</Button>
<Button variant="default" size="sm">
<ArrowRightCircle class="mr-1.5 size-3.5" /> Convert
</Button>
</div>
{/snippet}
</PageHeader>
<!-- Stage stepper -->
<div class="px-7 pb-3 md:px-8">
<StageStepper stages={stepperStages} current={normalizedStatus} />
</div>
<!-- Tabs -->
<Tabs.Root bind:value={tab} class="px-7 md:px-8">
<Tabs.List class="">
<Tabs.Trigger class="" value="overview">Overview</Tabs.Trigger>
<Tabs.Trigger class="" value="activity">
Activity
<span
class="ml-1.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-[color:var(--bg-elevated)] px-1 text-[10px] tabular-nums text-[color:var(--text-subtle)]"
>
{timelineItems.length}
</span>
</Tabs.Trigger>
<Tabs.Trigger class="" value="files">
Files
<span
class="ml-1.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-[color:var(--bg-elevated)] px-1 text-[10px] tabular-nums text-[color:var(--text-subtle)]"
>
{attachments.length}
</span>
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content class="" value="overview">
<div class="grid grid-cols-1 gap-6 pt-4 pb-8 lg:grid-cols-[1fr_320px]">
<!-- Main column -->
<div class="flex flex-col gap-6">
<!-- About card -->
<Card.Root>
<Card.Header>
<Card.Title>About</Card.Title>
</Card.Header>
<Card.Content>
{#if lead?.description}
<p class="text-[13px] leading-[1.6] whitespace-pre-wrap text-[color:var(--text-muted)]">
{lead.description}
</p>
{:else}
<p class="text-[12.5px] italic text-[color:var(--text-subtle)]">No description.</p>
{/if}
</Card.Content>
</Card.Root>
<!-- Deal card -->
{#if hasDeal}
<Card.Root>
<Card.Header><Card.Title>Deal</Card.Title></Card.Header>
<Card.Content>
<dl class="grid grid-cols-2 gap-x-6 gap-y-3 text-[12.5px] sm:grid-cols-3">
<div class="flex flex-col gap-1">
<dt class="flex items-center gap-1 text-[11px] text-[color:var(--text-subtle)]">
<DollarSign class="size-3" /> Deal Value
</dt>
<dd class="text-[15px] font-medium tabular-nums text-[color:var(--text)]">
{lead?.opportunity_amount != null
? formatCurrency(lead.opportunity_amount, lead?.currency || 'USD')
: '—'}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="flex items-center gap-1 text-[11px] text-[color:var(--text-subtle)]">
<Target class="size-3" /> Probability
</dt>
<dd class="text-[color:var(--text)]">
{#if probability !== null}
<div class="flex items-center gap-2">
<span class="text-[13px] tabular-nums">{probability}%</span>
<div
class="h-1.5 flex-1 overflow-hidden rounded-full bg-[color:var(--bg-elevated)]"
>
<div
class="h-full rounded-full bg-[color:var(--color-primary-default)]"
style="width: {probability}%"
></div>
</div>
</div>
{:else}
<span class="text-[color:var(--text-muted)]">—</span>
{/if}
</dd>
</div>
<div class="flex flex-col gap-1">
<dt class="flex items-center gap-1 text-[11px] text-[color:var(--text-subtle)]">
<Calendar class="size-3" /> Close Date
</dt>
<dd class="text-[13px] text-[color:var(--text)]">
{lead?.close_date ? formatDate(lead.close_date) : '—'}
</dd>
</div>
</dl>
</Card.Content>
</Card.Root>
{/if}
<!-- Contact info card -->
{#if hasContactLinks}
<Card.Root>
<Card.Header><Card.Title>Contact</Card.Title></Card.Header>
<Card.Content>
<dl class="grid grid-cols-1 gap-y-3 text-[12.5px] sm:grid-cols-2">
{#if lead?.email}
<div class="flex items-start gap-2">
<Mail
class="mt-0.5 size-3.5 shrink-0 text-[color:var(--text-subtle)]"
aria-hidden="true"
/>
<div class="flex min-w-0 flex-col">
<dt class="text-[11px] text-[color:var(--text-subtle)]">Email</dt>
<dd>
<a
href="mailto:{lead.email}"
class="truncate text-[color:var(--color-primary-default)] hover:underline"
>
{lead.email}
</a>
</dd>
</div>
</div>
{/if}
{#if lead?.phone}
<div class="flex items-start gap-2">
<Phone
class="mt-0.5 size-3.5 shrink-0 text-[color:var(--text-subtle)]"
aria-hidden="true"
/>
<div class="flex min-w-0 flex-col">
<dt class="text-[11px] text-[color:var(--text-subtle)]">Phone</dt>
<dd>
<a
href="tel:{lead.phone}"
class="truncate text-[color:var(--color-primary-default)] hover:underline"
>
{lead.phone}
</a>
</dd>
</div>
</div>
{/if}
{#if lead?.job_title}
<div class="flex items-start gap-2">
<Briefcase
class="mt-0.5 size-3.5 shrink-0 text-[color:var(--text-subtle)]"
aria-hidden="true"
/>
<div class="flex min-w-0 flex-col">
<dt class="text-[11px] text-[color:var(--text-subtle)]">Job Title</dt>
<dd class="truncate text-[color:var(--text)]">{lead.job_title}</dd>
</div>
</div>
{/if}
{#if lead?.website}
<div class="flex items-start gap-2">
<Globe
class="mt-0.5 size-3.5 shrink-0 text-[color:var(--text-subtle)]"
aria-hidden="true"
/>
<div class="flex min-w-0 flex-col">
<dt class="text-[11px] text-[color:var(--text-subtle)]">Website</dt>
<dd>
<a
href={normalizeUrl(lead.website)}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 truncate text-[color:var(--color-primary-default)] hover:underline"
>
{lead.website}
<ExternalLink class="size-3 shrink-0" aria-hidden="true" />
</a>
</dd>
</div>
</div>
{/if}
{#if lead?.linkedin_url}
<div class="flex items-start gap-2">
<Linkedin
class="mt-0.5 size-3.5 shrink-0 text-[color:var(--text-subtle)]"
aria-hidden="true"
/>
<div class="flex min-w-0 flex-col">
<dt class="text-[11px] text-[color:var(--text-subtle)]">LinkedIn</dt>
<dd>
<a
href={normalizeUrl(lead.linkedin_url)}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 truncate text-[color:var(--color-primary-default)] hover:underline"
>
{lead.linkedin_url}
<ExternalLink class="size-3 shrink-0" aria-hidden="true" />
</a>
</dd>
</div>
</div>
{/if}
</dl>
</Card.Content>
</Card.Root>
{/if}
<!-- Address card -->
{#if hasAddress}
<Card.Root>
<Card.Header><Card.Title>Address</Card.Title></Card.Header>
<Card.Content>
<div class="flex items-start gap-2 text-[12.5px]">
<MapPin
class="mt-0.5 size-3.5 shrink-0 text-[color:var(--text-subtle)]"
aria-hidden="true"
/>
<address class="flex flex-col gap-0.5 not-italic text-[color:var(--text-muted)]">
{#each addressLines as line}
<span>{line}</span>
{/each}
</address>
</div>
</Card.Content>
</Card.Root>
{/if}
<!-- Activity timeline card -->
<Card.Root>
<Card.Header>
<Card.Title>Activity</Card.Title>
</Card.Header>
<Card.Content class="px-4 py-2">
<Timeline isEmpty={timelineItems.length === 0}>
{#each timelineItems as item (item.id)}
{#if item.kind === 'comment'}
<TimelineItem
variant="violet"
time={item.ts ? formatRelativeDate(item.ts) : ''}
quote={item.payload.comment || ''}
>
{#snippet icon()}<MessageSquare class="size-3.5" />{/snippet}
{#snippet text()}
<strong>{item.payload.commented_by_user || 'Someone'}</strong> commented
{/snippet}
</TimelineItem>
{:else if item.kind === 'attachment'}
<TimelineItem time={item.ts ? formatRelativeDate(item.ts) : ''}>
{#snippet icon()}<Paperclip class="size-3.5" />{/snippet}
{#snippet text()}
<strong>{item.payload.created_by_user || 'Someone'}</strong> uploaded
<strong>{item.payload.file_name || 'a file'}</strong>
{/snippet}
</TimelineItem>
{:else}
<TimelineItem
variant="success"
time={item.ts ? formatRelativeDate(item.ts) : ''}
>
{#snippet icon()}<Calendar class="size-3.5" />{/snippet}
{#snippet text()}Lead created{/snippet}
</TimelineItem>
{/if}
{/each}
</Timeline>
</Card.Content>
</Card.Root>
</div>
<!-- Right rail -->
<div class="flex flex-col gap-6">
<Card.Root>
<Card.Header><Card.Title>Details</Card.Title></Card.Header>
<Card.Content>
<dl class="grid grid-cols-1 gap-y-3 text-[12.5px]">
<div class="flex items-baseline justify-between gap-3">
<dt class="text-[color:var(--text-subtle)]">Status</dt>
<dd>
{#if normalizedStatus}
<Badge
variant="secondary"
class={getOptionStyle(normalizedStatus, leadStatusOptions)}
>
{getOptionLabel(normalizedStatus, leadStatusOptions)}
</Badge>
{:else}
<span class="text-[color:var(--text-muted)]">—</span>
{/if}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="text-[color:var(--text-subtle)]">Rating</dt>
<dd>
{#if normalizedRating}
<Badge
variant="secondary"
class={getOptionStyle(normalizedRating, leadRatingOptions)}
>
<Star class="mr-1 size-3" />
{getOptionLabel(normalizedRating, leadRatingOptions)}
</Badge>
{:else}
<span class="text-[color:var(--text-muted)]">—</span>
{/if}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="text-[color:var(--text-subtle)]">Source</dt>
<dd class="truncate text-right text-[color:var(--text-muted)]">
{sourceLabel || '—'}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="text-[color:var(--text-subtle)]">Industry</dt>
<dd class="truncate text-right text-[color:var(--text-muted)]">
{industryLabel || '—'}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="text-[color:var(--text-subtle)]">Company</dt>
<dd class="truncate text-right text-[color:var(--text-muted)]">
{lead?.company_name || '—'}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="text-[color:var(--text-subtle)]">Created by</dt>
<dd class="truncate text-right text-[color:var(--text-muted)]">
{lead?.created_by?.email || '—'}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="text-[color:var(--text-subtle)]">Updated</dt>
<dd class="truncate text-right text-[color:var(--text-muted)]">
{lead?.updated_at ? formatRelativeDate(lead.updated_at) : '—'}
</dd>
</div>
</dl>
</Card.Content>
</Card.Root>
<!-- People -->
<Card.Root>
<Card.Header><Card.Title>People</Card.Title></Card.Header>
<Card.Content>
<div class="flex flex-col gap-3 text-[12.5px]">
<div>
<div class="mb-1.5 flex items-center gap-1 text-[11px] text-[color:var(--text-subtle)]">
<UserCheck class="size-3" /> Assigned to
</div>
{#if assignedUsers.length === 0}
<p class="italic text-[color:var(--text-subtle)]">Unassigned</p>
{:else}
<ul class="flex flex-col gap-1.5">
{#each assignedUsers as user (user.id)}
<li class="flex items-center gap-2">
<span
class="flex size-5 items-center justify-center rounded-full bg-[color:var(--color-primary-light)] text-[9.5px] font-semibold text-[color:var(--color-primary-default)]"
>
{getNameInitials(user.email, '')}
</span>
<span class="truncate text-[color:var(--text-muted)]">{user.email}</span>
</li>
{/each}
</ul>
{/if}
</div>
{#if teams.length > 0}
<div>
<div
class="mb-1.5 flex items-center gap-1 text-[11px] text-[color:var(--text-subtle)]"
>
<Users class="size-3" /> Teams
</div>
<div class="flex flex-wrap gap-1.5">
{#each teams as team (team.id)}
<Badge
variant="secondary"
class="bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)]"
>
{team.name}
</Badge>
{/each}
</div>
</div>
{/if}
</div>
</Card.Content>
</Card.Root>
<!-- Dates -->
{#if lead?.last_contacted || lead?.next_follow_up}
<Card.Root>
<Card.Header><Card.Title>Dates</Card.Title></Card.Header>
<Card.Content>
<dl class="grid grid-cols-1 gap-y-3 text-[12.5px]">
{#if lead?.last_contacted}
<div class="flex items-baseline justify-between gap-3">
<dt class="text-[color:var(--text-subtle)]">Last contact</dt>
<dd
class="truncate text-right text-[color:var(--text-muted)]"
title={formatDate(lead.last_contacted)}
>
{formatRelativeDate(lead.last_contacted)}
</dd>
</div>
{/if}
{#if lead?.next_follow_up}
<div class="flex items-baseline justify-between gap-3">
<dt class="text-[color:var(--text-subtle)]">Next follow-up</dt>
<dd
class="truncate text-right text-[color:var(--text-muted)]"
title={formatDate(lead.next_follow_up)}
>
{formatDate(lead.next_follow_up)}
</dd>
</div>
{/if}
</dl>
</Card.Content>
</Card.Root>
{/if}
<Card.Root>
<Card.Header><Card.Title>Tags</Card.Title></Card.Header>
<Card.Content>
{#if tags.length === 0}
<p class="text-[12.5px] italic text-[color:var(--text-subtle)]">No tags.</p>
{:else}
<div class="flex flex-wrap gap-1.5">
{#each tags as tag, i (tag.id ?? tag.slug ?? tag.name ?? i)}
<Badge
variant="secondary"
class="bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)]"
>
{tag.name}
</Badge>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
</div>
</Tabs.Content>
<Tabs.Content class="" value="activity">
<div class="pt-4 pb-8">
<Card.Root>
<Card.Header><Card.Title>All activity</Card.Title></Card.Header>
<Card.Content class="px-4 py-2">
<Timeline isEmpty={timelineItems.length === 0}>
{#each timelineItems as item (item.id)}
{#if item.kind === 'comment'}
<TimelineItem
variant="violet"
time={item.ts ? formatRelativeDate(item.ts) : ''}
quote={item.payload.comment || ''}
>
{#snippet icon()}<MessageSquare class="size-3.5" />{/snippet}
{#snippet text()}<strong
>{item.payload.commented_by_user || 'Someone'}</strong
> commented{/snippet}
</TimelineItem>
{:else if item.kind === 'attachment'}
<TimelineItem time={item.ts ? formatRelativeDate(item.ts) : ''}>
{#snippet icon()}<Paperclip class="size-3.5" />{/snippet}
{#snippet text()}<strong>{item.payload.created_by_user || 'Someone'}</strong>
uploaded <strong>{item.payload.file_name || 'a file'}</strong>{/snippet}
</TimelineItem>
{:else}
<TimelineItem variant="success" time={item.ts ? formatRelativeDate(item.ts) : ''}>
{#snippet icon()}<Calendar class="size-3.5" />{/snippet}
{#snippet text()}Lead created{/snippet}
</TimelineItem>
{/if}
{/each}
</Timeline>
</Card.Content>
</Card.Root>
</div>
</Tabs.Content>
<Tabs.Content class="" value="files">
<div class="pt-4 pb-8">
<Card.Root>
<Card.Header><Card.Title>Files</Card.Title></Card.Header>
<Card.Content>
{#if attachments.length === 0}
<p class="text-[12.5px] italic text-[color:var(--text-subtle)]">No files uploaded.</p>
{:else}
<ul class="flex flex-col divide-y divide-[color:var(--border-faint)]">
{#each attachments as a (a.id)}
<li class="flex items-center gap-3 py-2.5 text-[12.5px]">
<Paperclip class="size-3.5 shrink-0 text-[color:var(--text-subtle)]" />
<span class="flex-1 truncate text-[color:var(--text)]">
{a.file_name || 'File'}
</span>
<span class="text-[11px] text-[color:var(--text-subtle)]">
{a.created_on ? formatRelativeDate(a.created_on) : ''}
</span>
</li>
{/each}
</ul>
{/if}
</Card.Content>
</Card.Root>
</div>
</Tabs.Content>
</Tabs.Root>

View file

@ -0,0 +1,92 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { apiRequest } from '$lib/api-helpers.js';
/** @type {import('./$types').PageServerLoad} */
export async function load({ params, locals, cookies }) {
if (!locals.org) throw error(401, 'Organization context required');
try {
const response = await apiRequest(`/leads/${params.id}/`, {}, { cookies, org: locals.org });
if (response?.error) throw error(404, response.errors || 'Lead not found');
return {
lead: response.lead_obj || null,
users: response.users || [],
tags: response.tags || []
};
} catch (err) {
if (err?.status) throw err;
throw error(500, 'Failed to load lead');
}
}
/** @type {import('./$types').Actions} */
export const actions = {
default: async ({ request, params, locals, cookies }) => {
const form = await request.formData();
const get = /** @param {string} k */ (k) => form.get(k)?.toString().trim() || '';
const title = get('title');
if (!title) {
return fail(400, { error: 'Title is required', values: Object.fromEntries(form) });
}
/** @type {Record<string, any>} */
const body = {
title,
description: get('description') || '',
org: locals.org.id
};
const map = {
salutation: 'salutation',
firstName: 'first_name',
lastName: 'last_name',
email: 'email',
phone: 'phone',
jobTitle: 'job_title',
website: 'website',
linkedinUrl: 'linkedin_url',
industry: 'industry',
company: 'company_name',
source: 'source',
status: 'status',
rating: 'rating',
currency: 'currency',
addressLine: 'address_line',
city: 'city',
state: 'state',
postcode: 'postcode',
country: 'country'
};
for (const [formKey, apiKey] of Object.entries(map)) {
const v = get(formKey);
if (v) body[apiKey] = v;
}
const amount = get('opportunityAmount');
if (amount) body.opportunity_amount = parseFloat(amount);
const probability = get('probability');
if (probability) body.probability = parseInt(probability, 10);
const closeDate = get('closeDate');
if (closeDate) body.close_date = closeDate;
const lastContacted = get('lastContacted');
if (lastContacted) body.last_contacted = lastContacted;
const nextFollowUp = get('nextFollowUp');
if (nextFollowUp) body.next_follow_up = nextFollowUp;
try {
await apiRequest(
`/leads/${params.id}/`,
{ method: 'PATCH', body },
{ cookies, org: locals.org }
);
} catch (err) {
console.error('Update lead error:', err);
return fail(err?.status || 500, {
error: err?.message || 'Failed to update lead',
values: Object.fromEntries(form)
});
}
throw redirect(303, `/leads/${params.id}`);
}
};

View file

@ -0,0 +1,308 @@
<script>
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { Loader2 } from '@lucide/svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import { FormShell } from '$lib/components/ui/form-shell/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { LEAD_STATUSES, LEAD_SOURCES, LEAD_RATINGS, CURRENCY_CODES } from '$lib/constants/filters.js';
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
const lead = $derived(data?.lead);
const initial = $derived(form?.values || {});
const displayName = $derived(
[lead?.first_name, lead?.last_name].filter(Boolean).join(' ') ||
lead?.email ||
lead?.title ||
'Lead'
);
const statusChoices = LEAD_STATUSES.filter((s) => s.value !== 'ALL');
const sourceChoices = LEAD_SOURCES.filter((s) => s.value !== 'ALL');
const ratingChoices = LEAD_RATINGS.filter((r) => r.value !== 'ALL');
const currencyChoices = CURRENCY_CODES.filter((c) => c.value !== '');
let submitting = $state(false);
/** @type {import('@sveltejs/kit').SubmitFunction} */
function handleSubmit() {
submitting = true;
return async ({ result, update }) => {
submitting = false;
if (result.type === 'redirect') {
toast.success('Lead updated');
await update();
} else if (result.type === 'failure') {
toast.error(/** @type {any} */ (result.data)?.error || 'Failed to update');
await update({ reset: false });
}
};
}
function fv(/** @type {string} */ key, /** @type {any} */ fallback) {
if (initial[key] !== undefined && initial[key] !== null) return initial[key];
return fallback ?? '';
}
/** Normalize status: backend stores "in process", constants use "IN_PROCESS" */
function normalizeStatus(/** @type {any} */ value) {
return (value || '').toString().toUpperCase().replace(/\s+/g, '_');
}
/** Normalize rating: backend stores lowercase */
function normalizeRating(/** @type {any} */ value) {
return (value || '').toString().toUpperCase();
}
</script>
<svelte:head>
<title>Edit {displayName} - BottleCRM</title>
</svelte:head>
<PageHeader
title="Edit {displayName}"
breadcrumb={[
{ label: 'Leads', href: '/leads' },
{ label: displayName, href: lead?.id ? `/leads/${lead.id}` : '/leads' },
{ label: 'Edit' }
]}
/>
<FormShell errorMessage={form?.error || ''} useEnhance={handleSubmit}>
{#snippet children()}
<!-- Section 1: Basics -->
<section class="flex flex-col gap-3">
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-[color:var(--text-subtle)]">Basics</h3>
<div class="flex flex-col gap-1.5">
<Label for="lead-title">Title <span class="text-[color:var(--red)]">*</span></Label>
<Input id="lead-title" name="title" required maxlength="200"
value={fv('title', lead?.title)} placeholder="Lead title" />
</div>
<div class="grid grid-cols-[100px_1fr_1fr] gap-3">
<div class="flex flex-col gap-1.5">
<Label for="lead-salutation">Salutation</Label>
<select id="lead-salutation" name="salutation"
class="flex h-9 w-full rounded-[var(--r-md)] border border-[color:var(--border)] bg-[color:var(--bg-input)] px-3 text-[13.5px] text-[color:var(--text)] outline-none hover:border-[color:var(--border-strong)] focus-visible:border-[color:var(--text)] focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]">
<option value="" selected={!fv('salutation', lead?.salutation)}></option>
{#each ['Mr.', 'Mrs.', 'Ms.', 'Dr.'] as sal (sal)}
<option value={sal} selected={sal === fv('salutation', lead?.salutation)}>{sal}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1.5">
<Label for="lead-first-name">First name</Label>
<Input id="lead-first-name" name="firstName" maxlength="50"
value={fv('firstName', lead?.first_name)} />
</div>
<div class="flex flex-col gap-1.5">
<Label for="lead-last-name">Last name</Label>
<Input id="lead-last-name" name="lastName" maxlength="50"
value={fv('lastName', lead?.last_name)} />
</div>
</div>
</section>
<!-- Section 2: Contact -->
<section class="flex flex-col gap-3">
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-[color:var(--text-subtle)]">Contact</h3>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<Label for="lead-email">Email</Label>
<Input id="lead-email" name="email" type="email" maxlength="254"
value={fv('email', lead?.email)} />
</div>
<div class="flex flex-col gap-1.5">
<Label for="lead-phone">Phone</Label>
<Input id="lead-phone" name="phone" type="tel" maxlength="20"
value={fv('phone', lead?.phone)} />
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label for="lead-job-title">Job title</Label>
<Input id="lead-job-title" name="jobTitle" maxlength="100"
value={fv('jobTitle', lead?.job_title)} />
</div>
</section>
<!-- Section 3: Company -->
<section class="flex flex-col gap-3">
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-[color:var(--text-subtle)]">Company</h3>
<div class="flex flex-col gap-1.5">
<Label for="lead-company">Company</Label>
<Input id="lead-company" name="company" maxlength="255"
value={fv('company', lead?.company_name)} />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<Label for="lead-website">Website</Label>
<Input id="lead-website" name="website" type="url" maxlength="255" placeholder="https://"
value={fv('website', lead?.website)} />
</div>
<div class="flex flex-col gap-1.5">
<Label for="lead-linkedin">LinkedIn</Label>
<Input id="lead-linkedin" name="linkedinUrl" type="url" maxlength="255" placeholder="https://linkedin.com/in/..."
value={fv('linkedinUrl', lead?.linkedin_url)} />
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label for="lead-industry">Industry</Label>
<Input id="lead-industry" name="industry" maxlength="100" placeholder="e.g. Software, Healthcare"
value={fv('industry', lead?.industry)} />
</div>
</section>
<!-- Section 4: Classification -->
<section class="flex flex-col gap-3">
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-[color:var(--text-subtle)]">Classification</h3>
<div class="grid grid-cols-3 gap-3">
<div class="flex flex-col gap-1.5">
<Label for="lead-status">Status</Label>
<select id="lead-status" name="status"
class="flex h-9 w-full rounded-[var(--r-md)] border border-[color:var(--border)] bg-[color:var(--bg-input)] px-3 text-[13.5px] text-[color:var(--text)] outline-none hover:border-[color:var(--border-strong)] focus-visible:border-[color:var(--text)] focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]">
{#each statusChoices as opt (opt.value)}
<option value={opt.value} selected={opt.value === normalizeStatus(fv('status', lead?.status))}>{opt.label}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1.5">
<Label for="lead-source">Source</Label>
<select id="lead-source" name="source"
class="flex h-9 w-full rounded-[var(--r-md)] border border-[color:var(--border)] bg-[color:var(--bg-input)] px-3 text-[13.5px] text-[color:var(--text)] outline-none hover:border-[color:var(--border-strong)] focus-visible:border-[color:var(--text)] focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]">
<option value="">—</option>
{#each sourceChoices as opt (opt.value)}
<option value={opt.value} selected={opt.value === fv('source', lead?.source)}>{opt.label}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1.5">
<Label for="lead-rating">Rating</Label>
<select id="lead-rating" name="rating"
class="flex h-9 w-full rounded-[var(--r-md)] border border-[color:var(--border)] bg-[color:var(--bg-input)] px-3 text-[13.5px] text-[color:var(--text)] outline-none hover:border-[color:var(--border-strong)] focus-visible:border-[color:var(--text)] focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]">
<option value="">—</option>
{#each ratingChoices as opt (opt.value)}
<option value={opt.value} selected={opt.value === normalizeRating(fv('rating', lead?.rating))}>{opt.label}</option>
{/each}
</select>
</div>
</div>
</section>
<!-- Section 5: Opportunity -->
<section class="flex flex-col gap-3">
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-[color:var(--text-subtle)]">Opportunity</h3>
<div class="grid grid-cols-[1fr_140px] gap-3">
<div class="flex flex-col gap-1.5">
<Label for="lead-opp-amount">Estimated value</Label>
<Input id="lead-opp-amount" name="opportunityAmount" type="number" min="0" step="0.01"
value={fv('opportunityAmount', lead?.opportunity_amount)} placeholder="0.00" />
</div>
<div class="flex flex-col gap-1.5">
<Label for="lead-currency">Currency</Label>
<select id="lead-currency" name="currency"
class="flex h-9 w-full rounded-[var(--r-md)] border border-[color:var(--border)] bg-[color:var(--bg-input)] px-3 text-[13.5px] text-[color:var(--text)] outline-none hover:border-[color:var(--border-strong)] focus-visible:border-[color:var(--text)] focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]">
{#each currencyChoices as c (c.value)}
<option value={c.value} selected={c.value === fv('currency', lead?.currency || 'USD')}>{c.value}</option>
{/each}
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<Label for="lead-probability">Probability</Label>
<Input id="lead-probability" name="probability" type="number" min="0" max="100" step="1"
value={fv('probability', lead?.probability)} placeholder="0–100" />
</div>
<div class="flex flex-col gap-1.5">
<Label for="lead-close-date">Close date</Label>
<Input id="lead-close-date" name="closeDate" type="date"
value={fv('closeDate', lead?.close_date)} />
</div>
</div>
</section>
<!-- Section 6: Address -->
<section class="flex flex-col gap-3">
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-[color:var(--text-subtle)]">Address</h3>
<div class="flex flex-col gap-1.5">
<Label for="lead-address-line">Street</Label>
<Input id="lead-address-line" name="addressLine" maxlength="255"
value={fv('addressLine', lead?.address_line)} />
</div>
<div class="grid grid-cols-[1fr_120px_140px] gap-3">
<div class="flex flex-col gap-1.5">
<Label for="lead-city">City</Label>
<Input id="lead-city" name="city" maxlength="100"
value={fv('city', lead?.city)} />
</div>
<div class="flex flex-col gap-1.5">
<Label for="lead-state">State</Label>
<Input id="lead-state" name="state" maxlength="100"
value={fv('state', lead?.state)} />
</div>
<div class="flex flex-col gap-1.5">
<Label for="lead-postcode">Postcode</Label>
<Input id="lead-postcode" name="postcode" maxlength="20"
value={fv('postcode', lead?.postcode)} />
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label for="lead-country">Country</Label>
<Input id="lead-country" name="country" maxlength="100"
value={fv('country', lead?.country)} />
</div>
</section>
<!-- Section 7: Activity -->
<section class="flex flex-col gap-3">
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-[color:var(--text-subtle)]">Activity</h3>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<Label for="lead-last-contacted">Last contacted</Label>
<Input id="lead-last-contacted" name="lastContacted" type="date"
value={fv('lastContacted', lead?.last_contacted)} />
</div>
<div class="flex flex-col gap-1.5">
<Label for="lead-next-follow-up">Next follow-up</Label>
<Input id="lead-next-follow-up" name="nextFollowUp" type="date"
value={fv('nextFollowUp', lead?.next_follow_up)} />
</div>
</div>
<div class="flex flex-col gap-1.5">
<Label for="lead-description">Description</Label>
<textarea id="lead-description" name="description" rows="5"
class="flex w-full resize-y rounded-[var(--r-md)] border border-[color:var(--border)] bg-[color:var(--bg-input)] px-3 py-2 text-[13.5px] text-[color:var(--text)] outline-none hover:border-[color:var(--border-strong)] focus-visible:border-[color:var(--text)] focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]">{fv('description', lead?.description)}</textarea>
</div>
</section>
{/snippet}
{#snippet actions()}
<Button type="button" variant="ghost" onclick={() => goto(lead?.id ? `/leads/${lead.id}` : '/leads')}>
Cancel
</Button>
<Button type="submit" disabled={submitting}>
{#if submitting}<Loader2 class="mr-1 size-3.5 animate-spin" />{/if}
Save changes
</Button>
{/snippet}
</FormShell>

View file

@ -21,13 +21,13 @@
Banknote,
Contact,
Tags,
Filter,
Package,
Trash2,
Receipt,
Clock
Clock,
ArrowUpRight
} from '@lucide/svelte';
import { PageHeader } from '$lib/components/layout';
import { PageHeader, FilterStrip, ViewTabs, StatusBar, FilterPill } from '$lib/components/layout';
import { Button } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { CrmDrawer } from '$lib/components/ui/crm-drawer';
@ -35,7 +35,6 @@
import { getCurrentUser } from '$lib/api.js';
import { CrmTable } from '$lib/components/ui/crm-table';
import {
FilterBar,
SearchInput,
SelectFilter,
DateRangeFilter,
@ -554,9 +553,6 @@
// Status chip filter for quick filtering (client-side on top of server filters)
let statusChipFilter = $state($page.url.searchParams.get('rotten') === 'true' ? 'stale' : 'ALL');
// Filter panel expansion state
let filtersExpanded = $state(false);
// Status stages for filtering
const openStages = ['PROSPECTING', 'QUALIFICATION', 'PROPOSAL', 'NEGOTIATION'];
@ -577,6 +573,10 @@
});
});
// Active row (highlighted in the table) — null until the user clicks a row.
/** @type {string | null} */
let activeRowId = $state(null);
// Status counts for filter chips
const openCount = $derived(
opportunities.filter((/** @type {any} */ o) => openStages.includes(o.stage)).length
@ -1116,6 +1116,7 @@
<title>Opportunities - BottleCRM</title>
</svelte:head>
<div class="flex flex-col">
<PageHeader title="Opportunities" subtitle="Pipeline: {formatCurrency(stats.pipeline)}">
{#snippet actions()}
<div class="flex items-center gap-2">
@ -1213,24 +1214,6 @@
<div class="bg-border mx-1 h-6 w-px"></div>
<!-- Filter Toggle Button -->
<Button
variant={filtersExpanded ? 'secondary' : 'outline'}
size="sm"
class="gap-2"
onclick={() => (filtersExpanded = !filtersExpanded)}
>
<Filter class="h-4 w-4" />
Filters
{#if activeFiltersCount > 0}
<span
class="rounded-full bg-[var(--color-primary-light)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-primary-default)]"
>
{activeFiltersCount}
</span>
{/if}
</Button>
<!-- Column Visibility Dropdown -->
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
@ -1270,17 +1253,14 @@
</Button>
</div>
{/snippet}
{#snippet tabs()}
<ViewTabs views={[{ id: 'all', label: 'All', count: pagination.total }]} active="all" />
{/snippet}
</PageHeader>
<div class="flex-1">
<!-- Collapsible Filter Bar -->
<FilterBar
minimal={true}
expanded={filtersExpanded}
activeCount={activeFiltersCount}
onClear={clearFilters}
class="pb-4"
>
<!-- Filter Strip -->
<FilterStrip>
<SearchInput
value={filters.search}
onchange={(value) => updateFilters({ ...filters, search: value })}
@ -1305,7 +1285,13 @@
value={filters.tags}
onchange={(ids) => updateFilters({ ...filters, tags: ids })}
/>
</FilterBar>
{#if activeFiltersCount > 0}
<FilterPill label="Clear all" dashed onclick={clearFilters} />
{/if}
{#snippet meta()}
<span>{filteredOpportunities.length} of {pagination.total} opportunities</span>
{/snippet}
</FilterStrip>
<!-- Table View -->
{#if filteredOpportunities.length === 0}
<div class="flex flex-col items-center justify-center py-16 text-center">
@ -1324,6 +1310,7 @@
data={filteredOpportunities}
{columns}
bind:visibleColumns
bind:activeRowId
onRowChange={handleRowChange}
onRowClick={(row) => openDrawer(row.id)}
>
@ -1350,6 +1337,9 @@
/>
</div>
<StatusBar status="{filteredOpportunities.length} of {pagination.total} opportunities" />
</div>
<!-- Opportunity Drawer -->
<CrmDrawer
bind:open={drawerOpen}
@ -1368,6 +1358,13 @@
{#snippet activitySection()}
<!-- Account and Owner info (view mode only) -->
{#if drawerMode !== 'create' && selectedRow}
<a
href={`/opportunities/${selectedRow.id}`}
class="mb-4 inline-flex items-center gap-1.5 rounded-[var(--r-sm)] border border-[color:var(--border-faint)] bg-[color:var(--bg-elevated)] px-2.5 py-1 text-[11.5px] font-medium text-[color:var(--text-muted)] transition-colors hover:bg-[color:var(--bg-hover)] hover:text-[color:var(--text)]"
>
Open full detail
<ArrowUpRight class="size-3.5" />
</a>
<div class="mb-4">
<p class="mb-2 text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">
Details

View file

@ -0,0 +1,39 @@
/**
* Opportunity Detail Page - Server Load
*
* Django endpoint: GET /api/opportunities/<id>/
* Response shape: { opportunity_obj, comments, attachments, contacts, users, stage, lead_source, currency, comment_permission, users_mention }
*/
import { error } from '@sveltejs/kit';
import { apiRequest } from '$lib/api-helpers.js';
/** @type {import('./$types').PageServerLoad} */
export async function load({ params, locals, cookies }) {
const org = locals.org;
if (!org) {
throw error(401, 'Organization context required');
}
try {
const response = await apiRequest(`/opportunities/${params.id}/`, {}, { cookies, org });
if (response?.error) {
throw error(404, response.errors || 'Opportunity not found');
}
return {
opportunity: response.opportunity_obj || null,
comments: response.comments || [],
attachments: response.attachments || [],
contacts: response.contacts || [],
users: response.users || [],
commentPermission: response.comment_permission || false
};
} catch (err) {
// SvelteKit `error()` throws a special object — let it bubble.
if (/** @type {any} */ (err)?.status) throw err;
console.error('Failed to load opportunity detail:', err);
throw error(500, 'Failed to load opportunity');
}
}

View file

@ -0,0 +1,325 @@
<script>
import { goto } from '$app/navigation';
import {
Pencil,
Mail,
Phone,
MoreHorizontal,
Paperclip,
MessageSquare,
Calendar
} from '@lucide/svelte';
import { PageHeader } from '$lib/components/layout';
import { StageStepper } from '$lib/components/ui/stage-stepper';
import { Timeline, TimelineItem } from '$lib/components/ui/timeline';
import * as Card from '$lib/components/ui/card/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { formatCurrency, formatDate, formatRelativeDate, getNameInitials } from '$lib/utils/formatting.js';
import { OPPORTUNITY_STAGES } from '$lib/constants/filters.js';
/** @type {{ data: { opportunity: any, comments: any[], attachments: any[], contacts: any[], users: any[], commentPermission: boolean } }} */
let { data } = $props();
const opp = $derived(data.opportunity || {});
const comments = $derived(data.comments || []);
const attachments = $derived(data.attachments || []);
const contacts = $derived(data.contacts || []);
let tab = $state('overview');
// Build the 6 stepper cells from OPPORTUNITY_STAGES (skipping the 'ALL' filter sentinel)
const stepperStages = $derived(
OPPORTUNITY_STAGES.filter((s) => s.value !== 'ALL').map((s) => ({
value: s.value,
label: s.label
}))
);
// Timeline items: comments + attachments + created event, sorted DESC by timestamp.
const timelineItems = $derived.by(() => {
/** @type {Array<{ id: string, ts: string, kind: 'comment' | 'attachment' | 'created', payload: any }>} */
const items = [];
for (const c of comments) {
items.push({ id: `comment-${c.id}`, ts: c.commented_on || c.commented_on_arrow || '', kind: 'comment', payload: c });
}
for (const a of attachments) {
items.push({ id: `attachment-${a.id}`, ts: a.created_on || a.created_at || '', kind: 'attachment', payload: a });
}
if (opp?.created_at || opp?.created_on) {
items.push({ id: `created-${opp.id}`, ts: opp.created_at || opp.created_on, kind: 'created', payload: opp });
}
return items.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime());
});
</script>
<svelte:head>
<title>{opp?.name || 'Opportunity'} · BottleCRM</title>
</svelte:head>
<PageHeader
title={opp?.name || 'Opportunity'}
breadcrumb={[
{ label: 'Opportunities', href: '/opportunities' },
{ label: opp?.name || '—' }
]}
>
{#snippet meta()}
<div class="flex flex-wrap items-center gap-3 text-[12px] leading-none text-[color:var(--text-subtle)]">
{#if opp?.created_at || opp?.created_on}
<span>Created {formatRelativeDate(opp.created_at || opp.created_on)}</span>
{/if}
{#if opp?.account?.name}
<span>·</span>
<a href={`/accounts`} class="hover:text-[color:var(--text-muted)]">{opp.account.name}</a>
{/if}
{#if opp?.assigned_to?.[0]}
<span>·</span>
<span class="flex items-center gap-1.5">
<span class="flex size-4 items-center justify-center rounded-full bg-[color:var(--bg-elevated)] text-[9px] font-medium text-[color:var(--text-muted)]">
{getNameInitials(opp.assigned_to[0].user_details?.email || '?', '')}
</span>
{opp.assigned_to[0].user_details?.email || 'Unassigned'}
</span>
{/if}
</div>
{/snippet}
{#snippet amount()}
{#if opp?.amount != null}
<span class="text-[28px] font-bold leading-none tabular-nums text-[color:var(--text)]">
{formatCurrency(opp.amount, opp.currency || 'USD')}
</span>
{#if opp?.probability != null}
<span class="text-[11px] leading-none text-[color:var(--text-subtle)]">
{opp.probability}% probability
</span>
{/if}
{/if}
{/snippet}
{#snippet actions()}
<div class="flex items-center gap-1.5">
<Button variant="ghost" size="icon" aria-label="Email"><Mail class="size-4" /></Button>
<Button variant="ghost" size="icon" aria-label="Call"><Phone class="size-4" /></Button>
<Button variant="ghost" size="icon" aria-label="More"><MoreHorizontal class="size-4" /></Button>
<Button variant="outline" size="sm" onclick={() => opp?.id && goto(`/opportunities/${opp.id}/edit`)}>
<Pencil class="mr-1.5 size-3.5" /> Edit
</Button>
</div>
{/snippet}
</PageHeader>
<!-- Stage stepper -->
<div class="px-7 pb-3 md:px-8">
<StageStepper stages={stepperStages} current={opp?.stage || ''} />
</div>
<!-- Tabs -->
<Tabs.Root bind:value={tab} class="px-7 md:px-8">
<Tabs.List class="">
<Tabs.Trigger class="" value="overview">Overview</Tabs.Trigger>
<Tabs.Trigger class="" value="activity">
Activity
<span class="ml-1.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-[color:var(--bg-elevated)] px-1 text-[10px] tabular-nums text-[color:var(--text-subtle)]">
{timelineItems.length}
</span>
</Tabs.Trigger>
<Tabs.Trigger class="" value="files">
Files
<span class="ml-1.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-[color:var(--bg-elevated)] px-1 text-[10px] tabular-nums text-[color:var(--text-subtle)]">
{attachments.length}
</span>
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content class="" value="overview">
<div class="grid grid-cols-1 gap-6 pt-4 pb-8 lg:grid-cols-[1fr_320px]">
<!-- Main column -->
<div class="flex flex-col gap-6">
<!-- About card -->
<Card.Root>
<Card.Header>
<Card.Title>About</Card.Title>
</Card.Header>
<Card.Content>
{#if opp?.description}
<p class="text-[13px] leading-[1.6] text-[color:var(--text-muted)]">{opp.description}</p>
{:else}
<p class="text-[12.5px] italic text-[color:var(--text-subtle)]">No description.</p>
{/if}
</Card.Content>
</Card.Root>
<!-- Activity timeline card -->
<Card.Root>
<Card.Header>
<Card.Title>Activity</Card.Title>
</Card.Header>
<Card.Content class="px-4 py-2">
<Timeline isEmpty={timelineItems.length === 0}>
{#each timelineItems as item (item.id)}
{#if item.kind === 'comment'}
<TimelineItem
variant="violet"
time={item.ts ? formatRelativeDate(item.ts) : ''}
quote={item.payload.comment || ''}
>
{#snippet icon()}<MessageSquare class="size-3.5" />{/snippet}
{#snippet text()}
<strong>{item.payload.commented_by_user || 'Someone'}</strong> commented
{/snippet}
</TimelineItem>
{:else if item.kind === 'attachment'}
<TimelineItem
time={item.ts ? formatRelativeDate(item.ts) : ''}
>
{#snippet icon()}<Paperclip class="size-3.5" />{/snippet}
{#snippet text()}
<strong>{item.payload.created_by_user || 'Someone'}</strong> uploaded
<strong>{item.payload.file_name || 'a file'}</strong>
{/snippet}
</TimelineItem>
{:else}
<TimelineItem
variant="success"
time={item.ts ? formatRelativeDate(item.ts) : ''}
>
{#snippet icon()}<Calendar class="size-3.5" />{/snippet}
{#snippet text()}
Opportunity created
{/snippet}
</TimelineItem>
{/if}
{/each}
</Timeline>
</Card.Content>
</Card.Root>
</div>
<!-- Right rail -->
<div class="flex flex-col gap-6">
<Card.Root>
<Card.Header><Card.Title>Details</Card.Title></Card.Header>
<Card.Content>
<dl class="grid grid-cols-1 gap-y-3 text-[12.5px]">
<div class="flex items-baseline justify-between gap-3">
<dt class="text-[color:var(--text-subtle)]">Account</dt>
<dd class="truncate text-right font-medium text-[color:var(--text)]">{opp?.account?.name || '—'}</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="text-[color:var(--text-subtle)]">Stage</dt>
<dd class="truncate text-right text-[color:var(--text-muted)]">
{OPPORTUNITY_STAGES.find((s) => s.value === opp?.stage)?.label || opp?.stage || '—'}
</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="text-[color:var(--text-subtle)]">Close date</dt>
<dd class="text-right text-[color:var(--text-muted)]">{opp?.closed_on ? formatDate(opp.closed_on) : '—'}</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="text-[color:var(--text-subtle)]">Probability</dt>
<dd class="text-right tabular-nums text-[color:var(--text-muted)]">{opp?.probability != null ? `${opp.probability}%` : '—'}</dd>
</div>
<div class="flex items-baseline justify-between gap-3">
<dt class="text-[color:var(--text-subtle)]">Source</dt>
<dd class="truncate text-right text-[color:var(--text-muted)]">{opp?.lead_source || '—'}</dd>
</div>
</dl>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header><Card.Title>Contacts</Card.Title></Card.Header>
<Card.Content>
{#if contacts.length === 0}
<p class="text-[12.5px] italic text-[color:var(--text-subtle)]">No contacts linked.</p>
{:else}
<ul class="flex flex-col gap-2">
{#each contacts as ct (ct.id)}
<li class="flex items-center gap-2 text-[12.5px]">
<span class="flex size-6 items-center justify-center rounded-full bg-[color:var(--bg-elevated)] text-[10px] font-medium text-[color:var(--text-muted)]">
{getNameInitials(ct.first_name || ct.email || '?', ct.last_name || '')}
</span>
<span class="truncate text-[color:var(--text)]">{`${ct.first_name || ''} ${ct.last_name || ''}`.trim() || ct.email || 'Contact'}</span>
</li>
{/each}
</ul>
{/if}
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header><Card.Title>Tags</Card.Title></Card.Header>
<Card.Content>
{#if (opp?.tags || []).length === 0}
<p class="text-[12.5px] italic text-[color:var(--text-subtle)]">No tags.</p>
{:else}
<div class="flex flex-wrap gap-1.5">
{#each opp.tags as tag, i (tag.id ?? tag.slug ?? tag.name ?? i)}
<Badge variant="secondary" class="bg-[color:var(--bg-elevated)] text-[color:var(--text-muted)]">
{tag.name}
</Badge>
{/each}
</div>
{/if}
</Card.Content>
</Card.Root>
</div>
</div>
</Tabs.Content>
<Tabs.Content class="" value="activity">
<div class="pt-4 pb-8">
<Card.Root>
<Card.Header><Card.Title>All activity</Card.Title></Card.Header>
<Card.Content class="px-4 py-2">
<Timeline isEmpty={timelineItems.length === 0}>
{#each timelineItems as item (item.id)}
{#if item.kind === 'comment'}
<TimelineItem variant="violet" time={item.ts ? formatRelativeDate(item.ts) : ''} quote={item.payload.comment || ''}>
{#snippet icon()}<MessageSquare class="size-3.5" />{/snippet}
{#snippet text()}<strong>{item.payload.commented_by_user || 'Someone'}</strong> commented{/snippet}
</TimelineItem>
{:else if item.kind === 'attachment'}
<TimelineItem time={item.ts ? formatRelativeDate(item.ts) : ''}>
{#snippet icon()}<Paperclip class="size-3.5" />{/snippet}
{#snippet text()}<strong>{item.payload.created_by_user || 'Someone'}</strong> uploaded <strong>{item.payload.file_name || 'a file'}</strong>{/snippet}
</TimelineItem>
{:else}
<TimelineItem variant="success" time={item.ts ? formatRelativeDate(item.ts) : ''}>
{#snippet icon()}<Calendar class="size-3.5" />{/snippet}
{#snippet text()}Opportunity created{/snippet}
</TimelineItem>
{/if}
{/each}
</Timeline>
</Card.Content>
</Card.Root>
</div>
</Tabs.Content>
<Tabs.Content class="" value="files">
<div class="pt-4 pb-8">
<Card.Root>
<Card.Header><Card.Title>Files</Card.Title></Card.Header>
<Card.Content>
{#if attachments.length === 0}
<p class="text-[12.5px] italic text-[color:var(--text-subtle)]">No files uploaded.</p>
{:else}
<ul class="flex flex-col divide-y divide-[color:var(--border-faint)]">
{#each attachments as a (a.id)}
<li class="flex items-center gap-3 py-2.5 text-[12.5px]">
<Paperclip class="size-3.5 shrink-0 text-[color:var(--text-subtle)]" />
<span class="flex-1 truncate text-[color:var(--text)]">{a.file_name || 'File'}</span>
<span class="text-[11px] text-[color:var(--text-subtle)]">{a.created_on ? formatRelativeDate(a.created_on) : ''}</span>
</li>
{/each}
</ul>
{/if}
</Card.Content>
</Card.Root>
</div>
</Tabs.Content>
</Tabs.Root>

View file

@ -0,0 +1,68 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { apiRequest } from '$lib/api-helpers.js';
/** @type {import('./$types').PageServerLoad} */
export async function load({ params, locals, cookies }) {
if (!locals.org) throw error(401, 'Organization context required');
try {
const response = await apiRequest(
`/opportunities/${params.id}/`,
{},
{ cookies, org: locals.org }
);
if (response?.error) throw error(404, response.errors || 'Opportunity not found');
return {
opportunity: response.opportunity_obj || null,
users: response.users || [],
contacts: response.contacts || []
};
} catch (err) {
if (err?.status) throw err;
throw error(500, 'Failed to load opportunity');
}
}
/** @type {import('./$types').Actions} */
export const actions = {
default: async ({ request, params, locals, cookies }) => {
const form = await request.formData();
const get = /** @param {string} k */ (k) => form.get(k)?.toString().trim() || '';
const name = get('name');
if (!name) return fail(400, { error: 'Name is required', values: Object.fromEntries(form) });
/** @type {Record<string, any>} */
const body = { name };
const stage = get('stage');
const currency = get('currency');
const amount = get('amount');
const probability = get('probability');
const closeDate = get('closeDate');
const description = get('description');
const leadSource = get('leadSource');
if (stage) body.stage = stage;
if (currency) body.currency = currency;
if (amount) body.amount = parseFloat(amount);
if (probability) body.probability = parseInt(probability, 10);
if (closeDate) body.close_date = closeDate;
if (description) body.description = description;
if (leadSource) body.lead_source = leadSource;
try {
await apiRequest(
`/opportunities/${params.id}/`,
{ method: 'PATCH', body },
{ cookies, org: locals.org }
);
} catch (err) {
console.error('Update opportunity error:', err);
return fail(err?.status || 500, {
error: err?.message || 'Failed to update opportunity',
values: Object.fromEntries(form)
});
}
throw redirect(303, `/opportunities/${params.id}`);
}
};

View file

@ -0,0 +1,147 @@
<script>
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { Loader2 } from '@lucide/svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import { FormShell } from '$lib/components/ui/form-shell/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { OPPORTUNITY_STAGES, OPPORTUNITY_SOURCES, CURRENCY_CODES } from '$lib/constants/filters.js';
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
const opp = $derived(data?.opportunity);
const initial = $derived(form?.values || {});
const stageChoices = OPPORTUNITY_STAGES.filter((s) => s.value !== 'ALL');
const sourceChoices = OPPORTUNITY_SOURCES.filter((s) => s.value !== '');
const currencyChoices = CURRENCY_CODES.filter((c) => c.value !== '');
let submitting = $state(false);
/** @type {import('@sveltejs/kit').SubmitFunction} */
function handleSubmit() {
submitting = true;
return async ({ result, update }) => {
submitting = false;
if (result.type === 'redirect') {
toast.success('Opportunity updated');
await update();
} else if (result.type === 'failure') {
toast.error(/** @type {any} */ (result.data)?.error || 'Failed to update');
await update({ reset: false });
}
};
}
function fv(/** @type {string} */ key, /** @type {any} */ fallback) {
if (initial[key] !== undefined && initial[key] !== null) return initial[key];
return fallback ?? '';
}
</script>
<svelte:head>
<title>{opp?.name ? `Edit ${opp.name}` : 'Edit opportunity'} - BottleCRM</title>
</svelte:head>
<PageHeader
title={opp?.name ? `Edit ${opp.name}` : 'Edit opportunity'}
breadcrumb={[
{ label: 'Opportunities', href: '/opportunities' },
{ label: opp?.name || 'Opportunity', href: opp?.id ? `/opportunities/${opp.id}` : '/opportunities' },
{ label: 'Edit' }
]}
/>
<FormShell errorMessage={form?.error || ''} useEnhance={handleSubmit}>
{#snippet children()}
<section class="flex flex-col gap-3">
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-[color:var(--text-subtle)]">Basics</h3>
<div class="flex flex-col gap-1.5">
<Label for="opp-name">Name <span class="text-[color:var(--red)]">*</span></Label>
<Input id="opp-name" name="name" required maxlength="64"
value={fv('name', opp?.name)} placeholder="e.g. Acme Q3 expansion" />
</div>
<div class="flex flex-col gap-1.5">
<Label for="opp-stage">Stage</Label>
<select id="opp-stage" name="stage"
class="flex h-9 w-full rounded-[var(--r-md)] border border-[color:var(--border)] bg-[color:var(--bg-input)] px-3 text-[13.5px] text-[color:var(--text)] outline-none hover:border-[color:var(--border-strong)] focus-visible:border-[color:var(--text)] focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]">
{#each stageChoices as opt (opt.value)}
<option value={opt.value} selected={opt.value === fv('stage', opp?.stage)}>{opt.label}</option>
{/each}
</select>
</div>
</section>
<section class="flex flex-col gap-3">
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-[color:var(--text-subtle)]">Deal value</h3>
<div class="grid grid-cols-[1fr_140px] gap-3">
<div class="flex flex-col gap-1.5">
<Label for="opp-amount">Amount</Label>
<Input id="opp-amount" name="amount" type="number" min="0" step="0.01"
value={fv('amount', opp?.amount)} placeholder="0.00" />
</div>
<div class="flex flex-col gap-1.5">
<Label for="opp-currency">Currency</Label>
<select id="opp-currency" name="currency"
class="flex h-9 w-full rounded-[var(--r-md)] border border-[color:var(--border)] bg-[color:var(--bg-input)] px-3 text-[13.5px] text-[color:var(--text)] outline-none hover:border-[color:var(--border-strong)] focus-visible:border-[color:var(--text)] focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]">
{#each currencyChoices as c (c.value)}
<option value={c.value} selected={c.value === fv('currency', opp?.currency || 'USD')}>{c.value}</option>
{/each}
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1.5">
<Label for="opp-probability">Probability</Label>
<Input id="opp-probability" name="probability" type="number" min="0" max="100" step="1"
value={fv('probability', opp?.probability)} placeholder="0–100" />
<span class="text-[11.5px] text-[color:var(--text-subtle)]">Percent likelihood of closing.</span>
</div>
<div class="flex flex-col gap-1.5">
<Label for="opp-close-date">Close date</Label>
<Input id="opp-close-date" name="closeDate" type="date" value={fv('closeDate', opp?.close_date)} />
</div>
</div>
</section>
<section class="flex flex-col gap-3">
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-[color:var(--text-subtle)]">Detail</h3>
<div class="flex flex-col gap-1.5">
<Label for="opp-source">Lead source</Label>
<select id="opp-source" name="leadSource"
class="flex h-9 w-full rounded-[var(--r-md)] border border-[color:var(--border)] bg-[color:var(--bg-input)] px-3 text-[13.5px] text-[color:var(--text)] outline-none hover:border-[color:var(--border-strong)] focus-visible:border-[color:var(--text)] focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]">
<option value="" selected={!fv('leadSource', opp?.lead_source)}>Not specified</option>
{#each sourceChoices as opt (opt.value)}
<option value={opt.value} selected={opt.value === fv('leadSource', opp?.lead_source)}>{opt.label}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1.5">
<Label for="opp-description">Description</Label>
<textarea id="opp-description" name="description" rows="5"
placeholder="Notes about the deal, context for the team..."
class="flex w-full resize-y rounded-[var(--r-md)] border border-[color:var(--border)] bg-[color:var(--bg-input)] px-3 py-2 text-[13.5px] text-[color:var(--text)] outline-none hover:border-[color:var(--border-strong)] focus-visible:border-[color:var(--text)] focus-visible:shadow-[0_0_0_3px_var(--focus-ring)]">{fv('description', opp?.description)}</textarea>
</div>
</section>
{/snippet}
{#snippet actions()}
<Button type="button" variant="ghost" onclick={() => goto(opp?.id ? `/opportunities/${opp.id}` : '/opportunities')}>
Cancel
</Button>
<Button type="submit" disabled={submitting}>
{#if submitting}<Loader2 class="mr-1 size-3.5 animate-spin" />{/if}
Save changes
</Button>
{/snippet}
</FormShell>

View file

@ -157,26 +157,20 @@
<title>Tags - BottleCRM</title>
</svelte:head>
<!-- HubSpot-style header -->
<div class="border-border bg-card border-b">
<div class="px-6 py-5">
<div class="flex items-center justify-between">
<div>
<h1 class="text-foreground text-2xl font-semibold tracking-tight">Tags</h1>
<p class="text-muted-foreground mt-1 text-sm">
Create and manage tags to organize contacts, companies, deals, and tickets.
</p>
</div>
<Button
onclick={openCreate}
class="border-0 bg-[var(--color-primary-default)] text-white hover:bg-[var(--color-primary-dark)]"
>
<Plus class="mr-2 h-4 w-4" />
Create tag
</Button>
</div>
</div>
</div>
<PageHeader
title="Tags"
subtitle="Create and manage tags to organize contacts, companies, deals, and tickets."
>
{#snippet actions()}
<Button
onclick={openCreate}
class="border-0 bg-[var(--color-primary-default)] text-white hover:bg-[var(--color-primary-dark)]"
>
<Plus class="mr-2 h-4 w-4" />
Create tag
</Button>
{/snippet}
</PageHeader>
<div class="bg-background flex-1">
<!-- Search and filter bar -->

View file

@ -2,6 +2,7 @@
import { invalidateAll } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { ShieldCheck, Loader2, Plus, Trash2, Edit3 } from '@lucide/svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import { Button } from '$lib/components/ui/button/index.js';
/** @type {{ data: any }} */
@ -142,19 +143,20 @@
<title>Approval rules - Settings - BottleCRM</title>
</svelte:head>
<div class="flex flex-col gap-4 p-4">
<header class="flex items-center justify-between">
<div class="flex items-center gap-2">
<ShieldCheck class="h-5 w-5 text-[var(--text-secondary)]" />
<h1 class="text-xl font-semibold">Approval rules</h1>
</div>
<PageHeader title="Approval rules">
{#snippet titleIcon()}
<ShieldCheck class="size-4" />
{/snippet}
{#snippet actions()}
{#if !creating && editingId === null}
<Button onclick={startCreate} disabled={pending}>
<Plus class="mr-1 h-4 w-4" /> New rule
</Button>
{/if}
</header>
{/snippet}
</PageHeader>
<div class="flex flex-col gap-4 p-4">
<p class="text-sm text-[var(--text-secondary)]">
Active rules block the close transition until an approval is recorded.
Filters combine — a rule with priority=Urgent + case_type=Incident matches

View file

@ -13,6 +13,7 @@
Pencil,
CheckCircle2
} from '@lucide/svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import { Button } from '$lib/components/ui/button/index.js';
/** @type {{ data: any }} */
@ -117,21 +118,24 @@
<title>Knowledge Base - BottleCRM</title>
</svelte:head>
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<BookOpen class="h-5 w-5 text-[var(--text-secondary)]" />
<h1 class="text-lg font-semibold">Knowledge Base</h1>
<span class="text-xs text-[var(--text-secondary)]">
{total} {total === 1 ? 'article' : 'articles'}
</span>
</div>
<PageHeader title="Knowledge Base">
{#snippet titleIcon()}
<BookOpen class="size-4" />
{/snippet}
{#snippet meta()}
<span class="text-xs text-[var(--text-secondary)]">
{total} {total === 1 ? 'article' : 'articles'}
</span>
{/snippet}
{#snippet actions()}
<Button onclick={() => goto('/solutions/new')} size="sm" class="gap-1">
<Plus class="h-4 w-4" />
New solution
</Button>
</div>
{/snippet}
</PageHeader>
<div class="flex flex-col gap-4 p-4">
<section
class="flex flex-wrap items-end gap-2 rounded-md border border-[var(--border-default)] bg-[var(--surface-default)] p-3"
>

View file

@ -1,10 +1,8 @@
<script>
import { goto, invalidateAll } from '$app/navigation';
import { invalidateAll } from '$app/navigation';
import { enhance } from '$app/forms';
import { toast } from 'svelte-sonner';
import {
ChevronLeft,
BookOpen,
Eye,
EyeOff,
Trash2,
@ -13,6 +11,7 @@
Briefcase
} from '@lucide/svelte';
import { Button } from '$lib/components/ui/button/index.js';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
/** @type {{ data: any }} */
let { data } = $props();
@ -121,68 +120,55 @@
<title>{sol.title} - Knowledge Base - BottleCRM</title>
</svelte:head>
<div class="flex flex-col gap-4 p-4">
<div class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<button
type="button"
onclick={() => goto('/solutions')}
class="inline-flex items-center gap-1 hover:text-[var(--text-primary)]"
>
<ChevronLeft class="h-4 w-4" />
Knowledge Base
</button>
<span>/</span>
<span class="truncate font-medium text-[var(--text-primary)]">{sol.title}</span>
</div>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex items-center gap-2">
<BookOpen class="h-5 w-5 text-[var(--text-secondary)]" />
<h1 class="text-lg font-semibold">{sol.title}</h1>
<span class={`rounded px-2 py-0.5 text-[10px] font-medium uppercase ${statusBadgeClass(sol.status)}`}>
{sol.status}
<PageHeader
title={sol.title}
breadcrumb={[{ label: 'Knowledge Base', href: '/solutions' }, { label: sol.title }]}
>
{#snippet meta()}
<span class={`rounded px-2 py-0.5 text-[10px] font-medium uppercase ${statusBadgeClass(sol.status)}`}>
{sol.status}
</span>
{#if sol.is_published}
<span class="inline-flex items-center gap-1 rounded bg-green-100 px-2 py-0.5 text-[10px] font-medium uppercase text-green-900 dark:bg-green-900/30 dark:text-green-200">
<CheckCircle2 class="h-3 w-3" />
Live
</span>
{#if sol.is_published}
<span class="inline-flex items-center gap-1 rounded bg-green-100 px-2 py-0.5 text-[10px] font-medium uppercase text-green-900 dark:bg-green-900/30 dark:text-green-200">
<CheckCircle2 class="h-3 w-3" />
Live
</span>
{/if}
</div>
<div class="flex items-center gap-2">
{#if sol.is_published}
<form method="POST" action="?/unpublish" use:enhance={handleUnpublish}>
<Button type="submit" variant="outline" size="sm" class="gap-1">
<EyeOff class="h-4 w-4" />
Unpublish
</Button>
</form>
{:else}
<form method="POST" action="?/publish" use:enhance={handlePublish}>
<Button
type="submit"
size="sm"
class="gap-1"
disabled={sol.status !== 'approved'}
title={sol.status !== 'approved'
? 'Set status to Approved first'
: 'Publish to the knowledge base'}
>
<Eye class="h-4 w-4" />
Publish
</Button>
</form>
{/if}
<form method="POST" action="?/delete" use:enhance={handleDelete}>
<Button type="submit" variant="ghost" size="sm" class="gap-1">
<Trash2 class="h-4 w-4 text-red-600" />
Delete
{/if}
{/snippet}
{#snippet actions()}
{#if sol.is_published}
<form method="POST" action="?/unpublish" use:enhance={handleUnpublish}>
<Button type="submit" variant="outline" size="sm" class="gap-1">
<EyeOff class="h-4 w-4" />
Unpublish
</Button>
</form>
</div>
</div>
{:else}
<form method="POST" action="?/publish" use:enhance={handlePublish}>
<Button
type="submit"
size="sm"
class="gap-1"
disabled={sol.status !== 'approved'}
title={sol.status !== 'approved'
? 'Set status to Approved first'
: 'Publish to the knowledge base'}
>
<Eye class="h-4 w-4" />
Publish
</Button>
</form>
{/if}
<form method="POST" action="?/delete" use:enhance={handleDelete}>
<Button type="submit" variant="ghost" size="sm" class="gap-1">
<Trash2 class="h-4 w-4 text-red-600" />
Delete
</Button>
</form>
{/snippet}
</PageHeader>
<div class="flex flex-col gap-4 p-4">
<div class="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_320px]">
<form
method="POST"

View file

@ -2,8 +2,9 @@
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import { toast } from 'svelte-sonner';
import { ChevronLeft, BookOpen, Loader2 } from '@lucide/svelte';
import { Loader2 } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button/index.js';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
/** @type {{ data: any, form: any }} */
let { data, form } = $props();
@ -30,25 +31,12 @@
<title>New Solution - BottleCRM</title>
</svelte:head>
<PageHeader
title="New solution"
breadcrumb={[{ label: 'Knowledge Base', href: '/solutions' }, { label: 'New solution' }]}
/>
<div class="mx-auto flex max-w-2xl flex-col gap-4 p-4">
<div class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<button
type="button"
onclick={() => goto('/solutions')}
class="inline-flex items-center gap-1 hover:text-[var(--text-primary)]"
>
<ChevronLeft class="h-4 w-4" />
Knowledge Base
</button>
<span>/</span>
<span class="font-medium text-[var(--text-primary)]">New solution</span>
</div>
<div class="flex items-center gap-2">
<BookOpen class="h-5 w-5 text-[var(--text-secondary)]" />
<h1 class="text-lg font-semibold">New solution</h1>
</div>
<form method="POST" use:enhance={handleSubmit} class="flex flex-col gap-4 rounded-md border border-[var(--border-default)] bg-[var(--surface-default)] p-4">
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Title <span class="text-red-600">*</span></span>

View file

@ -8,7 +8,6 @@
Heart,
Users,
ExternalLink,
Sparkles,
ArrowRight,
Zap,
Code2,
@ -16,6 +15,7 @@
} from '@lucide/svelte';
import { GithubIcon as Github } from '$lib/components/icons';
import { Button } from '$lib/components/ui/button/index.js';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
/** @type {{ data: import('./$types').PageData }} */
let { data } = $props();
@ -25,25 +25,13 @@
<title>Support - BottleCRM</title>
</svelte:head>
<div class="support-page">
<!-- Hero Section with Gradient Mesh -->
<header class="hero noise-overlay">
<div class="hero-glow"></div>
<div class="hero-content">
<div class="hero-badge animate-in-up">
<Sparkles class="h-3.5 w-3.5" />
<span>Open Source • Forever Free</span>
</div>
<h1 class="hero-title animate-in-up stagger-1">
Help & <span class="text-gradient">Support</span>
</h1>
<p class="hero-description animate-in-up stagger-2">
Join thousands of businesses using BottleCRM to manage customer relationships without the
enterprise price tag.
</p>
</div>
</header>
<PageHeader
title="Help & Support"
subtitle="Join thousands of businesses using BottleCRM to manage customer relationships without the enterprise price tag."
size="display"
/>
<div class="support-page">
<!-- Mission Card - Featured -->
<section class="content-area">
<div class="mission-card glass-card animate-in-up stagger-3">
@ -240,85 +228,6 @@
background: var(--background);
}
/* ─────────────────────────────────────────────────────────────────────────
HERO SECTION
───────────────────────────────────────────────────────────────────────── */
.hero {
position: relative;
padding: 3rem 1.5rem 2.5rem;
overflow: hidden;
background:
radial-gradient(ellipse 80% 50% at 50% -20%, var(--accent-primary-subtle), transparent),
radial-gradient(ellipse 60% 40% at 80% 0%, var(--accent-secondary-subtle), transparent),
var(--background);
}
:global(.dark) .hero {
background:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(34, 211, 238, 0.08), transparent),
radial-gradient(ellipse 60% 40% at 80% 0%, rgba(251, 191, 36, 0.05), transparent),
var(--background);
}
.hero-glow {
position: absolute;
top: -50%;
left: 50%;
transform: translateX(-50%);
width: 600px;
height: 400px;
background: radial-gradient(circle, var(--accent-primary) 0%, transparent 70%);
opacity: 0.04;
filter: blur(60px);
pointer-events: none;
}
:global(.dark) .hero-glow {
opacity: 0.08;
}
.hero-content {
position: relative;
max-width: 680px;
margin: 0 auto;
text-align: center;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.875rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent-primary);
background: var(--accent-primary-subtle);
border: 1px solid color-mix(in oklch, var(--accent-primary) 20%, transparent);
border-radius: var(--radius-full);
margin-bottom: 1rem;
}
.hero-title {
font-family: var(--font-sans);
font-size: clamp(2rem, 5vw, 2.75rem);
font-weight: 800;
letter-spacing: -0.03em;
line-height: 1.1;
color: var(--text-primary);
margin-bottom: 0.75rem;
}
.hero-description {
font-size: 1rem;
line-height: 1.6;
color: var(--text-secondary);
max-width: 520px;
margin: 0 auto;
}
/* ─────────────────────────────────────────────────────────────────────────
CONTENT AREA
───────────────────────────────────────────────────────────────────────── */

View file

@ -27,11 +27,10 @@
Tag,
Contact,
Link2,
Filter,
Columns
} from '@lucide/svelte';
import { TaskKanban } from '$lib/components/ui/task-kanban';
import { PageHeader } from '$lib/components/layout';
import { PageHeader, FilterStrip, ViewTabs, StatusBar, FilterPill } from '$lib/components/layout';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
@ -39,7 +38,6 @@
import { CommentSection } from '$lib/components/ui/comment-section';
import { getCurrentUser } from '$lib/api.js';
import {
FilterBar,
SearchInput,
SelectFilter,
DateRangeFilter,
@ -418,9 +416,6 @@
// Status chip filter state (client-side quick filter on top of server filters)
let statusChipFilter = $state('ALL');
// Filter panel expansion state
let filtersExpanded = $state(false);
// Filtered tasks - server already applies main filters, just apply status chip
const filteredTasks = $derived.by(() => {
let filtered = tasks;
@ -439,6 +434,10 @@
localTasks = [...filteredTasks];
});
// Active row (highlighted in the table) — null until the user clicks a row.
/** @type {string | null} */
let activeRowId = $state(null);
// Row detail sheet state
let sheetOpen = $state(false);
/** @type {string | null} */
@ -1084,6 +1083,7 @@
<title>Tasks - BottleCRM</title>
</svelte:head>
<div class="flex flex-col">
<PageHeader title="Tasks" subtitle="{filteredTasks.length} of {tasks.length} tasks">
{#snippet actions()}
<div class="flex items-center gap-2">
@ -1175,24 +1175,6 @@
</Button>
</div>
<!-- Filter Toggle Button -->
<Button
variant={filtersExpanded ? 'secondary' : 'outline'}
size="sm"
class="gap-2"
onclick={() => (filtersExpanded = !filtersExpanded)}
>
<Filter class="h-4 w-4" />
Filters
{#if activeFiltersCount > 0}
<span
class="rounded-full bg-[var(--color-primary-light)] px-1.5 py-0.5 text-xs font-medium text-[var(--color-primary-default)]"
>
{activeFiltersCount}
</span>
{/if}
</Button>
{#if viewMode === 'list'}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
@ -1233,17 +1215,13 @@
</Button>
</div>
{/snippet}
{#snippet tabs()}
<ViewTabs views={[{ id: 'all', label: 'All', count: pagination.total }]} active="all" />
{/snippet}
</PageHeader>
<div class="flex-1">
<!-- Collapsible Filter Bar -->
<FilterBar
minimal={true}
expanded={filtersExpanded}
activeCount={activeFiltersCount}
onClear={clearFilters}
class="pb-4"
>
<FilterStrip>
<SearchInput
value={filters.search}
onchange={(value) => updateFilters({ ...filters, search: value })}
@ -1267,12 +1245,19 @@
value={filters.tags}
onchange={(ids) => updateFilters({ ...filters, tags: ids })}
/>
</FilterBar>
{#if activeFiltersCount > 0}
<FilterPill label="Clear all" dashed onclick={clearFilters} />
{/if}
{#snippet meta()}
<span>{filteredTasks.length} of {pagination.total} tasks</span>
{/snippet}
</FilterStrip>
{#if viewMode === 'list'}
<CrmTable
data={localTasks}
columns={taskColumns}
bind:visibleColumns
bind:activeRowId
onRowChange={handleRowChange}
onRowClick={handleRowClick}
>
@ -1511,6 +1496,11 @@
/>
</div>
{#if viewMode === 'list'}
<StatusBar status="{filteredTasks.length} of {pagination.total} tasks" />
{/if}
</div>
<!-- Task Detail Drawer -->
<CrmDrawer
bind:open={sheetOpen}

View file

@ -1,6 +1,7 @@
<script>
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
export let data;
@ -100,15 +101,13 @@
}
</script>
<div class="board-container">
<header class="board-header">
<a href="/tasks" class="back-link">Back to Boards</a>
<h1>{data.board.name}</h1>
{#if data.board.description}
<p class="board-description">{data.board.description}</p>
{/if}
</header>
<PageHeader
title={data.board.name}
subtitle={data.board.description || ''}
breadcrumb={[{ label: 'Tasks', href: '/tasks' }, { label: data.board.name }]}
/>
<div class="board-container">
<div class="kanban-board">
{#each data.columns as column (column.id)}
<div
@ -269,35 +268,6 @@
background: #f4f5f7;
}
.board-header {
margin-bottom: 1.5rem;
}
.back-link {
color: #5e6c84;
text-decoration: none;
font-size: 0.9rem;
display: inline-block;
margin-bottom: 0.5rem;
}
.back-link:hover {
color: #0079bf;
}
.board-header h1 {
font-size: 1.8rem;
font-weight: 700;
color: #172b4d;
margin: 0;
}
.board-description {
color: #5e6c84;
margin-top: 0.5rem;
font-size: 0.95rem;
}
.kanban-board {
display: flex;
gap: 1rem;

View file

@ -24,13 +24,12 @@
} from '@lucide/svelte';
import { TicketKanban } from '$lib/components/ui/ticket-kanban';
import { Button } from '$lib/components/ui/button/index.js';
import { PageHeader } from '$lib/components/layout';
import { PageHeader, FilterStrip, ViewTabs, StatusBar, FilterPill } from '$lib/components/layout';
import { CrmTable } from '$lib/components/ui/crm-table';
import { CrmDrawer } from '$lib/components/ui/crm-drawer';
import { BulkActionBar } from '$lib/components/ui/bulk-action-bar';
import { TicketStatusChips, TicketListActions } from '$lib/components/tickets';
import {
FilterBar,
SearchInput,
SelectFilter,
DateRangeFilter,
@ -489,9 +488,6 @@
/** @type {string[]} */
let selectedIds = $state([]);
// Filter panel expansion state
let filtersExpanded = $state(false);
// Filtered tickets - server already applies main filters, just apply status chip
const filteredTickets = $derived.by(() => {
let filtered = ticketsData;
@ -503,6 +499,10 @@
return filtered;
});
// Active row (highlighted in the table) — null until the user clicks a row.
/** @type {string | null} */
let activeRowId = $state(null);
// Form references for server actions
/** @type {HTMLFormElement} */
let createForm;
@ -706,6 +706,7 @@
<title>Tickets - BottleCRM</title>
</svelte:head>
<div class="flex flex-col">
<PageHeader title="Tickets" subtitle="{filteredTickets.length} of {ticketsData.length} tickets">
{#snippet actions()}
<div class="flex items-center gap-2">
@ -747,30 +748,26 @@
<div class="bg-border mx-1 h-6 w-px"></div>
<TicketListActions
{viewMode}
{filtersExpanded}
filtersExpanded={false}
{activeFiltersCount}
{columns}
visibleCount={columnCounts.visible}
totalCount={columnCounts.total}
{isColumnVisible}
onViewMode={updateViewMode}
onToggleFilters={() => (filtersExpanded = !filtersExpanded)}
onToggleFilters={() => {}}
onToggleColumn={toggleColumn}
onCreate={drawer.openCreate}
/>
</div>
{/snippet}
{#snippet tabs()}
<ViewTabs views={[{ id: 'all', label: 'All', count: pagination.total }]} active="all" />
{/snippet}
</PageHeader>
<div class="flex-1">
<!-- Collapsible Filter Bar -->
<FilterBar
minimal={true}
expanded={filtersExpanded}
activeCount={activeFiltersCount}
onClear={clearFilters}
class="pb-4"
>
<FilterStrip>
<SearchInput
value={filters.search}
onchange={(value) => updateFilters({ ...filters, search: value })}
@ -800,7 +797,13 @@
value={filters.tags}
onchange={(ids) => updateFilters({ ...filters, tags: ids })}
/>
</FilterBar>
{#if activeFiltersCount > 0}
<FilterPill label="Clear all" dashed onclick={clearFilters} />
{/if}
{#snippet meta()}
<span>{filteredTickets.length} of {pagination.total} tickets</span>
{/snippet}
</FilterStrip>
{#if viewMode === 'list'}
<CrmTable
@ -808,6 +811,7 @@
{columns}
bind:visibleColumns
bind:selectedIds
bind:activeRowId
selectable={true}
onRowChange={handleRowChange}
onRowClick={(row) => goto(`/tickets/${row.id}`)}
@ -869,6 +873,9 @@
{/if}
</div>
<StatusBar status="{filteredTickets.length} of {pagination.total} tickets" />
</div>
<BulkActionBar
count={selectedIds.length}
onClear={() => (selectedIds = [])}

View file

@ -15,6 +15,7 @@
} from '@lucide/svelte';
import Sparkline from '$lib/components/tickets/analytics/Sparkline.svelte';
import HBarChart from '$lib/components/tickets/analytics/HBarChart.svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
/** @type {{ data: import('./$types').PageData }} */
let { data } = $props();
@ -68,22 +69,21 @@
}
</script>
<div class="space-y-6 p-6">
<header class="flex items-center justify-between">
<div class="flex items-center gap-2">
<BarChart3 class="size-5 text-[var(--color-primary-default)]" />
<h1 class="text-xl font-semibold tracking-tight text-[var(--text-primary)]">
Tickets Analytics
</h1>
</div>
<PageHeader title="Tickets Analytics">
{#snippet titleIcon()}
<BarChart3 class="size-4" />
{/snippet}
{#snippet actions()}
<a
href="/tickets"
class="text-xs font-medium text-[var(--text-secondary)] hover:underline"
>
← Back to tickets
</a>
</header>
{/snippet}
</PageHeader>
<div class="space-y-6 p-6">
<!-- Filter bar -->
<section
class="flex flex-wrap items-end gap-3 rounded-lg border border-[var(--border-default)] bg-[var(--surface-default)] p-4"

View file

@ -3,6 +3,7 @@
import { page } from '$app/state';
import { toast } from 'svelte-sonner';
import { ShieldCheck, Loader2, Check, X, Ban } from '@lucide/svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import { Button } from '$lib/components/ui/button/index.js';
/** @type {{ data: any }} */
@ -113,12 +114,13 @@
<title>Approvals - BottleCRM</title>
</svelte:head>
<div class="flex flex-col gap-4 p-4">
<header class="flex items-center gap-2">
<ShieldCheck class="h-5 w-5 text-[var(--text-secondary)]" />
<h1 class="text-xl font-semibold">Approvals</h1>
</header>
<PageHeader title="Approvals">
{#snippet titleIcon()}
<ShieldCheck class="size-4" />
{/snippet}
</PageHeader>
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-wrap items-center gap-3 text-sm">
<div class="inline-flex rounded-md border border-[var(--border-default)] p-0.5">
<button

View file

@ -2,6 +2,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ChevronLeft, ChevronRight, Clock } from '@lucide/svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import { Button } from '$lib/components/ui/button/index.js';
/** @type {{ data: any }} */
@ -50,24 +51,24 @@
<title>Timesheet - BottleCRM</title>
</svelte:head>
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="flex items-center gap-2 text-xl font-semibold">
<Clock class="h-5 w-5" /> Timesheet
</h1>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" onclick={() => shiftWeek(-7)} class="gap-1">
<ChevronLeft class="h-3.5 w-3.5" /> Prev
</Button>
<span class="font-mono text-sm text-[var(--text-secondary)]">
{data.start} → {data.end}
</span>
<Button variant="outline" size="sm" onclick={() => shiftWeek(7)} class="gap-1">
Next <ChevronRight class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<PageHeader title="Timesheet">
{#snippet titleIcon()}
<Clock class="size-4" />
{/snippet}
{#snippet actions()}
<Button variant="outline" size="sm" onclick={() => shiftWeek(-7)} class="gap-1">
<ChevronLeft class="h-3.5 w-3.5" /> Prev
</Button>
<span class="font-mono text-sm text-[var(--text-secondary)]">
{data.start} → {data.end}
</span>
<Button variant="outline" size="sm" onclick={() => shiftWeek(7)} class="gap-1">
Next <ChevronRight class="h-3.5 w-3.5" />
</Button>
{/snippet}
</PageHeader>
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-wrap items-center gap-4 rounded-lg border border-[var(--border-default)] bg-[var(--surface-default)] p-3 text-sm">
<span><span class="text-[var(--text-secondary)]">Total:</span> <strong>{formatMinutes(totalMinutes)}</strong></span>
<span><span class="text-[var(--text-secondary)]">Billable:</span> <strong>{formatMinutes(billableMinutes)}</strong></span>

View file

@ -15,7 +15,7 @@
AlertCircle,
UserCheck
} from '@lucide/svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import { PageHeader, ViewTabs, StatusBar } from '$lib/components/layout';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
@ -223,7 +223,11 @@
<title>Users & Teams - BottleCRM</title>
</svelte:head>
<PageHeader title="Users & Teams" subtitle="Manage users and teams in your organization" />
<PageHeader title="Users & Teams" subtitle="Manage users and teams in your organization">
{#snippet tabs()}
<ViewTabs views={[{ id: 'all', label: 'All', count: users.length }]} active="all" />
{/snippet}
</PageHeader>
<div class="flex-1 space-y-6 p-4 md:p-6">
<!-- Error Message -->
@ -538,6 +542,8 @@
{/if}
</div>
<StatusBar status="{users.length} user{users.length !== 1 ? 's' : ''}" />
<!-- Team Form Dialog -->
<TeamFormDialog
bind:open={teamDialogOpen}