Project tracker (PM workspace)

Linear-style project workspace: KPI strip (Projects / In progress / Ready / Blocked / Budget), bulk-action toolbar (Mark ready, Block, Move to launch, Delete) that enables on selection, phase-grouped rows with per-phase aggregate cards, NEW pill + SVG progress ring on the name column, avatar owner, colour-block Status / Priority / Risk cells, Department chip, multi-skills chips, inline filter row.

A live, editable Svelte 5 data grid example from the SvGrid gallery (Industry Templates). See the SvGrid documentation for the full API.

About this example

A project workspace in the Svelte 5 data grid with a restrained visual language. A KPI strip counts projects, in progress, ready, blocked and budget; a bulk-action toolbar for Mark ready, Block, Move to launch and Delete enables on selection; rows group by phase with per-phase aggregate cards; the name column carries a NEW pill and an SVG progress ring; owner shows an avatar; Status, Priority and Risk are colour-block cells; Department and skills render as chips; and an inline filter row can be toggled with showFilterRow. Everything drives off one data array, so edits flow to the KPIs, cards and group aggregates.

A Linear / Asana / Monday-style project tracker built on SvGrid. The visual language is restrained: subtle tinted pills + chips, compact toolbar, sparkline-feel KPI cards. Everything drives off the same data array so edits flow through to KPIs, phase cards, and group aggregates instantly.

Imports, features and API used

Imports: @svgrid/grid

Table features registered: rowSortingFeature, columnFilteringFeature, rowSelectionFeature, columnGroupingFeature, rowExpandingFeature

SvGridApi methods called: api.clearRowSelection(), api.clearSort(), api.setGroupBy(), api.setRowExpanded(), api.setSort()

Frequently asked questions

How are the phase cards computed?

A $derived groups the data array by phase and totals count, budget and progress per phase; the grid groups by the same field through api.setGroupBy so the cards and the group rows agree.

How is the filter row toggled?

filterMode is 'row' and showFilterRow is bound to a toolbar switch, so the inline filter inputs appear and disappear without touching the column definitions.

How do bulk actions update the rows?

The toolbar reads the rows from onRowSelectionChange and mutates their status in the data array; the grid, the KPI strip and the phase cards update from the same change, and api.clearRowSelection() clears the checkboxes.

Related articles

Source code (167-project-tracker.svelte)

<script lang="ts">
  /**
   * 167. Project tracker (Enterprise PM workspace)
   * -----------------------------------------------
   * A Linear / Asana / Monday-style project tracker built on SvGrid.
   * The visual language is restrained: subtle tinted pills + chips,
   * compact toolbar, sparkline-feel KPI cards. Everything drives off
   * the same `data` array so edits flow through to KPIs, phase cards,
   * and group aggregates instantly.
   */
  import {
    SvGrid,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    rowSelectionFeature,
    columnGroupingFeature,
    rowExpandingFeature,
    renderSnippet,
    type ColumnDef,
    type SvGridApi,
  } from '@svgrid/grid'

  // ---- Domain ---------------------------------------------------------
  type Phase = 'New requests' | 'Under review' | 'Launch ready' | 'Backlog'
  type Status = 'New' | 'In Review' | 'In Progress' | 'Blocked' | 'Ready'
  type Priority = 'Urgent' | 'High' | 'Medium' | 'Low'
  type Risk = 'High' | 'Medium' | 'Low'
  type Department =
    | 'Product' | 'Engineering' | 'Customer Success' | 'Design'
    | 'Support' | 'Marketing' | 'Data' | 'Security'

  type Project = {
    id: string
    name: string
    isNew: boolean
    progress: number       // 0..100
    owner: string
    ownerInitial: string
    summary: string
    status: Status
    priority: Priority
    risk: Risk
    department: Department
    skills: string[]
    budget: number         // USD
    spent: number          // USD spent so far
    due: string            // ISO date
    phase: Phase
  }

  // Deterministic helpers used to fill in due dates without changing the
  // identity of the source SEED below.
  function dueDateFor(idx: number): string {
    const start = new Date(2026, 5, 25) // June 25, 2026
    const d = new Date(start.getTime() + idx * 5 * 86_400_000)
    return d.toISOString().slice(0, 10)
  }

  const SEED: Project[] = [
    // ----- New requests --------------------------------------------------
    { id: 'PRJ-101', name: 'Mobile checkout launch',
      isNew: true, progress: 20, owner: 'Jamal Wright', ownerInitial: 'JW',
      summary: 'Ship checkout v1 for iOS + Android with Apple/Google Pay',
      status: 'New', priority: 'High', risk: 'Medium',
      department: 'Product', skills: ['React', 'TypeScript', 'Mobile'],
      budget: 95_000, spent: 18_000, due: dueDateFor(0), phase: 'New requests' },
    { id: 'PRJ-102', name: 'Enterprise SSO rollout',
      isNew: true, progress: 15, owner: 'Riya Patel', ownerInitial: 'RP',
      summary: 'SAML SSO + SCIM provisioning for enterprise customers',
      status: 'New', priority: 'Medium', risk: 'Low',
      department: 'Engineering', skills: ['Angular', 'TypeScript', 'Auth'],
      budget: 80_000, spent: 12_000, due: dueDateFor(1), phase: 'New requests' },
    { id: 'PRJ-103', name: 'Q3 onboarding revamp',
      isNew: true, progress: 10, owner: 'Elena Cruz', ownerInitial: 'EC',
      summary: 'Activation flow, docs, first-week checklist',
      status: 'New', priority: 'Medium', risk: 'Low',
      department: 'Customer Success', skills: ['Vue', 'Design', 'Content'],
      budget: 60_000, spent: 6_000, due: dueDateFor(2), phase: 'New requests' },
    { id: 'PRJ-104', name: 'Design system token audit',
      isNew: true, progress: 5, owner: 'Sara Lindqvist', ownerInitial: 'SL',
      summary: 'Audit + normalize design tokens across web and mobile',
      status: 'New', priority: 'Low', risk: 'Low',
      department: 'Design', skills: ['TypeScript', 'Design', 'Tooling'],
      budget: 45_000, spent: 2_500, due: dueDateFor(3), phase: 'New requests' },
    { id: 'PRJ-105', name: 'AI support triage beta',
      isNew: true, progress: 15, owner: 'Aiko Tanaka', ownerInitial: 'AT',
      summary: 'Pilot AI triage for L1 tickets with human-in-the-loop',
      status: 'New', priority: 'Urgent', risk: 'Medium',
      department: 'Support', skills: ['React', 'Node', 'Data'],
      budget: 105_000, spent: 19_000, due: dueDateFor(4), phase: 'New requests' },

    // ----- Under review --------------------------------------------------
    { id: 'PRJ-201', name: 'Pricing engine optimization',
      isNew: false, progress: 60, owner: 'Rafael Diaz', ownerInitial: 'RD',
      summary: 'Quote + discount calc engine targeting sub-100ms p95',
      status: 'In Review', priority: 'High', risk: 'Medium',
      department: 'Engineering', skills: ['TypeScript', 'Node'],
      budget: 130_000, spent: 78_000, due: dueDateFor(8), phase: 'Under review' },
    { id: 'PRJ-202', name: 'Marketing site replatform',
      isNew: false, progress: 45, owner: 'Lin Wei', ownerInitial: 'LW',
      summary: 'Move site to new CMS + edge stack, target sub-1s LCP',
      status: 'In Review', priority: 'High', risk: 'Medium',
      department: 'Marketing', skills: ['Vue', 'Design'],
      budget: 110_000, spent: 49_000, due: dueDateFor(10), phase: 'Under review' },
    { id: 'PRJ-203', name: 'Data warehouse migration',
      isNew: false, progress: 35, owner: 'Yuki Tanaka', ownerInitial: 'YT',
      summary: 'Move analytics to new warehouse with dbt models',
      status: 'In Review', priority: 'Medium', risk: 'Low',
      department: 'Data', skills: ['Node', 'Data'],
      budget: 95_000, spent: 33_000, due: dueDateFor(12), phase: 'Under review' },
    { id: 'PRJ-204', name: 'Security posture review',
      isNew: false, progress: 0, owner: 'Chen Wei', ownerInitial: 'CW',
      summary: 'Quarterly security + SOC 2 compliance posture review',
      status: 'Blocked', priority: 'Low', risk: 'High',
      department: 'Security', skills: ['Angular', 'Security'],
      budget: 75_000, spent: 0, due: dueDateFor(14), phase: 'Under review' },

    // ----- Launch ready --------------------------------------------------
    { id: 'PRJ-301', name: 'Webhook reliability',
      isNew: false, progress: 90, owner: 'Mei Sato', ownerInitial: 'MS',
      summary: 'Retry queues + DLQ visibility for outbound webhooks',
      status: 'Ready', priority: 'High', risk: 'Low',
      department: 'Engineering', skills: ['Node', 'TypeScript'],
      budget: 100_000, spent: 90_000, due: dueDateFor(16), phase: 'Launch ready' },
    { id: 'PRJ-302', name: 'Billing portal v2',
      isNew: false, progress: 85, owner: 'Diego Reyes', ownerInitial: 'DR',
      summary: 'Self-serve invoices, tax IDs, seat management',
      status: 'Ready', priority: 'High', risk: 'Low',
      department: 'Product', skills: ['React', 'TypeScript'],
      budget: 120_000, spent: 102_000, due: dueDateFor(18), phase: 'Launch ready' },
    { id: 'PRJ-303', name: 'iOS push reliability',
      isNew: false, progress: 80, owner: 'Ana Silva', ownerInitial: 'AS',
      summary: 'APNs reliability fixes + richer notification surface',
      status: 'Ready', priority: 'Medium', risk: 'Low',
      department: 'Engineering', skills: ['TypeScript', 'Mobile'],
      budget: 85_000, spent: 68_000, due: dueDateFor(20), phase: 'Launch ready' },
    { id: 'PRJ-304', name: 'EU residency tier',
      isNew: false, progress: 80, owner: 'Sven Berg', ownerInitial: 'SB',
      summary: 'Frankfurt residency tier, audit-ready DPA + DPIA',
      status: 'Ready', priority: 'Medium', risk: 'Low',
      department: 'Security', skills: ['Data', 'Security'],
      budget: 90_000, spent: 72_000, due: dueDateFor(22), phase: 'Launch ready' },
    { id: 'PRJ-305', name: 'Onboarding emails refresh',
      isNew: false, progress: 90, owner: 'Priya Shah', ownerInitial: 'PS',
      summary: '7-touch onboarding sequence with persona branching',
      status: 'Ready', priority: 'Low', risk: 'Low',
      department: 'Customer Success', skills: ['Design', 'Content'],
      budget: 70_000, spent: 63_000, due: dueDateFor(24), phase: 'Launch ready' },

    // ----- Backlog -------------------------------------------------------
    { id: 'PRJ-401', name: 'Field service dispatch',
      isNew: false, progress: 25, owner: 'Tomas Vilar', ownerInitial: 'TV',
      summary: 'Mobile dispatch app for field techs with offline mode',
      status: 'In Progress', priority: 'Medium', risk: 'Medium',
      department: 'Product', skills: ['React', 'Mobile'],
      budget: 110_000, spent: 28_000, due: dueDateFor(28), phase: 'Backlog' },
    { id: 'PRJ-402', name: 'Internal AI knowledge base',
      isNew: false, progress: 30, owner: 'Hana Kobayashi', ownerInitial: 'HK',
      summary: 'Slack-grounded AI assistant for internal product knowledge',
      status: 'In Progress', priority: 'Medium', risk: 'Medium',
      department: 'Support', skills: ['Node', 'Data'],
      budget: 50_000, spent: 14_000, due: dueDateFor(32), phase: 'Backlog' },
  ]

  let data = $state<Project[]>(SEED)

  // ---- Feature set ---------------------------------------------------
  const features = tableFeatures({
    rowSortingFeature, columnFilteringFeature,
    rowSelectionFeature, columnGroupingFeature, rowExpandingFeature,
  })

  // ---- Theme tokens used by the chip cells ---------------------------
  // Modern subtle-tint pills: light fill (color/14% alpha) + same-hue
  // dark text (or vice-versa in dark mode). Looks like Linear / Notion.
  const STATUS_COLOR: Record<Status, string> = {
    'New':         '#3b82f6',
    'In Review':   '#f59e0b',
    'In Progress': '#8b5cf6',
    'Blocked':     '#ef4444',
    'Ready':       '#10b981',
  }
  const PRIORITY_COLOR: Record<Priority, string> = {
    'Urgent': '#dc2626',
    'High':   '#ef4444',
    'Medium': '#f59e0b',
    'Low':    '#64748b',
  }
  const RISK_COLOR: Record<Risk, string> = {
    'High':   '#ef4444',
    'Medium': '#f59e0b',
    'Low':    '#10b981',
  }
  const DEPT_COLOR: Record<Department, string> = {
    'Product':          '#3b82f6',
    'Engineering':      '#8b5cf6',
    'Customer Success': '#06b6d4',
    'Design':           '#ec4899',
    'Support':          '#14b8a6',
    'Marketing':        '#f97316',
    'Data':             '#0ea5e9',
    'Security':         '#7c3aed',
  }
  /** Avatar gradient picked from a stable hash of the initials. */
  const AVATAR_GRADIENTS = [
    'linear-gradient(135deg, #6366f1, #8b5cf6)',
    'linear-gradient(135deg, #ec4899, #f97316)',
    'linear-gradient(135deg, #06b6d4, #3b82f6)',
    'linear-gradient(135deg, #10b981, #14b8a6)',
    'linear-gradient(135deg, #f59e0b, #ef4444)',
    'linear-gradient(135deg, #8b5cf6, #ec4899)',
    'linear-gradient(135deg, #0ea5e9, #10b981)',
    'linear-gradient(135deg, #7c3aed, #06b6d4)',
  ]
  function avatarGradient(initials: string): string {
    let h = 0
    for (let i = 0; i < initials.length; i += 1) h = (h * 31 + initials.charCodeAt(i)) >>> 0
    return AVATAR_GRADIENTS[h % AVATAR_GRADIENTS.length]!
  }

  const STATUS_OPTS = ['New', 'In Review', 'In Progress', 'Blocked', 'Ready']
  const PRIORITY_OPTS = ['Urgent', 'High', 'Medium', 'Low']
  const RISK_OPTS = ['High', 'Medium', 'Low']
  const DEPT_OPTS = Object.keys(DEPT_COLOR)
  const SKILL_OPTS = [
    'React', 'Angular', 'Vue', 'TypeScript', 'Node',
    'Design', 'Data', 'Security', 'Mobile', 'Auth', 'Tooling', 'Content',
  ]

  // ---- KPI strip -----------------------------------------------------
  const kpi = $derived.by(() => {
    const total = data.length
    const inProgress = data.filter((p) => p.status === 'In Progress' || p.status === 'In Review' || p.status === 'New').length
    const ready      = data.filter((p) => p.status === 'Ready').length
    const blocked    = data.filter((p) => p.status === 'Blocked').length
    const budget     = data.reduce((s, p) => s + p.budget, 0)
    const spent      = data.reduce((s, p) => s + p.spent, 0)
    const avgProg    = total ? Math.round(data.reduce((s, p) => s + p.progress, 0) / total) : 0
    return { total, inProgress, ready, blocked, budget, spent, avgProg, burn: budget > 0 ? spent / budget : 0 }
  })

  // ---- Selection-aware bulk-action state ----------------------------
  let api = $state<SvGridApi<typeof features, Project> | null>(null)
  let selectedCount = $state(0)
  let selectedIds = $state<string[]>([])
  let isGrouped = $state(false)
  let showFilters = $state(false)
  let sortOpen = $state(false)
  let sortLabel = $state<string>('None')

  /** Sort presets the dropdown surfaces. Mapping to (columnId, direction)
   *  + null entry to clear. Labels show in the dropdown + the toolbar. */
  const SORT_PRESETS: Array<{ label: string; column: string | null; desc: boolean }> = [
    { label: 'None',                     column: null,       desc: false },
    { label: 'Name (A → Z)',             column: 'name',     desc: false },
    { label: 'Name (Z → A)',             column: 'name',     desc: true  },
    { label: 'Progress (high → low)',    column: 'progress', desc: true  },
    { label: 'Progress (low → high)',    column: 'progress', desc: false },
    { label: 'Due date (soonest)',       column: 'due',      desc: false },
    { label: 'Due date (latest)',        column: 'due',      desc: true  },
    { label: 'Budget (highest)',         column: 'budget',   desc: true  },
    { label: 'Budget (lowest)',          column: 'budget',   desc: false },
  ]
  function applySort(preset: typeof SORT_PRESETS[number]) {
    sortLabel = preset.label
    sortOpen = false
    if (!preset.column) api?.clearSort()
    else api?.setSort(preset.column, preset.desc ? 'desc' : 'asc')
  }
  // Close the sort dropdown on outside click.
  $effect(() => {
    if (!sortOpen) return
    function on(e: MouseEvent) {
      const t = e.target as HTMLElement | null
      if (t && t.closest('[data-pt-sort]')) return
      sortOpen = false
    }
    window.addEventListener('mousedown', on)
    return () => window.removeEventListener('mousedown', on)
  })

  function patch(ids: string[], change: Partial<Project>) {
    data = data.map((p) => ids.includes(p.id) ? { ...p, ...change } : p)
  }
  function markReady()    { patch(selectedIds, { status: 'Ready',   progress: 100 }) }
  function blockSelected(){ patch(selectedIds, { status: 'Blocked' }) }
  function moveToLaunch() { patch(selectedIds, { phase: 'Launch ready' }) }
  function deleteSelected(){
    const ids = new Set(selectedIds)
    data = data.filter((p) => !ids.has(p.id))
    api?.clearRowSelection()
  }
  function newProject() {
    const idx = data.length + 1
    const next: Project = {
      id: `PRJ-${500 + idx}`,
      name: 'New project',
      isNew: true, progress: 0,
      owner: 'You', ownerInitial: 'YO',
      summary: 'Click to add an executive summary',
      status: 'New', priority: 'Medium', risk: 'Low',
      department: 'Product', skills: [], budget: 50_000, spent: 0,
      due: dueDateFor(idx + 5), phase: 'New requests',
    }
    data = [next, ...data]
  }
  function toggleGrouping() {
    isGrouped = !isGrouped
    if (!api) return
    if (isGrouped) {
      api.setGroupBy(['phase'])
      // Expand every phase group so the user sees the rows under each
      // header right away (rather than landing on collapsed groups).
      queueMicrotask(() => {
        api?.setRowExpanded('phase:New requests', true)
        api?.setRowExpanded('phase:Under review', true)
        api?.setRowExpanded('phase:Launch ready', true)
        api?.setRowExpanded('phase:Backlog', true)
      })
    } else {
      api.setGroupBy([])
    }
  }

  // ---- Columns -------------------------------------------------------
  const columns = $derived<ColumnDef<typeof features, Project>[]>([
    {
      field: 'id', header: 'ID', width: 100, editable: false,
      cell: (ctx) => renderSnippet(IdCell, { row: ctx.row.original }),
    },
    {
      field: 'name', header: 'Project', editorType: 'text', width: 320,
      cell: (ctx) => renderSnippet(NameCell, { row: ctx.row.original }),
    },
    {
      field: 'owner', header: 'Owner', editorType: 'text', width: 200,
      cell: (ctx) => renderSnippet(OwnerCell, { row: ctx.row.original }),
    },
    {
      field: 'status', header: 'Status', editorType: 'list',
      editorOptions: STATUS_OPTS, width: 140,
      cell: (ctx) => renderSnippet(PillCell, {
        text: ctx.row.original.status, color: STATUS_COLOR[ctx.row.original.status as Status],
      }),
    },
    {
      field: 'priority', header: 'Priority', editorType: 'list',
      editorOptions: PRIORITY_OPTS, width: 130,
      cell: (ctx) => renderSnippet(PillCell, {
        text: ctx.row.original.priority, color: PRIORITY_COLOR[ctx.row.original.priority as Priority],
      }),
    },
    {
      field: 'department', header: 'Team', editorType: 'list',
      editorOptions: DEPT_OPTS, width: 180,
      cell: (ctx) => renderSnippet(DeptCell, { row: ctx.row.original }),
    },
    {
      field: 'skills', header: 'Tags', editorType: 'chips',
      editorOptions: SKILL_OPTS, editorMultiple: true, width: 230,
      cell: (ctx) => renderSnippet(SkillsCell, { row: ctx.row.original }),
    },
    {
      field: 'risk', header: 'Risk', editorType: 'list',
      editorOptions: RISK_OPTS, width: 100,
      cell: (ctx) => renderSnippet(PillCell, {
        text: ctx.row.original.risk, color: RISK_COLOR[ctx.row.original.risk as Risk], dim: true,
      }),
    },
    {
      field: 'due', header: 'Due', editorType: 'text', width: 120,
      cell: (ctx) => renderSnippet(DueCell, { row: ctx.row.original }),
    },
    {
      field: 'budget', header: 'Budget', editorType: 'number', width: 160,
      cell: (ctx) => renderSnippet(BudgetCell, { row: ctx.row.original }),
    },
    {
      field: 'summary', header: 'Summary', editorType: 'text', width: 320,
      cell: (ctx) => renderSnippet(SummaryCell, { row: ctx.row.original }),
    },
  ])

  // ---- Phase summary cards on the right of the toolbar --------------
  const PHASE_ORDER: Phase[] = ['New requests', 'Under review', 'Launch ready', 'Backlog']
  const PHASE_COLOR: Record<Phase, string> = {
    'New requests':  '#3b82f6',
    'Under review':  '#f59e0b',
    'Launch ready':  '#10b981',
    'Backlog':       '#64748b',
  }
  const phaseStats = $derived(PHASE_ORDER.map((phase) => {
    const items = data.filter((p) => p.phase === phase)
    const avg = items.length ? Math.round(items.reduce((s, p) => s + p.progress, 0) / items.length) : 0
    const blocked = items.filter((p) => p.status === 'Blocked').length
    const ready   = items.filter((p) => p.status === 'Ready').length
    return { phase, count: items.length, avg, blocked, ready }
  }))

  function fmtMoney(n: number): string {
    if (n >= 1_000_000) return '$' + (n / 1_000_000).toFixed(1) + 'M'
    if (n >= 1_000)     return '$' + Math.round(n / 1_000) + 'k'
    return '$' + n
  }
  function fmtDue(iso: string): { label: string; tone: 'overdue' | 'soon' | 'ok' } {
    const today = new Date('2026-06-25T00:00:00Z').getTime()
    const due = new Date(iso + 'T00:00:00Z').getTime()
    const days = Math.round((due - today) / 86_400_000)
    if (days < 0) return { label: `${-days}d overdue`, tone: 'overdue' }
    if (days <= 7) return { label: `in ${days}d`, tone: 'soon' }
    const d = new Date(iso)
    return { label: d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), tone: 'ok' }
  }
</script>

{#snippet IdCell({ row }: { row: Project })}
  <span class="pt-id">{row.id}</span>
{/snippet}

{#snippet NameCell({ row }: { row: Project })}
  <div class="pt-name">
    <div class="pt-name-top">
      <span class="pt-name-text">{row.name}</span>
      {#if row.isNew}<span class="pt-new-pill">NEW</span>{/if}
    </div>
    <div class="pt-progress">
      <div class="pt-progress-track">
        <div class="pt-progress-fill"
          style:width={row.progress + '%'}
          style:background={row.progress >= 75 ? '#10b981' : row.progress >= 35 ? '#f59e0b' : '#94a3b8'}>
        </div>
      </div>
      <span class="pt-progress-text">{row.progress}%</span>
    </div>
  </div>
{/snippet}

{#snippet OwnerCell({ row }: { row: Project })}
  <div class="pt-owner">
    <span class="pt-avatar" style:background={avatarGradient(row.ownerInitial)}>
      {row.ownerInitial}
    </span>
    <span class="pt-owner-name">{row.owner}</span>
  </div>
{/snippet}

{#snippet PillCell({ text, color, dim }: { text: string; color: string; dim?: boolean })}
  <span class="pt-pill" style:--c={color} class:is-dim={dim}>
    <span class="pt-pill-dot" style:background={color}></span>
    {text}
  </span>
{/snippet}

{#snippet DeptCell({ row }: { row: Project })}
  <span class="pt-dept" style:--c={DEPT_COLOR[row.department]}>{row.department}</span>
{/snippet}

{#snippet SkillsCell({ row }: { row: Project })}
  <div class="pt-skills">
    {#each row.skills.slice(0, 2) as s (s)}
      <span class="pt-skill">{s}</span>
    {/each}
    {#if row.skills.length > 2}
      <span class="pt-skill pt-skill-more" title={row.skills.slice(2).join(', ')}>
        +{row.skills.length - 2}
      </span>
    {/if}
  </div>
{/snippet}

{#snippet DueCell({ row }: { row: Project })}
  {@const due = fmtDue(row.due)}
  <span class="pt-due pt-due-{due.tone}">{due.label}</span>
{/snippet}

{#snippet BudgetCell({ row }: { row: Project })}
  <div class="pt-budget">
    <div class="pt-budget-top">
      <span class="pt-budget-spent">{fmtMoney(row.spent)}</span>
      <span class="pt-budget-sep">/</span>
      <span class="pt-budget-total">{fmtMoney(row.budget)}</span>
    </div>
    <div class="pt-budget-track">
      <div class="pt-budget-fill"
        style:width={Math.min(100, (row.spent / Math.max(1, row.budget)) * 100) + '%'}
        style:background={row.spent > row.budget ? '#ef4444' : row.spent > row.budget * 0.85 ? '#f59e0b' : '#10b981'}>
      </div>
    </div>
  </div>
{/snippet}

{#snippet SummaryCell({ row }: { row: Project })}
  <span class="pt-summary" title={row.summary}>{row.summary}</span>
{/snippet}

<section class="pt-shell">
  <!-- Page header + primary action ----------------------------------- -->
  <header class="pt-page-head">
    <div>
      <div class="pt-eyebrow">PM workspace</div>
      <h2 class="pt-title">Active projects</h2>
      <p class="pt-lede">
        {kpi.total} projects across {phaseStats.length} phases · {kpi.avgProg}% avg complete
        {#if kpi.blocked > 0} · <span class="pt-warn">{kpi.blocked} blocked</span>{/if}
      </p>
    </div>
    <div class="pt-page-actions">
      <button class="pt-btn pt-btn-ghost" title="Import projects">
        <span aria-hidden="true">↥</span> Import
      </button>
      <button class="pt-btn pt-btn-ghost" title="Export">
        <span aria-hidden="true">↧</span> Export
      </button>
      <button class="pt-btn pt-btn-primary" onclick={newProject}>
        <span aria-hidden="true">+</span> New project
      </button>
    </div>
  </header>

  <!-- KPI strip -------------------------------------------------------- -->
  <div class="pt-kpis">
    <div class="pt-kpi">
      <div class="pt-kpi-label">Total projects</div>
      <div class="pt-kpi-value">{kpi.total}</div>
      <div class="pt-kpi-meta">across {phaseStats.length} phases</div>
    </div>
    <div class="pt-kpi" data-accent="blue">
      <div class="pt-kpi-label">In progress</div>
      <div class="pt-kpi-value">{kpi.inProgress}</div>
      <div class="pt-kpi-bar"><span style:width={(kpi.inProgress / Math.max(1, kpi.total)) * 100 + '%'}></span></div>
    </div>
    <div class="pt-kpi" data-accent="green">
      <div class="pt-kpi-label">Launch ready</div>
      <div class="pt-kpi-value">{kpi.ready}</div>
      <div class="pt-kpi-bar"><span style:width={(kpi.ready / Math.max(1, kpi.total)) * 100 + '%'}></span></div>
    </div>
    <div class="pt-kpi" data-accent={kpi.blocked > 0 ? 'red' : 'neutral'}>
      <div class="pt-kpi-label">Blocked</div>
      <div class="pt-kpi-value">{kpi.blocked}</div>
      <div class="pt-kpi-meta">{kpi.blocked === 0 ? 'all clear' : 'needs attention'}</div>
    </div>
    <div class="pt-kpi" data-accent="violet">
      <div class="pt-kpi-label">Budget burn</div>
      <div class="pt-kpi-value">{Math.round(kpi.burn * 100)}%</div>
      <div class="pt-kpi-meta">{fmtMoney(kpi.spent)} of {fmtMoney(kpi.budget)}</div>
    </div>
  </div>

  <!-- Phase pipeline cards -------------------------------------------- -->
  <div class="pt-pipeline">
    {#each phaseStats as ps (ps.phase)}
      <div class="pt-pcard" style:--c={PHASE_COLOR[ps.phase]}>
        <div class="pt-pcard-top">
          <span class="pt-pcard-dot"></span>
          <span class="pt-pcard-name">{ps.phase}</span>
          <span class="pt-pcard-count">{ps.count}</span>
        </div>
        <div class="pt-pcard-bar">
          <div class="pt-pcard-fill" style:width={ps.avg + '%'}></div>
        </div>
        <div class="pt-pcard-meta">
          {ps.avg}% avg · {ps.ready} ready
          {#if ps.blocked > 0}<span class="pt-warn"> · {ps.blocked} blocked</span>{/if}
        </div>
      </div>
    {/each}
  </div>

  <!-- Toolbar --------------------------------------------------------- -->
  <div class="pt-toolbar">
    <div class="pt-toolbar-group">
      <button class="pt-btn pt-btn-ghost" onclick={toggleGrouping} class:is-active={isGrouped}>
        <span aria-hidden="true">▤</span> Group by phase
      </button>

      <!-- Sort dropdown ------------------------------------------ -->
      <div class="pt-sort-wrap" data-pt-sort>
        <button
          class="pt-btn pt-btn-ghost"
          class:is-active={sortLabel !== 'None'}
          onclick={() => (sortOpen = !sortOpen)}
          aria-haspopup="true"
          aria-expanded={sortOpen}
        >
          <span aria-hidden="true">⇅</span>
          Sort{sortLabel !== 'None' ? ': ' + sortLabel : ''}
          <span class="pt-caret" aria-hidden="true">▾</span>
        </button>
        {#if sortOpen}
          <div class="pt-popover" role="menu" data-pt-sort>
            {#each SORT_PRESETS as preset (preset.label)}
              <button
                type="button"
                role="menuitem"
                class="pt-popover-item"
                class:is-active={sortLabel === preset.label}
                onclick={() => applySort(preset)}
              >{preset.label}</button>
            {/each}
          </div>
        {/if}
      </div>

      <!-- Filter toggle ------------------------------------------ -->
      <button
        class="pt-btn pt-btn-ghost"
        class:is-active={showFilters}
        onclick={() => (showFilters = !showFilters)}
        title={showFilters ? 'Hide column filters' : 'Show column filters'}
      >
        <span aria-hidden="true">⌖</span> {showFilters ? 'Hide filters' : 'Filter'}
      </button>
    </div>

    {#if selectedCount > 0}
      <div class="pt-toolbar-sel">
        <span class="pt-sel-counter">{selectedCount} selected</span>
        <button class="pt-btn pt-btn-ghost" onclick={markReady}>
          <span aria-hidden="true">✓</span> Mark ready
        </button>
        <button class="pt-btn pt-btn-ghost" onclick={blockSelected}>
          <span aria-hidden="true">⊘</span> Block
        </button>
        <button class="pt-btn pt-btn-ghost" onclick={moveToLaunch}>
          <span aria-hidden="true">⤴</span> Move to launch
        </button>
        <button class="pt-btn pt-btn-danger" onclick={deleteSelected}>
          <span aria-hidden="true">🗑</span> Delete
        </button>
      </div>
    {/if}
  </div>

  <!-- Grid ------------------------------------------------------------ -->
  <div class="pt-grid-wrap">
    <SvGrid responsive={true}
      columnResize
      data={data}
      columns={columns}
      features={features}
      filterMode="row"
      selectionMode="row"
      showRowSelection={true}
      showFilterRow={showFilters}
      showColumnFilters={false}
      showGroupingControls={false}
      enableInlineEditing={true}
      enableCellSelection={false}
      rowHeight={52}
      containerHeight="100%"
      fitColumns={false}
      onApiReady={(next) => { api = next }}
      onRowSelectionChange={(_, rows) => {
        selectedCount = rows.length
        selectedIds = rows.map((r) => r.id)
      }}
    />
  </div>
</section>

<style>
  .pt-shell {
    display: flex;
    flex-direction: column;
    gap: 12px;
    flex: 1;
    min-height: 0;
    color: var(--sg-fg, #0f172a);
  }

  /* ----- Page header ------------------------------------------ */
  .pt-page-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 16px;
    flex-shrink: 0;
  }
  .pt-eyebrow {
    font-size: 10.5px;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--sg-muted, #64748b);
    font-weight: 700;
  }
  .pt-title {
    margin: 2px 0 4px;
    font-size: 22px;
    font-weight: 800;
    line-height: 1.1;
    letter-spacing: -0.01em;
    color: var(--sg-fg, #0f172a);
  }
  .pt-lede {
    margin: 0;
    font-size: 12.5px;
    color: var(--sg-muted, #64748b);
  }
  .pt-warn { color: #ef4444; font-weight: 600; }
  .pt-page-actions {
    display: flex; gap: 6px; align-items: center;
    flex-shrink: 0;
  }

  /* ----- Buttons --------------------------------------------- */
  .pt-btn {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    padding: 6px 11px;
    border-radius: 6px;
    background: transparent;
    color: var(--sg-fg, #0f172a);
    border: 1px solid transparent;
    cursor: pointer;
    font-size: 12.5px;
    font-weight: 500;
    transition: background 80ms ease, border-color 80ms ease, color 80ms ease;
    white-space: nowrap;
  }
  .pt-btn:hover { background: var(--sg-row-hover-bg, rgba(148,163,184,0.10)); }
  .pt-btn:disabled { color: var(--sg-muted, #94a3b8); opacity: 0.55; cursor: not-allowed; }
  .pt-btn-ghost { border-color: var(--sg-border, #e2e8f0); }
  .pt-btn-ghost.is-active { background: color-mix(in srgb, var(--sg-accent, #3b82f6) 12%, transparent); border-color: var(--sg-accent, #3b82f6); color: var(--sg-accent, #3b82f6); }
  .pt-btn-primary {
    background: var(--sg-accent, #3b82f6);
    color: var(--sg-on-accent, white);
    border-color: var(--sg-accent, #3b82f6);
    font-weight: 600;
  }
  .pt-btn-primary:hover { background: color-mix(in srgb, var(--sg-accent, #3b82f6) 88%, black); }
  .pt-btn-danger { color: #ef4444; border-color: rgba(239, 68, 68, 0.3); }
  .pt-btn-danger:hover { background: rgba(239, 68, 68, 0.10); border-color: #ef4444; }

  /* ----- KPI strip ------------------------------------------- */
  .pt-kpis {
    display: grid;
    grid-template-columns: repeat(5, 1fr);
    gap: 10px;
    flex-shrink: 0;
  }
  .pt-kpi {
    --kpi: var(--sg-muted, #64748b);
    padding: 12px 14px;
    border-radius: 10px;
    background: var(--sg-bg, #ffffff);
    border: 1px solid var(--sg-border, #e2e8f0);
    position: relative;
    overflow: hidden;
  }
  .pt-kpi::before {
    content: '';
    position: absolute;
    top: 0; left: 0; right: 0;
    height: 2px;
    background: var(--kpi);
    opacity: 0.5;
  }
  .pt-kpi[data-accent='blue']    { --kpi: #3b82f6; }
  .pt-kpi[data-accent='green']   { --kpi: #10b981; }
  .pt-kpi[data-accent='red']     { --kpi: #ef4444; }
  .pt-kpi[data-accent='violet']  { --kpi: #8b5cf6; }
  .pt-kpi[data-accent='neutral'] { --kpi: var(--sg-muted, #64748b); }
  .pt-kpi-label {
    font-size: 10.5px;
    color: var(--sg-muted, #64748b);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    font-weight: 700;
  }
  .pt-kpi-value {
    margin: 6px 0 2px;
    font-size: 26px;
    font-weight: 800;
    font-variant-numeric: tabular-nums;
    line-height: 1;
    color: var(--kpi);
  }
  .pt-kpi-meta {
    font-size: 11px;
    color: var(--sg-muted, #64748b);
  }
  .pt-kpi-bar {
    margin-top: 8px;
    height: 4px;
    background: var(--sg-header-bg, #f1f5f9);
    border-radius: 2px;
    overflow: hidden;
  }
  .pt-kpi-bar span {
    display: block;
    height: 100%;
    background: var(--kpi);
    border-radius: 2px;
    transition: width 200ms ease;
  }

  /* ----- Phase pipeline -------------------------------------- */
  .pt-pipeline {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 8px;
    flex-shrink: 0;
  }
  .pt-pcard {
    --c: var(--sg-muted, #64748b);
    padding: 10px 12px;
    border-radius: 8px;
    border: 1px solid var(--sg-border, #e2e8f0);
    background: var(--sg-bg, #ffffff);
  }
  .pt-pcard-top {
    display: flex;
    align-items: center;
    gap: 6px;
    margin-bottom: 6px;
  }
  .pt-pcard-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--c);
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--c) 20%, transparent);
  }
  .pt-pcard-name {
    font-size: 12.5px;
    font-weight: 700;
    flex: 1;
  }
  .pt-pcard-count {
    font-size: 12px;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    color: var(--sg-muted, #64748b);
    padding: 1px 7px;
    background: var(--sg-header-bg, #f1f5f9);
    border-radius: 999px;
  }
  .pt-pcard-bar {
    height: 4px;
    background: var(--sg-header-bg, #f1f5f9);
    border-radius: 2px;
    overflow: hidden;
    margin-bottom: 5px;
  }
  .pt-pcard-fill {
    height: 100%;
    background: var(--c);
    border-radius: 2px;
    transition: width 200ms ease;
  }
  .pt-pcard-meta {
    font-size: 11px;
    color: var(--sg-muted, #64748b);
  }

  /* ----- Toolbar --------------------------------------------- */
  .pt-toolbar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 6px 8px;
    border-radius: 8px;
    background: var(--sg-bg, #ffffff);
    border: 1px solid var(--sg-border, #e2e8f0);
    flex-shrink: 0;
    min-height: 44px;
  }
  .pt-toolbar-group, .pt-toolbar-sel { display: flex; align-items: center; gap: 4px; }
  .pt-caret { font-size: 9px; color: var(--sg-muted, #94a3b8); margin-left: 2px; }

  /* Sort dropdown popover */
  .pt-sort-wrap { position: relative; }
  .pt-popover {
    position: absolute;
    top: calc(100% + 4px);
    left: 0;
    z-index: 50;
    min-width: 200px;
    background: var(--sg-bg, #ffffff);
    border: 1px solid var(--sg-border, #cbd5e1);
    border-radius: 8px;
    box-shadow: 0 12px 28px rgba(15, 23, 42, 0.18);
    padding: 4px;
  }
  .pt-popover-item {
    display: block;
    width: 100%;
    text-align: left;
    border: 0;
    background: transparent;
    padding: 6px 10px;
    font-size: 12px;
    color: var(--sg-fg, #0f172a);
    cursor: pointer;
    border-radius: 5px;
    font-weight: 500;
  }
  .pt-popover-item:hover { background: var(--sg-row-hover-bg, rgba(148,163,184,0.10)); }
  .pt-popover-item.is-active {
    background: color-mix(in srgb, var(--sg-accent, #3b82f6) 14%, transparent);
    color: var(--sg-accent, #3b82f6);
    font-weight: 600;
  }
  .pt-sel-counter {
    color: var(--sg-muted, #64748b);
    font-size: 12px;
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    margin-right: 8px;
    padding-right: 8px;
    border-right: 1px solid var(--sg-border, #e2e8f0);
  }

  /* ----- Grid wrap ------------------------------------------- */
  .pt-grid-wrap {
    flex: 1;
    min-height: 0;
    border-radius: 8px;
    overflow: hidden;
    border: 1px solid var(--sg-border, #e2e8f0);
  }

  /* ----- Cell: ID -------------------------------------------- */
  :global(.pt-id) {
    font-family: ui-monospace, SFMono-Regular, monospace;
    font-size: 11px;
    color: var(--sg-muted, #64748b);
    font-weight: 600;
  }

  /* ----- Cell: Name + progress bar --------------------------- */
  :global(.pt-name) {
    display: flex;
    flex-direction: column;
    gap: 3px;
    height: 100%;
    justify-content: center;
    min-width: 0;
  }
  :global(.pt-name-top) {
    display: flex;
    align-items: center;
    gap: 6px;
    min-width: 0;
  }
  :global(.pt-name-text) {
    font-weight: 600;
    color: var(--sg-fg, #0f172a);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 13px;
  }
  :global(.pt-new-pill) {
    background: color-mix(in srgb, var(--sg-accent, #3b82f6) 16%, transparent);
    color: var(--sg-accent, #3b82f6);
    font-size: 9px;
    font-weight: 800;
    padding: 1px 5px;
    border-radius: 3px;
    letter-spacing: 0.06em;
    flex-shrink: 0;
  }
  :global(.pt-progress) {
    display: flex;
    align-items: center;
    gap: 6px;
  }
  :global(.pt-progress-track) {
    flex: 1;
    height: 4px;
    background: var(--sg-header-bg, #f1f5f9);
    border-radius: 2px;
    overflow: hidden;
  }
  :global(.pt-progress-fill) {
    height: 100%;
    border-radius: 2px;
    transition: width 200ms ease;
  }
  :global(.pt-progress-text) {
    font-size: 10.5px;
    color: var(--sg-muted, #64748b);
    font-variant-numeric: tabular-nums;
    font-weight: 600;
    min-width: 30px;
    text-align: right;
  }

  /* ----- Cell: Owner ----------------------------------------- */
  :global(.pt-owner) {
    display: flex;
    align-items: center;
    gap: 8px;
    height: 100%;
    min-width: 0;
  }
  :global(.pt-avatar) {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    border-radius: 50%;
    color: white;
    font-weight: 700;
    font-size: 11px;
    flex-shrink: 0;
    box-shadow: 0 1px 2px rgba(0,0,0,0.1);
    letter-spacing: 0.01em;
  }
  :global(.pt-owner-name) {
    font-size: 12.5px;
    color: var(--sg-fg, #0f172a);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* ----- Cell: Pills (status, priority, risk) ---------------- */
  :global(.pt-pill) {
    --c: var(--sg-muted, #64748b);
    display: inline-flex;
    align-items: center;
    gap: 5px;
    padding: 3px 9px 3px 7px;
    border-radius: 6px;
    font-size: 12px;
    font-weight: 600;
    background: color-mix(in srgb, var(--c) 14%, transparent);
    color: var(--c);
    white-space: nowrap;
  }
  :global(.pt-pill.is-dim) {
    background: transparent;
    border: 1px solid color-mix(in srgb, var(--c) 30%, transparent);
  }
  :global(.pt-pill-dot) {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    flex-shrink: 0;
  }

  /* ----- Cell: Department chip ------------------------------- */
  :global(.pt-dept) {
    --c: var(--sg-muted, #64748b);
    display: inline-flex;
    align-items: center;
    padding: 3px 9px;
    border-radius: 999px;
    background: color-mix(in srgb, var(--c) 12%, transparent);
    color: var(--c);
    font-size: 11.5px;
    font-weight: 600;
    white-space: nowrap;
  }

  /* ----- Cell: Skills (tags) --------------------------------- */
  :global(.pt-skills) {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    overflow: hidden;
    min-width: 0;
  }
  :global(.pt-skill) {
    padding: 2px 7px;
    border-radius: 4px;
    background: var(--sg-header-bg, #f1f5f9);
    color: var(--sg-fg, #475569);
    font-size: 10.5px;
    font-weight: 600;
    white-space: nowrap;
    border: 1px solid var(--sg-border, #e2e8f0);
  }
  :global(.pt-skill-more) {
    background: transparent;
    color: var(--sg-muted, #64748b);
    cursor: help;
  }

  /* ----- Cell: Due ------------------------------------------- */
  :global(.pt-due) {
    font-size: 12px;
    font-weight: 600;
    color: var(--sg-fg, #0f172a);
    font-variant-numeric: tabular-nums;
  }
  :global(.pt-due-soon)    { color: #f59e0b; }
  :global(.pt-due-overdue) { color: #ef4444; }

  /* ----- Cell: Budget --------------------------------------- */
  :global(.pt-budget) {
    display: flex;
    flex-direction: column;
    gap: 3px;
    height: 100%;
    justify-content: center;
    min-width: 0;
  }
  :global(.pt-budget-top) {
    font-size: 12px;
    font-variant-numeric: tabular-nums;
    color: var(--sg-fg, #0f172a);
  }
  :global(.pt-budget-spent) { font-weight: 700; }
  :global(.pt-budget-sep)   { color: var(--sg-muted, #94a3b8); margin: 0 3px; }
  :global(.pt-budget-total) { color: var(--sg-muted, #64748b); }
  :global(.pt-budget-track) {
    height: 4px;
    background: var(--sg-header-bg, #f1f5f9);
    border-radius: 2px;
    overflow: hidden;
  }
  :global(.pt-budget-fill) {
    height: 100%;
    border-radius: 2px;
    transition: width 200ms ease;
  }

  /* ----- Cell: Summary -------------------------------------- */
  :global(.pt-summary) {
    display: inline-block;
    width: 100%;
    color: var(--sg-muted, #64748b);
    font-size: 12px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* Mobile */
  @media (max-width: 1100px) {
    .pt-kpis { grid-template-columns: repeat(3, 1fr); }
    .pt-pipeline { grid-template-columns: repeat(2, 1fr); }
  }
  @media (max-width: 700px) {
    .pt-kpis, .pt-pipeline { grid-template-columns: repeat(2, 1fr); }
    .pt-page-head { flex-direction: column; align-items: stretch; }
  }
</style>

View this example on GitHub

More Industry Templates examples

  • HR team directory - Employee directory with avatars, status badges, group by team / location / status.
  • Finances - ledger - Account ledger with running balance, currency formatting, category chips, paging.
  • Manufacturing operations - Plant-floor view: KPI cards + active-runs grid with progress bars, status pills, live 5-second tick.
  • Permissions, audit & history - Role-based cell editing, PII masking, full audit log with one-click revert, per-cell history popover.
  • Print + boardroom export - Quarterly P&L print pack: cover page, repeat-on-page headers, page-size + orientation, CSV / HTML download, browser-native PDF.