From f4f677ebec4b8bb1304eef373d97f7e8a4ddcad5 Mon Sep 17 00:00:00 2001 From: ashwin Date: Mon, 11 May 2026 17:24:01 +0530 Subject: [PATCH] feat: implement frontend UI redesign, expand component library, and update case implementation documentation. --- backend/leads/serializer.py | 1 + frontend/src/app.css | 261 ++++- frontend/src/app.html | 4 +- .../src/lib/components/layout/AppShell.svelte | 5 +- .../lib/components/layout/AppSidebar.svelte | 962 +++++++++++++----- .../lib/components/layout/FilterPill.svelte | 46 + .../lib/components/layout/FilterStrip.svelte | 21 + .../lib/components/layout/PageHeader.svelte | 127 ++- .../lib/components/layout/StatusBar.svelte | 40 + .../src/lib/components/layout/ViewTabs.svelte | 32 + frontend/src/lib/components/layout/index.js | 4 + .../ui/avatar/avatar-fallback.svelte | 31 +- .../components/ui/avatar/avatar-gradients.js | 22 + .../lib/components/ui/avatar/avatar.svelte | 18 +- .../src/lib/components/ui/avatar/index.js | 1 + .../src/lib/components/ui/badge/badge.svelte | 44 +- .../lib/components/ui/button/button.svelte | 58 +- .../components/ui/card/card-content.svelte | 20 +- .../lib/components/ui/card/card-header.svelte | 4 +- .../lib/components/ui/card/card-title.svelte | 6 +- .../src/lib/components/ui/card/card.svelte | 4 +- .../ui/comment-section/CommentSection.svelte | 3 +- .../components/ui/crm-drawer/CrmDrawer.svelte | 233 ++--- .../ui/crm-drawer/CrmPropertyRow.svelte | 441 +++++--- .../components/ui/crm-table/CrmTable.svelte | 69 +- .../editable-field/EditableMultiSelect.svelte | 132 ++- .../ui/filter/DateRangeFilter.svelte | 170 +--- .../components/ui/filter/SearchInput.svelte | 162 +-- .../components/ui/filter/SelectFilter.svelte | 193 +--- .../lib/components/ui/filter/TagFilter.svelte | 206 +--- .../components/ui/form-shell/FormShell.svelte | 83 ++ .../src/lib/components/ui/form-shell/index.js | 1 + .../src/lib/components/ui/input/input.svelte | 28 +- .../components/ui/kanban/KanbanBoard.svelte | 8 +- .../ui/select/select-content.svelte | 15 +- .../components/ui/select/select-item.svelte | 14 +- .../ui/select/select-trigger.svelte | 18 +- .../components/ui/side-panel/SidePanel.svelte | 73 ++ .../ui/side-panel/SidePanelSection.svelte | 24 + .../src/lib/components/ui/side-panel/index.js | 27 + .../lib/components/ui/sidebar/constants.js | 4 +- .../ui/sidebar/sidebar-menu-sub-button.svelte | 5 +- .../ui/stage-stepper/StageStepper.svelte | 74 ++ .../lib/components/ui/stage-stepper/index.js | 1 + .../src/lib/components/ui/switch/index.js | 6 + .../lib/components/ui/switch/switch.svelte | 48 + .../lib/components/ui/tabs/tabs-list.svelte | 4 +- .../components/ui/tabs/tabs-trigger.svelte | 14 +- .../components/ui/textarea/textarea.svelte | 13 +- .../components/ui/timeline/Timeline.svelte | 27 + .../ui/timeline/TimelineItem.svelte | 55 + .../src/lib/components/ui/timeline/index.js | 2 + frontend/src/routes/(app)/+layout.svelte | 10 +- frontend/src/routes/(app)/+page.svelte | 49 +- .../src/routes/(app)/accounts/+page.svelte | 81 +- .../src/routes/(app)/contacts/+page.svelte | 169 +-- frontend/src/routes/(app)/goals/+page.svelte | 30 +- .../src/routes/(app)/invoices/+page.svelte | 47 +- .../routes/(app)/invoices/[id]/+page.svelte | 208 ++-- .../routes/(app)/invoices/new/+page.svelte | 48 +- .../src/routes/(app)/leads/+page.server.js | 9 +- frontend/src/routes/(app)/leads/+page.svelte | 178 +++- .../routes/(app)/leads/[id]/+page.server.js | 43 + .../src/routes/(app)/leads/[id]/+page.svelte | 732 +++++++++++++ .../(app)/leads/[id]/edit/+page.server.js | 92 ++ .../routes/(app)/leads/[id]/edit/+page.svelte | 308 ++++++ .../routes/(app)/opportunities/+page.svelte | 65 +- .../(app)/opportunities/[id]/+page.server.js | 39 + .../(app)/opportunities/[id]/+page.svelte | 325 ++++++ .../opportunities/[id]/edit/+page.server.js | 68 ++ .../opportunities/[id]/edit/+page.svelte | 147 +++ .../routes/(app)/settings/tags/+page.svelte | 34 +- .../settings/ticket-approvals/+page.svelte | 16 +- .../src/routes/(app)/solutions/+page.svelte | 24 +- .../routes/(app)/solutions/[id]/+page.svelte | 108 +- .../routes/(app)/solutions/new/+page.svelte | 26 +- .../src/routes/(app)/support/+page.svelte | 105 +- frontend/src/routes/(app)/tasks/+page.svelte | 56 +- .../(app)/tasks/board/[boardId]/+page.svelte | 44 +- .../src/routes/(app)/tickets/+page.svelte | 39 +- .../(app)/tickets/analytics/+page.svelte | 18 +- .../(app)/tickets/approvals/+page.svelte | 12 +- .../src/routes/(app)/timesheet/+page.svelte | 35 +- frontend/src/routes/(app)/users/+page.svelte | 10 +- 84 files changed, 4866 insertions(+), 2173 deletions(-) create mode 100644 frontend/src/lib/components/layout/FilterPill.svelte create mode 100644 frontend/src/lib/components/layout/FilterStrip.svelte create mode 100644 frontend/src/lib/components/layout/StatusBar.svelte create mode 100644 frontend/src/lib/components/layout/ViewTabs.svelte create mode 100644 frontend/src/lib/components/ui/avatar/avatar-gradients.js create mode 100644 frontend/src/lib/components/ui/form-shell/FormShell.svelte create mode 100644 frontend/src/lib/components/ui/form-shell/index.js create mode 100644 frontend/src/lib/components/ui/side-panel/SidePanel.svelte create mode 100644 frontend/src/lib/components/ui/side-panel/SidePanelSection.svelte create mode 100644 frontend/src/lib/components/ui/side-panel/index.js create mode 100644 frontend/src/lib/components/ui/stage-stepper/StageStepper.svelte create mode 100644 frontend/src/lib/components/ui/stage-stepper/index.js create mode 100644 frontend/src/lib/components/ui/switch/index.js create mode 100644 frontend/src/lib/components/ui/switch/switch.svelte create mode 100644 frontend/src/lib/components/ui/timeline/Timeline.svelte create mode 100644 frontend/src/lib/components/ui/timeline/TimelineItem.svelte create mode 100644 frontend/src/lib/components/ui/timeline/index.js create mode 100644 frontend/src/routes/(app)/leads/[id]/+page.server.js create mode 100644 frontend/src/routes/(app)/leads/[id]/+page.svelte create mode 100644 frontend/src/routes/(app)/leads/[id]/edit/+page.server.js create mode 100644 frontend/src/routes/(app)/leads/[id]/edit/+page.svelte create mode 100644 frontend/src/routes/(app)/opportunities/[id]/+page.server.js create mode 100644 frontend/src/routes/(app)/opportunities/[id]/+page.svelte create mode 100644 frontend/src/routes/(app)/opportunities/[id]/edit/+page.server.js create mode 100644 frontend/src/routes/(app)/opportunities/[id]/edit/+page.svelte diff --git a/backend/leads/serializer.py b/backend/leads/serializer.py index fa898e85..4fa35637 100644 --- a/backend/leads/serializer.py +++ b/backend/leads/serializer.py @@ -66,6 +66,7 @@ class LeadSerializer(serializers.ModelSerializer): # System "created_by", "created_at", + "updated_at", "is_active", "company_name", # Kanban diff --git a/frontend/src/app.css b/frontend/src/app.css index 7824963c..2e39d816 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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'; +} diff --git a/frontend/src/app.html b/frontend/src/app.html index 232f23ee..d6ed27e9 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -1,11 +1,11 @@ - + diff --git a/frontend/src/lib/components/layout/AppShell.svelte b/frontend/src/lib/components/layout/AppShell.svelte index ed4b226f..42399169 100644 --- a/frontend/src/lib/components/layout/AppShell.svelte +++ b/frontend/src/lib/components/layout/AppShell.svelte @@ -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 @@ - + {@render children?.()} diff --git a/frontend/src/lib/components/layout/AppSidebar.svelte b/frontend/src/lib/components/layout/AppSidebar.svelte index d1364559..dfa947de 100644 --- a/frontend/src/lib/components/layout/AppSidebar.svelte +++ b/frontend/src/lib/components/layout/AppSidebar.svelte @@ -2,12 +2,12 @@ import { untrack } from 'svelte'; import { page } from '$app/stores'; import { afterNavigate, goto } from '$app/navigation'; - import imgLogo from '$lib/assets/images/logo.png'; import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; import * as Collapsible from '$lib/components/ui/collapsible/index.js'; import { Button } from '$lib/components/ui/button/index.js'; + import { Avatar, AvatarFallback, gradientFor } from '$lib/components/ui/avatar/index.js'; import { LayoutDashboard, @@ -47,17 +47,27 @@ Clock, MessageSquareQuote, ShieldCheck, - BookOpen + BookOpen, + Activity, + Smartphone } from '@lucide/svelte'; + import { Bell } from '$lib/components/notifications/index.js'; /** * @typedef {Object} Props * @property {Object} user - User object * @property {string} [org_name] - Organization name + * @property {Object} [org_settings] - Org settings (tier, currency, etc.) */ /** @type {Props} */ - let { user = {}, org_name = 'BottleCRM' } = $props(); + let { user = {}, org_name = 'BottleCRM', org_settings = {} } = $props(); + + // Tier badge — hidden when JWT doesn't carry one (spec §8 "No tier in JWT") + const tier = $derived(org_settings?.tier ?? null); + + // Workspace gradient seed — first-letter monogram fallback for the avatar + const orgInitial = $derived((org_name || 'B').charAt(0).toUpperCase()); const sidebar = Sidebar.useSidebar(); @@ -163,107 +173,62 @@ } }); - // HubSpot-style navigation structure with sections - const crmItems = [ - { - href: '/', - label: 'Dashboard', - icon: Home, - type: 'link', - preload: 'off' - }, - { - href: '/leads', - label: 'Leads', - icon: Target, - type: 'link', - preload: 'off' - }, - { - href: '/contacts', - label: 'Contacts', - icon: Users, - type: 'link', - preload: 'off' - }, - { - href: '/accounts', - label: 'Accounts', - icon: Building, - type: 'link', - preload: 'off' - }, - { - href: '/opportunities', - label: 'Deals', - icon: Sparkles, - type: 'link', - preload: 'off' - }, - { - href: '/goals', - label: 'Goals', - icon: Trophy, - type: 'link', - preload: 'off' - }, + // Five-section IA per spec §4 + const workspaceItems = [ + { href: '/', label: 'Home', icon: Home, type: 'link', preload: 'off', count: undefined }, + { href: '/leads', label: 'Pipeline', icon: Activity, type: 'link', preload: 'off', count: undefined } + ]; + + const recordsItems = [ + { href: '/leads', label: 'Leads', icon: Target, type: 'link', preload: 'off', count: undefined }, + { href: '/contacts', label: 'Contacts', icon: Users, type: 'link', preload: 'off', count: undefined }, + { href: '/accounts', label: 'Accounts', icon: Building, type: 'link', preload: 'off', count: undefined }, + { href: '/opportunities', label: 'Deals', icon: Sparkles, type: 'link', preload: 'off', count: undefined } + ]; + + const workItems = [ { key: 'tickets', label: 'Tickets', icon: Briefcase, type: 'dropdown', + count: undefined, children: [ - { href: '/tickets', label: 'All tickets', icon: Briefcase, preload: 'off' }, - { href: '/tickets/approvals', label: 'Approvals', icon: ShieldCheck, preload: 'off' }, - { href: '/tickets/analytics', label: 'Analytics', icon: BarChart3, preload: 'off' }, - { href: '/solutions', label: 'Knowledge Base', icon: BookOpen, preload: 'off' } + { href: '/tickets', label: 'All tickets', icon: Briefcase, preload: 'off', count: undefined }, + { href: '/tickets/approvals', label: 'Approvals', icon: ShieldCheck, preload: 'off', count: undefined }, + { href: '/tickets/analytics', label: 'Analytics', icon: BarChart3, preload: 'off', count: undefined }, + { href: '/solutions', label: 'Knowledge base', icon: BookOpen, preload: 'off', count: undefined } ] }, - { - href: '/tasks', - label: 'Tasks', - icon: CheckSquare, - type: 'link', - preload: 'off' - }, - { - href: '/timesheet', - label: 'Timesheet', - icon: Clock, - type: 'link', - preload: 'off' - } + { href: '/tasks', label: 'Tasks', icon: CheckSquare, type: 'link', preload: 'off', count: undefined }, + { href: '/timesheet', label: 'Timesheet', icon: Clock, type: 'link', preload: 'off', count: undefined }, + { href: '/goals', label: 'Goals', icon: Trophy, type: 'link', preload: 'off', count: undefined } ]; - const salesItems = [ + const revenueItems = [ { key: 'invoices', label: 'Invoices', icon: FileText, type: 'dropdown', + count: undefined, children: [ - { href: '/invoices', label: 'All Invoices', icon: FileText, preload: 'off' }, - { href: '/invoices/estimates', label: 'Estimates', icon: FileEdit, preload: 'off' }, - { href: '/invoices/products', label: 'Products', icon: Package, preload: 'off' }, - { href: '/invoices/recurring', label: 'Recurring', icon: RefreshCw, preload: 'off' }, - { href: '/invoices/templates', label: 'Templates', icon: FileCode, preload: 'off' }, - { href: '/invoices/reports', label: 'Reports', icon: BarChart3, preload: 'off' } + { href: '/invoices', label: 'All Invoices', icon: FileText, preload: 'off', count: undefined }, + { href: '/invoices/estimates', label: 'Estimates', icon: FileEdit, preload: 'off', count: undefined }, + { href: '/invoices/products', label: 'Products', icon: Package, preload: 'off', count: undefined }, + { href: '/invoices/recurring', label: 'Recurring', icon: RefreshCw, preload: 'off', count: undefined }, + { href: '/invoices/templates', label: 'Templates', icon: FileCode, preload: 'off', count: undefined }, + { href: '/invoices/reports', label: 'Reports', icon: BarChart3, preload: 'off', count: undefined } ] } ]; const supportItems = [ - { - href: '/support', - label: 'Help Desk', - icon: HelpCircle, - type: 'link', - preload: 'off' - } + { href: '/support', label: 'Help desk', icon: HelpCircle, type: 'link', preload: 'off', count: undefined } ]; - // Combine for iteration - const navigationItems = [...crmItems, ...salesItems, ...supportItems]; + // Combine for the auto-open-on-active effect (which scans dropdown items) + const navigationItems = [...workspaceItems, ...recordsItems, ...workItems, ...revenueItems, ...supportItems]; /** * Check if any child route is active @@ -288,46 +253,56 @@ collapsible="icon" class="hubspot-sidebar border-sidebar-border/60 bg-sidebar border-r" > - - - - - - {#snippet child({ props })} - - -
+ + + +
+ +
+
- - + + - CRM + Workspace - - {#each crmItems as item} + + {#each workspaceItems as item} {#if item.type === 'dropdown' && item.children} {#snippet child({ props: btnProps })} - {/snippet} @@ -366,28 +354,33 @@ {#each item.children as navChild} {#snippet child({ props })} - - + + {#if currentPath === navChild.href} + + {/if} {navChild.label} + class="flex-1 truncate text-[12px] {currentPath === navChild.href ? 'font-semibold' : 'font-medium'}" + >{navChild.label} + {#if navChild.count !== undefined && navChild.count !== null} + {navChild.count} + {/if} {/snippet} @@ -402,30 +395,37 @@ {#snippet child({ props })} + {#if currentPath === item.href} + + {/if} {item.label} + class="flex-1 truncate text-[13px] group-data-[collapsible=icon]:hidden {currentPath === item.href ? 'font-semibold' : 'font-medium'}" + >{item.label} + {#if item.count !== undefined && item.count !== null} + {item.count} + {/if} {/snippet} @@ -436,16 +436,16 @@ - - + + - Sales + Records - - {#each salesItems as item} + + {#each recordsItems as item} {#if item.type === 'dropdown' && item.children} {#snippet child({ props: btnProps })} - {/snippet} @@ -484,28 +497,33 @@ {#each item.children as navChild} {#snippet child({ props })} - - + + {#if currentPath === navChild.href} + + {/if} {navChild.label} + class="flex-1 truncate text-[12px] {currentPath === navChild.href ? 'font-semibold' : 'font-medium'}" + >{navChild.label} + {#if navChild.count !== undefined && navChild.count !== null} + {navChild.count} + {/if} {/snippet} @@ -515,6 +533,332 @@ + {:else} + + + {#snippet child({ props })} + + {#if currentPath === item.href} + + {/if} + + {item.label} + {#if item.count !== undefined && item.count !== null} + {item.count} + {/if} + + {/snippet} + + + {/if} + {/each} + + + + + + + + Work + + + + {#each workItems as item} + {#if item.type === 'dropdown' && item.children} + { + if (item.key) openDropdowns[item.key] = open; + }} + class="group/collapsible" + > + + + {#snippet child({ props })} + + {#snippet child({ props: btnProps })} + + {/snippet} + + {/snippet} + + + + {#each item.children as navChild} + + + {#snippet child({ props })} + + {#if currentPath === navChild.href} + + {/if} + {navChild.label} + {#if navChild.count !== undefined && navChild.count !== null} + {navChild.count} + {/if} + + {/snippet} + + + {/each} + + + + + {:else} + + + {#snippet child({ props })} + + {#if currentPath === item.href} + + {/if} + + {item.label} + {#if item.count !== undefined && item.count !== null} + {item.count} + {/if} + + {/snippet} + + + {/if} + {/each} + + + + + + + + Revenue + + + + {#each revenueItems as item} + {#if item.type === 'dropdown' && item.children} + { + if (item.key) openDropdowns[item.key] = open; + }} + class="group/collapsible" + > + + + {#snippet child({ props })} + + {#snippet child({ props: btnProps })} + + {/snippet} + + {/snippet} + + + + {#each item.children as navChild} + + + {#snippet child({ props })} + + {#if currentPath === navChild.href} + + {/if} + {navChild.label} + {#if navChild.count !== undefined && navChild.count !== null} + {navChild.count} + {/if} + + {/snippet} + + + {/each} + + + + + {:else} + + + {#snippet child({ props })} + + {#if currentPath === item.href} + + {/if} + + {item.label} + {#if item.count !== undefined && item.count !== null} + {item.count} + {/if} + + {/snippet} + + {/if} {/each} @@ -522,70 +866,200 @@ - + Support - + {#each supportItems as item} - - { + if (item.key) openDropdowns[item.key] = open; + }} + class="group/collapsible" > - {#snippet child({ props })} - - - {item.label} + + {#snippet child({ props })} + + {#snippet child({ props: btnProps })} + + {/snippet} + + {/snippet} + + + - - {/snippet} - - + {#each item.children as navChild} + + + {#snippet child({ props })} + + {#if currentPath === navChild.href} + + {/if} + {navChild.label} + {#if navChild.count !== undefined && navChild.count !== null} + {navChild.count} + {/if} + + {/snippet} + + + {/each} + + + + + {:else} + + + {#snippet child({ props })} + + {#if currentPath === item.href} + + {/if} + + {item.label} + {#if item.count !== undefined && item.count !== null} + {item.count} + {/if} + + {/snippet} + + + {/if} {/each} - - + + + + + + {#snippet child({ props })} + + + + + Download app + + + {/snippet} + + + sidebar.toggle()} tooltipContent={sidebar.state === 'collapsed' ? 'Expand sidebar' : 'Collapse sidebar'} - class="text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-foreground h-9 rounded-lg px-2.5 transition-all duration-150" + class="h-[30px] rounded-md pl-[18px] pr-[10px] text-[color:var(--sidebar-muted)] transition-colors duration-150 hover:bg-[color:var(--sidebar-accent)] hover:text-[color:var(--sidebar-foreground)] + group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:rounded-md group-data-[collapsible=icon]:px-0" > {#snippet child({ props })} - @@ -601,36 +1075,28 @@ - -
- {#if user.profilePhoto && !profileImageError} - User avatar (profileImageError = true)} - /> - {:else} -
+ {#if user.profilePhoto && !profileImageError} + User avatar (profileImageError = true)} + /> + {:else} + + {getInitials(user.name)} -
- {/if} - -
+ + + {/if} +
+ {user.name} + {user.email}
-
- {user.name} - {user.email} -
- + {/snippet} @@ -818,7 +1284,11 @@ sans-serif; } - :global(.hubspot-sidebar [data-slot='sidebar-menu-button']) { + :global(.hubspot-sidebar [data-sidebar='menu-button']) { font-weight: 500; + cursor: pointer; + } + :global(.hubspot-sidebar [data-sidebar='menu-sub-button']) { + cursor: pointer; } diff --git a/frontend/src/lib/components/layout/FilterPill.svelte b/frontend/src/lib/components/layout/FilterPill.svelte new file mode 100644 index 00000000..a60dde5d --- /dev/null +++ b/frontend/src/lib/components/layout/FilterPill.svelte @@ -0,0 +1,46 @@ + + + diff --git a/frontend/src/lib/components/layout/FilterStrip.svelte b/frontend/src/lib/components/layout/FilterStrip.svelte new file mode 100644 index 00000000..b0763fa7 --- /dev/null +++ b/frontend/src/lib/components/layout/FilterStrip.svelte @@ -0,0 +1,21 @@ + + +
+
+ {@render children?.()} +
+ {#if meta} +
+ {@render meta()} +
+ {/if} +
diff --git a/frontend/src/lib/components/layout/PageHeader.svelte b/frontend/src/lib/components/layout/PageHeader.svelte index d4e4c445..c4bcdcc0 100644 --- a/frontend/src/lib/components/layout/PageHeader.svelte +++ b/frontend/src/lib/components/layout/PageHeader.svelte @@ -1,60 +1,107 @@
- - + {#if breadcrumb.length > 0} + + {/if} -
- -
-
-

- {title} -

+
+
+ {#if titleIcon} +
+ {@render titleIcon()} +
+ {/if} +
+ {#if eyebrow} +

{eyebrow}

+ {/if} +

{title}

{#if subtitle} - +

{subtitle}

{/if}
- {#if subtitle} -

{subtitle}

- {/if}
- - - {#if actions} -
- {@render actions()} + {#if amount || actions} +
+ {#if amount} +
+ {@render amount()} +
+ {/if} + {#if actions} +
+ {@render actions()} +
+ {/if}
{/if}
- -
+ {#if meta} +
+ {@render meta()} +
+ {/if} + + {#if tabs} +
+ {@render tabs()} +
+ {/if}
diff --git a/frontend/src/lib/components/layout/StatusBar.svelte b/frontend/src/lib/components/layout/StatusBar.svelte new file mode 100644 index 00000000..832ef65e --- /dev/null +++ b/frontend/src/lib/components/layout/StatusBar.svelte @@ -0,0 +1,40 @@ + + +
+
+ {#if online} + + {/if} + {status} +
+ +
diff --git a/frontend/src/lib/components/layout/ViewTabs.svelte b/frontend/src/lib/components/layout/ViewTabs.svelte new file mode 100644 index 00000000..56b649f1 --- /dev/null +++ b/frontend/src/lib/components/layout/ViewTabs.svelte @@ -0,0 +1,32 @@ + + +
+ {#each views as v (v.id)} + {@const isActive = v.id === active} + + {/each} +
diff --git a/frontend/src/lib/components/layout/index.js b/frontend/src/lib/components/layout/index.js index 3de977d8..657c5821 100644 --- a/frontend/src/lib/components/layout/index.js +++ b/frontend/src/lib/components/layout/index.js @@ -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'; diff --git a/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte b/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte index a81c5a32..d910906c 100644 --- a/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte +++ b/frontend/src/lib/components/ui/avatar/avatar-fallback.svelte @@ -1,13 +1,40 @@ diff --git a/frontend/src/lib/components/ui/avatar/avatar-gradients.js b/frontend/src/lib/components/ui/avatar/avatar-gradients.js new file mode 100644 index 00000000..73283516 --- /dev/null +++ b/frontend/src/lib/components/ui/avatar/avatar-gradients.js @@ -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]; +} diff --git a/frontend/src/lib/components/ui/avatar/avatar.svelte b/frontend/src/lib/components/ui/avatar/avatar.svelte index b2297fb2..7959949a 100644 --- a/frontend/src/lib/components/ui/avatar/avatar.svelte +++ b/frontend/src/lib/components/ui/avatar/avatar.svelte @@ -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]' + ); diff --git a/frontend/src/lib/components/ui/avatar/index.js b/frontend/src/lib/components/ui/avatar/index.js index 9cf5cc18..a32de14d 100644 --- a/frontend/src/lib/components/ui/avatar/index.js +++ b/frontend/src/lib/components/ui/avatar/index.js @@ -11,3 +11,4 @@ export { Image as AvatarImage, Fallback as AvatarFallback }; +export { gradientFor, AVATAR_GRADIENTS, WORKSPACE_GRADIENT } from './avatar-gradients.js'; diff --git a/frontend/src/lib/components/ui/badge/badge.svelte b/frontend/src/lib/components/ui/badge/badge.svelte index fc8fa27b..7f2fde39 100644 --- a/frontend/src/lib/components/ui/badge/badge.svelte +++ b/frontend/src/lib/components/ui/badge/badge.svelte @@ -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']); + {#if showDot} + + {/if} {@render children?.()} diff --git a/frontend/src/lib/components/ui/button/button.svelte b/frontend/src/lib/components/ui/button/button.svelte index 30fefd40..49172201 100644 --- a/frontend/src/lib/components/ui/button/button.svelte +++ b/frontend/src/lib/components/ui/button/button.svelte @@ -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: { diff --git a/frontend/src/lib/components/ui/card/card-content.svelte b/frontend/src/lib/components/ui/card/card-content.svelte index c1e4424c..6f9f876f 100644 --- a/frontend/src/lib/components/ui/card/card-content.svelte +++ b/frontend/src/lib/components/ui/card/card-content.svelte @@ -1,17 +1,17 @@ -
+
{@render children?.()}
diff --git a/frontend/src/lib/components/ui/card/card-header.svelte b/frontend/src/lib/components/ui/card/card-header.svelte index 28c41aeb..2036687c 100644 --- a/frontend/src/lib/components/ui/card/card-header.svelte +++ b/frontend/src/lib/components/ui/card/card-header.svelte @@ -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} diff --git a/frontend/src/lib/components/ui/card/card-title.svelte b/frontend/src/lib/components/ui/card/card-title.svelte index c0517323..f3c810b0 100644 --- a/frontend/src/lib/components/ui/card/card-title.svelte +++ b/frontend/src/lib/components/ui/card/card-title.svelte @@ -1,13 +1,15 @@
{@render children?.()} diff --git a/frontend/src/lib/components/ui/card/card.svelte b/frontend/src/lib/components/ui/card/card.svelte index 4287dd43..49f95e17 100644 --- a/frontend/src/lib/components/ui/card/card.svelte +++ b/frontend/src/lib/components/ui/card/card.svelte @@ -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} diff --git a/frontend/src/lib/components/ui/comment-section/CommentSection.svelte b/frontend/src/lib/components/ui/comment-section/CommentSection.svelte index be197ae5..dcd374da 100644 --- a/frontend/src/lib/components/ui/comment-section/CommentSection.svelte +++ b/frontend/src/lib/components/ui/comment-section/CommentSection.svelte @@ -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); diff --git a/frontend/src/lib/components/ui/crm-drawer/CrmDrawer.svelte b/frontend/src/lib/components/ui/crm-drawer/CrmDrawer.svelte index 793fca4d..7cf77e5d 100644 --- a/frontend/src/lib/components/ui/crm-drawer/CrmDrawer.svelte +++ b/frontend/src/lib/components/ui/crm-drawer/CrmDrawer.svelte @@ -1,5 +1,5 @@ onOpenChange?.(value)}> New {/if}
- +
+ {#if fullPageHref && mode !== 'create'} + + + {/if} + +
+ {#if banner} + {@render banner()} + {/if} +
{#if titleEditable} {:else}

{titleValue || titlePlaceholder}

{/if} + {#if metaSection && mode !== 'create'} +
+ {@render metaSection()} +
+ {/if}
- {#if enableProgressiveDisclosure && essentialColumns.length > 0} - -
- {#each essentialColumns as col (col.key)} - handleFieldChange(col.key, value)} - /> - {/each} -
- - - {#if hiddenFieldsCount > 0 || showAllFields} - - {/if} - - - {#if visibleAdditionalColumns.length > 0} -
- {#each visibleAdditionalColumns as col (col.key)} - handleFieldChange(col.key, value)} - /> - {/each} -
- {/if} - {:else} - -
- {#each columns as col (col.key)} - {#if col.key !== titleKey} - handleFieldChange(col.key, value)} - /> - {/if} - {/each} -
- {/if} +
+ {#each visibleColumns as col (col.key)} + handleFieldChange(col.key, value)} + /> + {/each} +
diff --git a/frontend/src/lib/components/ui/crm-drawer/CrmPropertyRow.svelte b/frontend/src/lib/components/ui/crm-drawer/CrmPropertyRow.svelte index 7ad16d7d..b6953122 100644 --- a/frontend/src/lib/components/ui/crm-drawer/CrmPropertyRow.svelte +++ b/frontend/src/lib/components/ui/crm-drawer/CrmPropertyRow.svelte @@ -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 @@ } -
- +
- {#if Icon} -
- -
- {/if} - {label} -
- - -
- {#if type === 'text' || type === 'email'} - - {:else if type === 'number'} -
- {#if prefix} - {prefix} - {/if} - -
- {:else if type === 'date'} - - - {#snippet child({ props })} - - {/snippet} - - - - - - {:else if type === 'textarea'} - - {:else if type === 'select'} - - - {#snippet child({ props })} - - {/snippet} - - - {#each options as option (option.value)} - handleSelectChange(option.value)} - class="flex items-center gap-2.5 rounded-lg px-2.5 py-2" - > - - {option.label} - {#if value === option.value} - - {/if} - - {/each} - - - {:else if type === 'checkbox' || type === 'boolean'} - - {:else if type === 'color'} -
- - {value || '#3B82F6'} -
- {:else if type === 'multiselect'} - - {:else if type === 'readonly'} - - {#if value} - {value} - {:else} - {emptyText || '—'} + + + + +
+ {#if isPlainReadonly} + + {#if value !== null && value !== undefined && value !== ''} + + {type === 'number' ? formatNumber(value) : value} + + {:else} + {emptyText || '—'} + {/if} + + {:else if type === 'text' || type === 'email'} + + {:else if type === 'number'} +
+ {#if prefix} + + {/if} + +
+ {:else if isReadonlyDate} + + {#if value} + {formatDateDisplay(value)} + {:else} + {emptyText || '—'} + {/if} + + {:else if type === 'date'} + + + {#snippet child({ props })} + + {/snippet} + + + + + + {:else if type === 'textarea'} + + {:else if isReadonlySelect} + + + {getOptionLabel(value)} + + {:else if type === 'select'} + + + {#snippet child({ props })} + + {/snippet} + + + {#each options as option (option.value)} + handleSelectChange(option.value)} + class="flex items-center gap-2.5 rounded-lg px-2.5 py-2" + > + + {option.label} + {#if value === option.value} + + {/each} + + + {:else if type === 'checkbox' || type === 'boolean'} + + {:else if type === 'color'} +
+ + {value || '#3B82F6'} +
+ {:else if type === 'multiselect'} + + {:else if type === 'readonly'} + + {#if value} + {value} + {:else} + {emptyText || '—'} + {/if} + + {/if} +
+ + {#if error} + + {/if}
diff --git a/frontend/src/lib/components/ui/crm-table/CrmTable.svelte b/frontend/src/lib/components/ui/crm-table/CrmTable.svelte index d352b8e5..6936d9f7 100644 --- a/frontend/src/lib/components/ui/crm-table/CrmTable.svelte +++ b/frontend/src/lib/components/ui/crm-table/CrmTable.svelte @@ -1,8 +1,7 @@ -
- {#if label} - - {label} - - {/if} - { if (!o) selectingEnd = false; @@ -183,87 +177,31 @@ {#snippet child({ props })} {/snippet} @@ -271,15 +209,7 @@
@@ -379,57 +309,3 @@
-
- - diff --git a/frontend/src/lib/components/ui/filter/SearchInput.svelte b/frontend/src/lib/components/ui/filter/SearchInput.svelte index a5a0e711..0fe73ec5 100644 --- a/frontend/src/lib/components/ui/filter/SearchInput.svelte +++ b/frontend/src/lib/components/ui/filter/SearchInput.svelte @@ -1,14 +1,10 @@ -
- {#if showLabel && label} -
- - diff --git a/frontend/src/lib/components/ui/filter/SelectFilter.svelte b/frontend/src/lib/components/ui/filter/SelectFilter.svelte index a2055f07..8ec2e82e 100644 --- a/frontend/src/lib/components/ui/filter/SelectFilter.svelte +++ b/frontend/src/lib/components/ui/filter/SelectFilter.svelte @@ -1,5 +1,5 @@ -
- {#if label} - - {label} - - {/if} - + {#snippet child({ props })} {/snippet} @@ -176,15 +131,7 @@ {#if !multiple} @@ -263,77 +210,3 @@
-
- - -{#if multiple && selectedValues.length > 0} -
- {#each selectedValues as val, i} - {@const opt = options.find((o) => o.value === val)} - - {opt?.label || val} - - - {/each} -
-{/if} - - diff --git a/frontend/src/lib/components/ui/filter/TagFilter.svelte b/frontend/src/lib/components/ui/filter/TagFilter.svelte index 76cfa9ce..bc29fb0a 100644 --- a/frontend/src/lib/components/ui/filter/TagFilter.svelte +++ b/frontend/src/lib/components/ui/filter/TagFilter.svelte @@ -26,7 +26,7 @@ tags = [], value = $bindable([]), placeholder = 'Filter by tags...', - label = 'Tags', + label: _label = '', class: className, onchange } = $props(); @@ -87,104 +87,51 @@ } -
- {#if label} - - {label} - - {/if} - + {#snippet child({ props })} {/snippet} @@ -192,15 +139,7 @@ {#if tags.length === 0}
@@ -292,78 +231,3 @@ {/if} -
- - -{#if selectedTags.length > 0} -
- {#each selectedTags as tag, i (tag.id)} - - - {tag.name} - - - {/each} -
-{/if} - - diff --git a/frontend/src/lib/components/ui/form-shell/FormShell.svelte b/frontend/src/lib/components/ui/form-shell/FormShell.svelte new file mode 100644 index 00000000..bf7adc93 --- /dev/null +++ b/frontend/src/lib/components/ui/form-shell/FormShell.svelte @@ -0,0 +1,83 @@ + + +{#snippet body()} +
+ {@render children()} +
+ +
+
+ {#if errorMessage} +

{errorMessage}

+ {:else} + + {/if} +
+ {@render actions()} +
+
+
+{/snippet} + +{#if useEnhance} +
+ {@render body()} +
+{:else} +
+ {@render body()} +
+{/if} diff --git a/frontend/src/lib/components/ui/form-shell/index.js b/frontend/src/lib/components/ui/form-shell/index.js new file mode 100644 index 00000000..05fb6068 --- /dev/null +++ b/frontend/src/lib/components/ui/form-shell/index.js @@ -0,0 +1 @@ +export { default as FormShell } from './FormShell.svelte'; diff --git a/frontend/src/lib/components/ui/input/input.svelte b/frontend/src/lib/components/ui/input/input.svelte index 4b435533..209fb6a0 100644 --- a/frontend/src/lib/components/ui/input/input.svelte +++ b/frontend/src/lib/components/ui/input/input.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(' '); {#if type === 'file'} { if (data?.columns) { localColumns = structuredClone(data.columns); diff --git a/frontend/src/lib/components/ui/select/select-content.svelte b/frontend/src/lib/components/ui/select/select-content.svelte index 862948ad..a5c5f870 100644 --- a/frontend/src/lib/components/ui/select/select-content.svelte +++ b/frontend/src/lib/components/ui/select/select-content.svelte @@ -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} diff --git a/frontend/src/lib/components/ui/select/select-item.svelte b/frontend/src/lib/components/ui/select/select-item.svelte index 6e13afdd..04d27f8d 100644 --- a/frontend/src/lib/components/ui/select/select-item.svelte +++ b/frontend/src/lib/components/ui/select/select-item.svelte @@ -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 })} {#if selected} - + {/if} {#if childrenProp} diff --git a/frontend/src/lib/components/ui/select/select-trigger.svelte b/frontend/src/lib/components/ui/select/select-trigger.svelte index fed084c4..ac19018c 100644 --- a/frontend/src/lib/components/ui/select/select-trigger.svelte +++ b/frontend/src/lib/components/ui/select/select-trigger.svelte @@ -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?.()} - + diff --git a/frontend/src/lib/components/ui/side-panel/SidePanel.svelte b/frontend/src/lib/components/ui/side-panel/SidePanel.svelte new file mode 100644 index 00000000..ad0561eb --- /dev/null +++ b/frontend/src/lib/components/ui/side-panel/SidePanel.svelte @@ -0,0 +1,73 @@ + + +{#if $sidePanelHidden} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/ui/side-panel/SidePanelSection.svelte b/frontend/src/lib/components/ui/side-panel/SidePanelSection.svelte new file mode 100644 index 00000000..0e0b6ef8 --- /dev/null +++ b/frontend/src/lib/components/ui/side-panel/SidePanelSection.svelte @@ -0,0 +1,24 @@ + + +
+

+ {label} +

+
+ {@render children()} +
+
diff --git a/frontend/src/lib/components/ui/side-panel/index.js b/frontend/src/lib/components/ui/side-panel/index.js new file mode 100644 index 00000000..158a0b92 --- /dev/null +++ b/frontend/src/lib/components/ui/side-panel/index.js @@ -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'; diff --git a/frontend/src/lib/components/ui/sidebar/constants.js b/frontend/src/lib/components/ui/sidebar/constants.js index 2d3bbfbc..232e1e16 100644 --- a/frontend/src/lib/components/ui/sidebar/constants.js +++ b/frontend/src/lib/components/ui/sidebar/constants.js @@ -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'; diff --git a/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte index 5d6cec60..be79195a 100644 --- a/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte +++ b/frontend/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte @@ -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 ), diff --git a/frontend/src/lib/components/ui/stage-stepper/StageStepper.svelte b/frontend/src/lib/components/ui/stage-stepper/StageStepper.svelte new file mode 100644 index 00000000..e69e1532 --- /dev/null +++ b/frontend/src/lib/components/ui/stage-stepper/StageStepper.svelte @@ -0,0 +1,74 @@ + + +
+ {#each stages as stage, i (stage.value)} + {@const state = stateAt(i)} +
+ + {stage.label} + + + {state === 'future' ? '—' : (stage.meta || '—')} + +
+ {/each} +
diff --git a/frontend/src/lib/components/ui/stage-stepper/index.js b/frontend/src/lib/components/ui/stage-stepper/index.js new file mode 100644 index 00000000..58c3de89 --- /dev/null +++ b/frontend/src/lib/components/ui/stage-stepper/index.js @@ -0,0 +1 @@ +export { default as StageStepper } from './StageStepper.svelte'; diff --git a/frontend/src/lib/components/ui/switch/index.js b/frontend/src/lib/components/ui/switch/index.js new file mode 100644 index 00000000..fc8a724e --- /dev/null +++ b/frontend/src/lib/components/ui/switch/index.js @@ -0,0 +1,6 @@ +import Root from './switch.svelte'; + +export { + Root, + Root as Switch +}; diff --git a/frontend/src/lib/components/ui/switch/switch.svelte b/frontend/src/lib/components/ui/switch/switch.svelte new file mode 100644 index 00000000..ae103251 --- /dev/null +++ b/frontend/src/lib/components/ui/switch/switch.svelte @@ -0,0 +1,48 @@ + + + + + diff --git a/frontend/src/lib/components/ui/tabs/tabs-list.svelte b/frontend/src/lib/components/ui/tabs/tabs-list.svelte index 74746481..9b2d4f0e 100644 --- a/frontend/src/lib/components/ui/tabs/tabs-list.svelte +++ b/frontend/src/lib/components/ui/tabs/tabs-list.svelte @@ -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} diff --git a/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte b/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte index e124635f..ada56f3b 100644 --- a/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte +++ b/frontend/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -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} diff --git a/frontend/src/lib/components/ui/textarea/textarea.svelte b/frontend/src/lib/components/ui/textarea/textarea.svelte index ec14d221..0d76e7b4 100644 --- a/frontend/src/lib/components/ui/textarea/textarea.svelte +++ b/frontend/src/lib/components/ui/textarea/textarea.svelte @@ -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 diff --git a/frontend/src/lib/components/ui/timeline/Timeline.svelte b/frontend/src/lib/components/ui/timeline/Timeline.svelte new file mode 100644 index 00000000..7b640a0b --- /dev/null +++ b/frontend/src/lib/components/ui/timeline/Timeline.svelte @@ -0,0 +1,27 @@ + + +{#if isEmpty} +
+ {#if empty} + {@render empty()} + {:else} + No activity yet. + {/if} +
+{:else} +
    + {@render children?.()} +
+{/if} diff --git a/frontend/src/lib/components/ui/timeline/TimelineItem.svelte b/frontend/src/lib/components/ui/timeline/TimelineItem.svelte new file mode 100644 index 00000000..6cb260e0 --- /dev/null +++ b/frontend/src/lib/components/ui/timeline/TimelineItem.svelte @@ -0,0 +1,55 @@ + + +
  • + +
    +
    + {@render text()} +
    + {#if quote} +
    + {quote} +
    + {/if} + {#if time} +

    {time}

    + {/if} +
    +
  • diff --git a/frontend/src/lib/components/ui/timeline/index.js b/frontend/src/lib/components/ui/timeline/index.js new file mode 100644 index 00000000..5b4130bf --- /dev/null +++ b/frontend/src/lib/components/ui/timeline/index.js @@ -0,0 +1,2 @@ +export { default as Timeline } from './Timeline.svelte'; +export { default as TimelineItem } from './TimelineItem.svelte'; diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte index 1bf85516..b659bf9d 100644 --- a/frontend/src/routes/(app)/+layout.svelte +++ b/frontend/src/routes/(app)/+layout.svelte @@ -1,7 +1,6 @@ - -
    -
    -
    - -
    -
    + +
    {@render children()}
    diff --git a/frontend/src/routes/(app)/+page.svelte b/frontend/src/routes/(app)/+page.svelte index 707dea6e..ac23780c 100644 --- a/frontend/src/routes/(app)/+page.svelte +++ b/frontend/src/routes/(app)/+page.svelte @@ -1,5 +1,5 @@ Dashboard - BottleCRM -
    - -
    +
    +
    +

    Today · {today}

    +
    - -
    -
    -
    -
    -
    - -
    -
    -

    {getGreeting()}

    -

    - Dashboard -

    -
    -
    -

    - Here's what's happening with your CRM today. Stay on top of your pipeline and close more - deals. -

    -
    -
    -
    - {#if data.error}
    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 @@ Accounts - BottleCRM +
    {#snippet actions()}
    @@ -796,26 +807,6 @@
    -
    - - - - @@ -854,17 +845,13 @@
    {/snippet} + {#snippet tabs()} + + {/snippet}
    - - + updateFilters({ ...filters, search: value })} @@ -888,7 +875,13 @@ value={filters.tags} onchange={(ids) => updateFilters({ ...filters, tags: ids })} /> - + {#if activeFiltersCount > 0} + + {/if} + {#snippet meta()} + {filteredAccounts.length} of {pagination.total} accounts + {/snippet} + {#if filteredAccounts.length === 0}
    @@ -909,6 +902,7 @@ data={filteredAccounts} {columns} bind:visibleColumns + bind:activeRowId onRowClick={(row) => openAccount(row)} > {#snippet emptyState()} @@ -995,6 +989,9 @@ />
    + +
    + 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 @@ Contacts - BottleCRM - +
    + {#snippet actions()} -
    - - + {/snippet} + + + Toggle columns + + {#each columns as column (column.key)} + toggleColumn(column.key)} + disabled={column.canHide === false} > - {activeFiltersCount} - - {/if} - - - - - - {#snippet child({ props })} - - {/snippet} - - - Toggle columns - - {#each columns as column (column.key)} - toggleColumn(column.key)} - disabled={column.canHide === false} - > - {column.label} - - {/each} - - - - -
    + {column.label} + + {/each} + + + + {/snippet} + {#snippet tabs()} + {/snippet}
    - - + updateFilters({ ...filters, search: value })} - class="w-64" + class="w-56" /> updateFilters({ ...filters, created_at_gte: start, created_at_lte: end })} - class="w-56" /> updateFilters({ ...filters, tags: ids })} /> - + {#if activeFiltersCount > 0} + + {/if} + {#snippet meta()} + {filteredContacts.length} of {contacts.length} contacts + {/snippet} + {#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 @@ />
    + +
    + Sales Goals - BottleCRM -
    - -
    -
    -
    - -
    -
    -

    Sales Goals

    -

    - Track targets and measure team performance -

    -
    -
    - + + {#snippet titleIcon()} + + {/snippet} + {#snippet actions()} {#if isAdmin} {/if} -
    + {/snippet} + +
    {#each [ diff --git a/frontend/src/routes/(app)/invoices/+page.svelte b/frontend/src/routes/(app)/invoices/+page.svelte index 1b5302b3..0d2a568a 100644 --- a/frontend/src/routes/(app)/invoices/+page.svelte +++ b/frontend/src/routes/(app)/invoices/+page.svelte @@ -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 @@
    - - - @@ -392,17 +373,15 @@
    {/snippet} + {#snippet tabs()} + + {/snippet} - - + + updateFilters({ ...filters, due_date_gte: start, due_date_lte: end })} /> - + {#if activeFiltersCount > 0} + + {/if} + {#snippet meta()} + {invoices.length} of {pagination.total} invoices + {/snippet} + @@ -491,3 +476,5 @@ /> {/if}
    + + diff --git a/frontend/src/routes/(app)/invoices/[id]/+page.svelte b/frontend/src/routes/(app)/invoices/[id]/+page.svelte index 0df6b05a..920a30c8 100644 --- a/frontend/src/routes/(app)/invoices/[id]/+page.svelte +++ b/frontend/src/routes/(app)/invoices/[id]/+page.svelte @@ -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 @@ }} > -
    - -
    -
    -
    - + -
    -
    -

    {invoice.invoiceNumber || 'Invoice'}

    - - - {(invoice.status || 'Draft').replace('_', ' ')} - - - {#each INVOICE_STATUS_OPTIONS as option} - - - - {option.label} - - - {/each} - - -
    -

    {invoice.clientName || 'No client name'}

    -
    -
    -
    - {#if isEditing} - - + + {#if invoice.status === 'Draft'} + - {:else} - - - {#if invoice.status === 'Draft'} - - {/if} - - {#if invoice.status !== 'Cancelled' && invoice.status !== 'Paid'} - - - {#snippet child({ props })} - - {/snippet} - - - - Cancel Invoice? - - This will cancel invoice {invoice.invoiceNumber}. Cancelled invoices cannot be - sent or edited. - - - - Keep Invoice - - - - - {/if} {/if} -
    -
    -
    + + {#if invoice.status !== 'Cancelled' && invoice.status !== 'Paid'} + + + {#snippet child({ props })} + + {/snippet} + + + + Cancel Invoice? + + This will cancel invoice {invoice.invoiceNumber}. Cancelled invoices cannot be + sent or edited. + + + + Keep Invoice + + + + + {/if} + {/if} + {/snippet} + +
    +
    {#if currentTab === 'view'} diff --git a/frontend/src/routes/(app)/invoices/new/+page.svelte b/frontend/src/routes/(app)/invoices/new/+page.svelte index bdd5e21f..e31b3bc0 100644 --- a/frontend/src/routes/(app)/invoices/new/+page.svelte +++ b/frontend/src/routes/(app)/invoices/new/+page.svelte @@ -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'); - } @@ -287,32 +283,24 @@ /> -
    - -
    -
    -
    - -
    -

    New Invoice

    -

    Create a new invoice for your client

    -
    -
    -
    - - -
    -
    -
    + + {#snippet actions()} + + + {/snippet} + +
    diff --git a/frontend/src/routes/(app)/leads/+page.server.js b/frontend/src/routes/(app)/leads/+page.server.js index 08d731e6..ef4c2ce7 100644 --- a/frontend/src/routes/(app)/leads/+page.server.js +++ b/frontend/src/routes/(app)/leads/+page.server.js @@ -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 diff --git a/frontend/src/routes/(app)/leads/+page.svelte b/frontend/src/routes/(app)/leads/+page.svelte index deed0e24..991923b8 100644 --- a/frontend/src/routes/(app)/leads/+page.svelte +++ b/frontend/src/routes/(app)/leads/+page.svelte @@ -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 @@ Leads - BottleCRM - +
    - - - {#if viewMode === 'table'} @@ -1568,17 +1597,13 @@
    {/snippet} + {#snippet tabs()} + + {/snippet}
    - - + updateFilters({ ...filters, tags: ids })} /> - + {#if activeFiltersCount > 0} + + {/if} + {#snippet meta()} + {filteredLeads.length} of {pagination.total} leads + {/snippet} + {#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}
    + +
    + + {#snippet banner()} + {#if drawerLoadError} + + {/if} + {/snippet} + + {#snippet metaSection()} + {#if drawerData} +
    + {#if drawerData.owner} +
    + + + {drawerData.owner.name || drawerData.owner.email} + +
    + {/if} + {#if drawerData.createdAt} + + + Created {formatDate(drawerData.createdAt)} + + {/if} + {#if drawerData.updatedAt && drawerData.updatedAt !== drawerData.createdAt} + + + Updated {formatRelativeDate(drawerData.updatedAt)} + + {/if} +
    + {/if} + {/snippet} + {#snippet activitySection()} {#if drawerMode !== 'create' && drawerData} / + * 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'); + } +} diff --git a/frontend/src/routes/(app)/leads/[id]/+page.svelte b/frontend/src/routes/(app)/leads/[id]/+page.svelte new file mode 100644 index 00000000..230de89e --- /dev/null +++ b/frontend/src/routes/(app)/leads/[id]/+page.svelte @@ -0,0 +1,732 @@ + + + + {fullName} · BottleCRM + + + + {#snippet meta()} +
    + {#if lead?.created_at || lead?.created_on} + Created {formatRelativeDate(lead.created_at || lead.created_on)} + {/if} + {#if lead?.company_name} + · + + + {lead.company_name} + + {/if} + {#if assignedUsers.length > 0} + · + + + {getNameInitials(assignedUsers[0].email, '')} + + {assignedUsers[0].email}{#if assignedUsers.length > 1} + +{assignedUsers.length - 1} + {/if} + + {/if} +
    + {/snippet} + + {#snippet actions()} +
    + {#if lead?.email} + + {/if} + {#if lead?.phone} + + {/if} + + + +
    + {/snippet} +
    + + +
    + +
    + + + + + Overview + + Activity + + {timelineItems.length} + + + + Files + + {attachments.length} + + + + + +
    + +
    + + + + About + + + {#if lead?.description} +

    + {lead.description} +

    + {:else} +

    No description.

    + {/if} +
    +
    + + + {#if hasDeal} + + Deal + +
    +
    +
    + Deal Value +
    +
    + {lead?.opportunity_amount != null + ? formatCurrency(lead.opportunity_amount, lead?.currency || 'USD') + : '—'} +
    +
    +
    +
    + Probability +
    +
    + {#if probability !== null} +
    + {probability}% +
    +
    +
    +
    + {:else} + — + {/if} +
    +
    +
    +
    + Close Date +
    +
    + {lead?.close_date ? formatDate(lead.close_date) : '—'} +
    +
    +
    +
    +
    + {/if} + + + {#if hasContactLinks} + + Contact + +
    + {#if lead?.email} +
    +
    + {/if} + {#if lead?.phone} +
    +
    + {/if} + {#if lead?.job_title} +
    +
    + {/if} + {#if lead?.website} +
    +
    + {/if} + {#if lead?.linkedin_url} +
    +
    + {/if} +
    +
    +
    + {/if} + + + {#if hasAddress} + + Address + +
    +
    +
    +
    + {/if} + + + + + Activity + + + + {#each timelineItems as item (item.id)} + {#if item.kind === 'comment'} + + {#snippet icon()}{/snippet} + {#snippet text()} + {item.payload.commented_by_user || 'Someone'} commented + {/snippet} + + {:else if item.kind === 'attachment'} + + {#snippet icon()}{/snippet} + {#snippet text()} + {item.payload.created_by_user || 'Someone'} uploaded + {item.payload.file_name || 'a file'} + {/snippet} + + {:else} + + {#snippet icon()}{/snippet} + {#snippet text()}Lead created{/snippet} + + {/if} + {/each} + + + +
    + + +
    + + Details + +
    +
    +
    Status
    +
    + {#if normalizedStatus} + + {getOptionLabel(normalizedStatus, leadStatusOptions)} + + {:else} + — + {/if} +
    +
    +
    +
    Rating
    +
    + {#if normalizedRating} + + + {getOptionLabel(normalizedRating, leadRatingOptions)} + + {:else} + — + {/if} +
    +
    +
    +
    Source
    +
    + {sourceLabel || '—'} +
    +
    +
    +
    Industry
    +
    + {industryLabel || '—'} +
    +
    +
    +
    Company
    +
    + {lead?.company_name || '—'} +
    +
    +
    +
    Created by
    +
    + {lead?.created_by?.email || '—'} +
    +
    +
    +
    Updated
    +
    + {lead?.updated_at ? formatRelativeDate(lead.updated_at) : '—'} +
    +
    +
    +
    +
    + + + + People + +
    +
    +
    + Assigned to +
    + {#if assignedUsers.length === 0} +

    Unassigned

    + {:else} +
      + {#each assignedUsers as user (user.id)} +
    • + + {getNameInitials(user.email, '')} + + {user.email} +
    • + {/each} +
    + {/if} +
    + {#if teams.length > 0} +
    +
    + Teams +
    +
    + {#each teams as team (team.id)} + + {team.name} + + {/each} +
    +
    + {/if} +
    +
    +
    + + + {#if lead?.last_contacted || lead?.next_follow_up} + + Dates + +
    + {#if lead?.last_contacted} +
    +
    Last contact
    +
    + {formatRelativeDate(lead.last_contacted)} +
    +
    + {/if} + {#if lead?.next_follow_up} +
    +
    Next follow-up
    +
    + {formatDate(lead.next_follow_up)} +
    +
    + {/if} +
    +
    +
    + {/if} + + + Tags + + {#if tags.length === 0} +

    No tags.

    + {:else} +
    + {#each tags as tag, i (tag.id ?? tag.slug ?? tag.name ?? i)} + + {tag.name} + + {/each} +
    + {/if} +
    +
    +
    +
    +
    + + +
    + + All activity + + + {#each timelineItems as item (item.id)} + {#if item.kind === 'comment'} + + {#snippet icon()}{/snippet} + {#snippet text()}{item.payload.commented_by_user || 'Someone'} commented{/snippet} + + {:else if item.kind === 'attachment'} + + {#snippet icon()}{/snippet} + {#snippet text()}{item.payload.created_by_user || 'Someone'} + uploaded {item.payload.file_name || 'a file'}{/snippet} + + {:else} + + {#snippet icon()}{/snippet} + {#snippet text()}Lead created{/snippet} + + {/if} + {/each} + + + +
    +
    + + +
    + + Files + + {#if attachments.length === 0} +

    No files uploaded.

    + {:else} +
      + {#each attachments as a (a.id)} +
    • + + + {a.file_name || 'File'} + + + {a.created_on ? formatRelativeDate(a.created_on) : ''} + +
    • + {/each} +
    + {/if} +
    +
    +
    +
    +
    diff --git a/frontend/src/routes/(app)/leads/[id]/edit/+page.server.js b/frontend/src/routes/(app)/leads/[id]/edit/+page.server.js new file mode 100644 index 00000000..687aa1de --- /dev/null +++ b/frontend/src/routes/(app)/leads/[id]/edit/+page.server.js @@ -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} */ + 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}`); + } +}; diff --git a/frontend/src/routes/(app)/leads/[id]/edit/+page.svelte b/frontend/src/routes/(app)/leads/[id]/edit/+page.svelte new file mode 100644 index 00000000..3b85996f --- /dev/null +++ b/frontend/src/routes/(app)/leads/[id]/edit/+page.svelte @@ -0,0 +1,308 @@ + + + + Edit {displayName} - BottleCRM + + + + + + {#snippet children()} + +
    +

    Basics

    + +
    + + +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +

    Contact

    + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    +
    + + +
    +

    Company

    + +
    + + +
    + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    +
    + + +
    +

    Classification

    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +

    Opportunity

    + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +

    Address

    + +
    + + +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    +
    + + +
    +

    Activity

    + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    +
    + {/snippet} + + {#snippet actions()} + + + {/snippet} +
    diff --git a/frontend/src/routes/(app)/opportunities/+page.svelte b/frontend/src/routes/(app)/opportunities/+page.svelte index 96ec151d..43aa47a5 100644 --- a/frontend/src/routes/(app)/opportunities/+page.svelte +++ b/frontend/src/routes/(app)/opportunities/+page.svelte @@ -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 @@ Opportunities - BottleCRM +
    {#snippet actions()}
    @@ -1213,24 +1214,6 @@
    - - - @@ -1270,17 +1253,14 @@
    {/snippet} + {#snippet tabs()} + + {/snippet}
    - - + + updateFilters({ ...filters, search: value })} @@ -1305,7 +1285,13 @@ value={filters.tags} onchange={(ids) => updateFilters({ ...filters, tags: ids })} /> - + {#if activeFiltersCount > 0} + + {/if} + {#snippet meta()} + {filteredOpportunities.length} of {pagination.total} opportunities + {/snippet} + {#if filteredOpportunities.length === 0}
    @@ -1324,6 +1310,7 @@ data={filteredOpportunities} {columns} bind:visibleColumns + bind:activeRowId onRowChange={handleRowChange} onRowClick={(row) => openDrawer(row.id)} > @@ -1350,6 +1337,9 @@ />
    + +
    + {#if drawerMode !== 'create' && selectedRow} + + Open full detail + +

    Details diff --git a/frontend/src/routes/(app)/opportunities/[id]/+page.server.js b/frontend/src/routes/(app)/opportunities/[id]/+page.server.js new file mode 100644 index 00000000..42d13ba3 --- /dev/null +++ b/frontend/src/routes/(app)/opportunities/[id]/+page.server.js @@ -0,0 +1,39 @@ +/** + * Opportunity Detail Page - Server Load + * + * Django endpoint: GET /api/opportunities// + * 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'); + } +} diff --git a/frontend/src/routes/(app)/opportunities/[id]/+page.svelte b/frontend/src/routes/(app)/opportunities/[id]/+page.svelte new file mode 100644 index 00000000..c4b21947 --- /dev/null +++ b/frontend/src/routes/(app)/opportunities/[id]/+page.svelte @@ -0,0 +1,325 @@ + + + + {opp?.name || 'Opportunity'} · BottleCRM + + + + {#snippet meta()} +

    + {#if opp?.created_at || opp?.created_on} + Created {formatRelativeDate(opp.created_at || opp.created_on)} + {/if} + {#if opp?.account?.name} + · + {opp.account.name} + {/if} + {#if opp?.assigned_to?.[0]} + · + + + {getNameInitials(opp.assigned_to[0].user_details?.email || '?', '')} + + {opp.assigned_to[0].user_details?.email || 'Unassigned'} + + {/if} +
    + {/snippet} + + {#snippet amount()} + {#if opp?.amount != null} + + {formatCurrency(opp.amount, opp.currency || 'USD')} + + {#if opp?.probability != null} + + {opp.probability}% probability + + {/if} + {/if} + {/snippet} + + {#snippet actions()} +
    + + + + +
    + {/snippet} + + + +
    + +
    + + + + + Overview + + Activity + + {timelineItems.length} + + + + Files + + {attachments.length} + + + + + +
    + +
    + + + + About + + + {#if opp?.description} +

    {opp.description}

    + {:else} +

    No description.

    + {/if} +
    +
    + + + + + Activity + + + + {#each timelineItems as item (item.id)} + {#if item.kind === 'comment'} + + {#snippet icon()}{/snippet} + {#snippet text()} + {item.payload.commented_by_user || 'Someone'} commented + {/snippet} + + {:else if item.kind === 'attachment'} + + {#snippet icon()}{/snippet} + {#snippet text()} + {item.payload.created_by_user || 'Someone'} uploaded + {item.payload.file_name || 'a file'} + {/snippet} + + {:else} + + {#snippet icon()}{/snippet} + {#snippet text()} + Opportunity created + {/snippet} + + {/if} + {/each} + + + +
    + + +
    + + Details + +
    +
    +
    Account
    +
    {opp?.account?.name || '—'}
    +
    +
    +
    Stage
    +
    + {OPPORTUNITY_STAGES.find((s) => s.value === opp?.stage)?.label || opp?.stage || '—'} +
    +
    +
    +
    Close date
    +
    {opp?.closed_on ? formatDate(opp.closed_on) : '—'}
    +
    +
    +
    Probability
    +
    {opp?.probability != null ? `${opp.probability}%` : '—'}
    +
    +
    +
    Source
    +
    {opp?.lead_source || '—'}
    +
    +
    +
    +
    + + + Contacts + + {#if contacts.length === 0} +

    No contacts linked.

    + {:else} +
      + {#each contacts as ct (ct.id)} +
    • + + {getNameInitials(ct.first_name || ct.email || '?', ct.last_name || '')} + + {`${ct.first_name || ''} ${ct.last_name || ''}`.trim() || ct.email || 'Contact'} +
    • + {/each} +
    + {/if} +
    +
    + + + Tags + + {#if (opp?.tags || []).length === 0} +

    No tags.

    + {:else} +
    + {#each opp.tags as tag, i (tag.id ?? tag.slug ?? tag.name ?? i)} + + {tag.name} + + {/each} +
    + {/if} +
    +
    +
    +
    +
    + + +
    + + All activity + + + {#each timelineItems as item (item.id)} + {#if item.kind === 'comment'} + + {#snippet icon()}{/snippet} + {#snippet text()}{item.payload.commented_by_user || 'Someone'} commented{/snippet} + + {:else if item.kind === 'attachment'} + + {#snippet icon()}{/snippet} + {#snippet text()}{item.payload.created_by_user || 'Someone'} uploaded {item.payload.file_name || 'a file'}{/snippet} + + {:else} + + {#snippet icon()}{/snippet} + {#snippet text()}Opportunity created{/snippet} + + {/if} + {/each} + + + +
    +
    + + +
    + + Files + + {#if attachments.length === 0} +

    No files uploaded.

    + {:else} +
      + {#each attachments as a (a.id)} +
    • + + {a.file_name || 'File'} + {a.created_on ? formatRelativeDate(a.created_on) : ''} +
    • + {/each} +
    + {/if} +
    +
    +
    +
    +
    diff --git a/frontend/src/routes/(app)/opportunities/[id]/edit/+page.server.js b/frontend/src/routes/(app)/opportunities/[id]/edit/+page.server.js new file mode 100644 index 00000000..7c025120 --- /dev/null +++ b/frontend/src/routes/(app)/opportunities/[id]/edit/+page.server.js @@ -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} */ + 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}`); + } +}; diff --git a/frontend/src/routes/(app)/opportunities/[id]/edit/+page.svelte b/frontend/src/routes/(app)/opportunities/[id]/edit/+page.svelte new file mode 100644 index 00000000..482c3fe1 --- /dev/null +++ b/frontend/src/routes/(app)/opportunities/[id]/edit/+page.svelte @@ -0,0 +1,147 @@ + + + + {opp?.name ? `Edit ${opp.name}` : 'Edit opportunity'} - BottleCRM + + + + + + {#snippet children()} +
    +

    Basics

    + +
    + + +
    + +
    + + +
    +
    + +
    +

    Deal value

    + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + + + Percent likelihood of closing. +
    +
    + + +
    +
    +
    + +
    +

    Detail

    + +
    + + +
    + +
    + + +
    +
    + {/snippet} + + {#snippet actions()} + + + {/snippet} +
    diff --git a/frontend/src/routes/(app)/settings/tags/+page.svelte b/frontend/src/routes/(app)/settings/tags/+page.svelte index b97d870c..2d20378b 100644 --- a/frontend/src/routes/(app)/settings/tags/+page.svelte +++ b/frontend/src/routes/(app)/settings/tags/+page.svelte @@ -157,26 +157,20 @@ Tags - BottleCRM - -
    -
    -
    -
    -

    Tags

    -

    - Create and manage tags to organize contacts, companies, deals, and tickets. -

    -
    - -
    -
    -
    + + {#snippet actions()} + + {/snippet} +
    diff --git a/frontend/src/routes/(app)/settings/ticket-approvals/+page.svelte b/frontend/src/routes/(app)/settings/ticket-approvals/+page.svelte index b113b5d0..e02f7c57 100644 --- a/frontend/src/routes/(app)/settings/ticket-approvals/+page.svelte +++ b/frontend/src/routes/(app)/settings/ticket-approvals/+page.svelte @@ -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 @@ Approval rules - Settings - BottleCRM -
    -
    -
    - -

    Approval rules

    -
    + + {#snippet titleIcon()} + + {/snippet} + {#snippet actions()} {#if !creating && editingId === null} {/if} -
    + {/snippet} + +

    Active rules block the close transition until an approval is recorded. Filters combine — a rule with priority=Urgent + case_type=Incident matches diff --git a/frontend/src/routes/(app)/solutions/+page.svelte b/frontend/src/routes/(app)/solutions/+page.svelte index 7639a861..ac534a43 100644 --- a/frontend/src/routes/(app)/solutions/+page.svelte +++ b/frontend/src/routes/(app)/solutions/+page.svelte @@ -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 @@ Knowledge Base - BottleCRM -

    -
    -
    - -

    Knowledge Base

    - - {total} {total === 1 ? 'article' : 'articles'} - -
    + + {#snippet titleIcon()} + + {/snippet} + {#snippet meta()} + + {total} {total === 1 ? 'article' : 'articles'} + + {/snippet} + {#snippet actions()} -
    + {/snippet} + +
    diff --git a/frontend/src/routes/(app)/solutions/[id]/+page.svelte b/frontend/src/routes/(app)/solutions/[id]/+page.svelte index 048a54ca..d0dcdbc8 100644 --- a/frontend/src/routes/(app)/solutions/[id]/+page.svelte +++ b/frontend/src/routes/(app)/solutions/[id]/+page.svelte @@ -1,10 +1,8 @@ -
    -
    - Back to Boards -

    {data.board.name}

    - {#if data.board.description} -

    {data.board.description}

    - {/if} -
    + +
    {#each data.columns as column (column.id)}
    { 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 @@ Tickets - BottleCRM +
    {#snippet actions()}
    @@ -747,30 +748,26 @@
    (filtersExpanded = !filtersExpanded)} + onToggleFilters={() => {}} onToggleColumn={toggleColumn} onCreate={drawer.openCreate} />
    {/snippet} + {#snippet tabs()} + + {/snippet}
    - - + updateFilters({ ...filters, search: value })} @@ -800,7 +797,13 @@ value={filters.tags} onchange={(ids) => updateFilters({ ...filters, tags: ids })} /> - + {#if activeFiltersCount > 0} + + {/if} + {#snippet meta()} + {filteredTickets.length} of {pagination.total} tickets + {/snippet} + {#if viewMode === 'list'} goto(`/tickets/${row.id}`)} @@ -869,6 +873,9 @@ {/if}
    + +
    + (selectedIds = [])} diff --git a/frontend/src/routes/(app)/tickets/analytics/+page.svelte b/frontend/src/routes/(app)/tickets/analytics/+page.svelte index 7007d689..58ab97b9 100644 --- a/frontend/src/routes/(app)/tickets/analytics/+page.svelte +++ b/frontend/src/routes/(app)/tickets/analytics/+page.svelte @@ -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 @@ } -
    -
    -
    - -

    - Tickets Analytics -

    -
    + + {#snippet titleIcon()} + + {/snippet} + {#snippet actions()} ← Back to tickets -
    + {/snippet} + +
    Approvals - BottleCRM -
    -
    - -

    Approvals

    -
    + + {#snippet titleIcon()} + + {/snippet} + +
    - - {data.start} → {data.end} - - -
    -
    + + {#snippet titleIcon()} + + {/snippet} + {#snippet actions()} + + + {data.start} → {data.end} + + + {/snippet} + +
    Total: {formatMinutes(totalMinutes)} Billable: {formatMinutes(billableMinutes)} diff --git a/frontend/src/routes/(app)/users/+page.svelte b/frontend/src/routes/(app)/users/+page.svelte index 556d8c1a..a513890d 100644 --- a/frontend/src/routes/(app)/users/+page.svelte +++ b/frontend/src/routes/(app)/users/+page.svelte @@ -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 @@ Users & Teams - BottleCRM - + + {#snippet tabs()} + + {/snippet} +
    @@ -538,6 +542,8 @@ {/if}
    + +