feat: implement frontend UI redesign, expand component library, and update case implementation documentation.
This commit is contained in:
parent
3825b34189
commit
f4f677ebec
84 changed files with 4866 additions and 2173 deletions
|
|
@ -66,6 +66,7 @@ class LeadSerializer(serializers.ModelSerializer):
|
|||
# System
|
||||
"created_by",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"is_active",
|
||||
"company_name",
|
||||
# Kanban
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) -->
|
||||
|
|
|
|||
|
|
@ -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
46
frontend/src/lib/components/layout/FilterPill.svelte
Normal file
46
frontend/src/lib/components/layout/FilterPill.svelte
Normal 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>
|
||||
21
frontend/src/lib/components/layout/FilterStrip.svelte
Normal file
21
frontend/src/lib/components/layout/FilterStrip.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
40
frontend/src/lib/components/layout/StatusBar.svelte
Normal file
40
frontend/src/lib/components/layout/StatusBar.svelte
Normal 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>
|
||||
32
frontend/src/lib/components/layout/ViewTabs.svelte
Normal file
32
frontend/src/lib/components/layout/ViewTabs.svelte
Normal 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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
22
frontend/src/lib/components/ui/avatar/avatar-gradients.js
Normal file
22
frontend/src/lib/components/ui/avatar/avatar-gradients.js
Normal 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];
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -11,3 +11,4 @@ export {
|
|||
Image as AvatarImage,
|
||||
Fallback as AvatarFallback
|
||||
};
|
||||
export { gradientFor, AVATAR_GRADIENTS, WORKSPACE_GRADIENT } from './avatar-gradients.js';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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?.()}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
83
frontend/src/lib/components/ui/form-shell/FormShell.svelte
Normal file
83
frontend/src/lib/components/ui/form-shell/FormShell.svelte
Normal 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}
|
||||
1
frontend/src/lib/components/ui/form-shell/index.js
Normal file
1
frontend/src/lib/components/ui/form-shell/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as FormShell } from './FormShell.svelte';
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
73
frontend/src/lib/components/ui/side-panel/SidePanel.svelte
Normal file
73
frontend/src/lib/components/ui/side-panel/SidePanel.svelte
Normal 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}
|
||||
|
|
@ -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>
|
||||
27
frontend/src/lib/components/ui/side-panel/index.js
Normal file
27
frontend/src/lib/components/ui/side-panel/index.js
Normal 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';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
1
frontend/src/lib/components/ui/stage-stepper/index.js
Normal file
1
frontend/src/lib/components/ui/stage-stepper/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as StageStepper } from './StageStepper.svelte';
|
||||
6
frontend/src/lib/components/ui/switch/index.js
Normal file
6
frontend/src/lib/components/ui/switch/index.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import Root from './switch.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Root as Switch
|
||||
};
|
||||
48
frontend/src/lib/components/ui/switch/switch.svelte
Normal file
48
frontend/src/lib/components/ui/switch/switch.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
27
frontend/src/lib/components/ui/timeline/Timeline.svelte
Normal file
27
frontend/src/lib/components/ui/timeline/Timeline.svelte
Normal 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}
|
||||
55
frontend/src/lib/components/ui/timeline/TimelineItem.svelte
Normal file
55
frontend/src/lib/components/ui/timeline/TimelineItem.svelte
Normal 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>
|
||||
2
frontend/src/lib/components/ui/timeline/index.js
Normal file
2
frontend/src/lib/components/ui/timeline/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as Timeline } from './Timeline.svelte';
|
||||
export { default as TimelineItem } from './TimelineItem.svelte';
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
43
frontend/src/routes/(app)/leads/[id]/+page.server.js
Normal file
43
frontend/src/routes/(app)/leads/[id]/+page.server.js
Normal 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');
|
||||
}
|
||||
}
|
||||
732
frontend/src/routes/(app)/leads/[id]/+page.svelte
Normal file
732
frontend/src/routes/(app)/leads/[id]/+page.svelte
Normal 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>
|
||||
92
frontend/src/routes/(app)/leads/[id]/edit/+page.server.js
Normal file
92
frontend/src/routes/(app)/leads/[id]/edit/+page.server.js
Normal 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}`);
|
||||
}
|
||||
};
|
||||
308
frontend/src/routes/(app)/leads/[id]/edit/+page.svelte
Normal file
308
frontend/src/routes/(app)/leads/[id]/edit/+page.svelte
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
39
frontend/src/routes/(app)/opportunities/[id]/+page.server.js
Normal file
39
frontend/src/routes/(app)/opportunities/[id]/+page.server.js
Normal 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');
|
||||
}
|
||||
}
|
||||
325
frontend/src/routes/(app)/opportunities/[id]/+page.svelte
Normal file
325
frontend/src/routes/(app)/opportunities/[id]/+page.svelte
Normal 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>
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
};
|
||||
147
frontend/src/routes/(app)/opportunities/[id]/edit/+page.svelte
Normal file
147
frontend/src/routes/(app)/opportunities/[id]/edit/+page.svelte
Normal 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>
|
||||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
───────────────────────────────────────────────────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = [])}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue