Skip to content

Admin Panel Developer Guide

The Vectis admin dashboard is a SvelteKit 2 app (Svelte 5 runes) at vectis/admin/, running on port 5173. All GraphQL calls go through the /api/graphql BFF proxy.

Admin dashboard

Project Layout

admin/src/
├── routes/
│   ├── +layout.svelte          # Sidebar nav + command palette
│   ├── api/graphql/+server.ts  # BFF proxy (forwards to FastAPI w/ JWT)
│   ├── orders/                 # Order management pages
│   └── products/               # Product management pages
├── lib/
│   ├── houdiniClient.ts        # Houdini HoudiniClient (points at /api/graphql)
│   ├── houdini/                # Named GraphQL ops per page (queries + mutations)
│   ├── server/                 # Server-only helpers (sessions, api headers)
│   ├── help/{en,es}.json       # In-app contextual help
│   └── components/             # Shared admin components
└── app.css

Every page's GraphQL lives in $lib/houdini/<PageName>.ts as named ops. Houdini generates typed Store classes into $houdini at npx houdini generate time.

Page Pattern (Houdini)

Data loading uses a +page.ts (not +page.server.ts) with load_<QueryName> from $houdini:

// src/lib/houdini/Products.ts
import { graphql } from '$houdini';

export const ProductsListStore = graphql(`
  query ProductsList($limit: Int!, $offset: Int!) {
    products(limit: $limit, offset: $offset) {
      products { id name slug variants { id sku price { amount } } }
      total
    }
  }
`);
// src/routes/products/+page.ts
import { load_ProductsList } from '$houdini';
import type { PageLoad } from './$types';

export const load: PageLoad = async (event) => {
  const main = await load_ProductsList({
    event,
    variables: { limit: 80, offset: 0 },
  });
  return { ...main };
};
<!-- src/routes/products/+page.svelte -->
<script lang="ts">
  import { Package } from '@lucide/svelte';
  let { data } = $props();
  const { ProductsList } = data;
</script>

<h1 class="text-2xl font-bold flex items-center gap-2">
  <Package size={24} /> Products
</h1>
{#each $ProductsList.data?.products.products ?? [] as product}
  <a href="/products/{product.id}" class="text-blue-600 hover:underline">{product.name}</a>
{/each}

For client-side mutations, instantiate the generated Store class and call .mutate():

import { UpdateProductStore } from '$houdini';

const updateProduct = new UpdateProductStore();
const { data } = await updateProduct.mutate({ id, input: { name } });

To refetch after a mutation, use the query store's .fetch()not invalidateAll():

import { ProductsListStore } from '$houdini';
await new ProductsListStore().fetch({ variables: {...} });

After adding or modifying any $lib/houdini/*.ts file, run npx houdini generate so types regenerate.

Layout: Sidebar + Command Palette

The root +layout.svelte provides a collapsible sidebar using Lucide icons, and a command palette triggered by Cmd+K (or Space). Navigation items are defined in mainSections and settingsItems inside +layout.svelte.

Command palette with quick actions

The sidebar uses a drill-down replacement pattern. Nav items with a children array replace the sidebar contents when the current path matches the parent or any child route. The authoritative list lives in admin/src/routes/+layout.svelte (mainSections). Current top-level groups (children abbreviated):

  • Catalog (/products parent) — Categories, Brands, Collections, Attributes, Traits, Inventory, Price Lists, Labels, QuickLinks
  • Customers (parent) — Registrations, Pending Changes
  • Marketing (parent) — Gift Cards (conditional), Loyalty (conditional), Email Campaigns, Affiliates (Approvals, Commissions, Payouts), …
  • Customer Service (/fraud parent) — Returns, Refund Approvals, Store Credit, Reviews, plus extension-contributed items
  • Content (/cms parent) — Banners, Sliders, Media, Navigation, Tags
  • Analytics (parent) — Overview, Revenue, Orders, Products, Customers, Promotions, Affiliates, Aging Report, Banner Tracking, Slider Tracking

The Settings index has ~36 items with nested children (Checkout, Accounts, Packaging, etc.). Extension-contributed items use manifest.admin_nav_items (Decided #223) and graft into the matching section.

Channel context (Decided #164)

The current channel is resolved server-side and forwarded to the API as the X-Channel-Slug header (handled by the BFF route at /api/graphql/+server.ts). Admin routes themselves are flat (/orders, /products, etc.) — there is no /c/<slug>/ URL prefix. The channel switcher in the sidebar header writes the chosen slug to a cookie; subsequent requests carry the header. Page loaders should not assume "current channel" — request it from the helpers in $lib/server/api.ts.

Back-link convention:

  • Main nav submenus show a single "Back to menu" link to /.
  • Settings sub-submenus (e.g., Checkout children) show two links: "Main Menu" → / and "Settings" → /settings, so the user can jump directly to either level.

Registering a new submenu group

Add a children array, subnavLabel, and optionally parentSubnavLabel to a MainNavItem in mainSections:

{ href: '/primary-route', label: 'Menu Label', icon: SomeIcon,
  subnavLabel: 'Section Title',
  parentSubnavLabel: 'Parent Link Label',
  children: [
    { href: '/child-a', label: 'Child A', icon: ChildAIcon },
    { href: '/child-b', label: 'Child B', icon: ChildBIcon },
  ]
}

The activeMainSubnav derivation detects active state automatically. Child routes are included in allNavItems() for the command palette.

Default landing page (staff profile)

defaultLandingPage in My Profile controls where the user is sent immediately after sign-in only: /api/login and /api/oauth/callback read myPreferences { defaultLandingPage } and return or redirect to that path. The root route / is always the Dashboard; it must not redirect, or the sidebar Dashboard link would be wrong.

Settings pages (layout & actions)

Staff-facing settings such as My Profile and My Security use the same patterns as the rest of admin: card sections, admin-input for fields, and btn-primary / btn-secondary / btn-danger with a Lucide icon on every action control (see app.css component layer). Avoid one-off bg-blue-600 primary actions.

The top-level Settings index is a two-pane drill-down: a left rail of settings categories and the selected panel on the right.

Settings — General

Deeper settings pages use the same pattern — here's the Checkout → Agreements panel:

Checkout agreements

AI providers and prompt templates live at Settings → AI:

AI content generation settings

Avatar upload (cross-host admin)

My Profile uploads go through the admin BFF (/api/upload-avatar) to the backend POST /api/upload/avatar. If the primary file storage strategy fails, the backend may fall back to local disk under /uploads. Relative /uploads/... URLs would load from the admin host and break when admin and API use different origins (e.g. admin.vectis.eto vs api.vectis.eto). Set PUBLIC_API_BROWSER_ORIGIN on the API (and recreate the container after changing compose) so local strategy URLs are absolute; set the same variable on the admin service so the BFF can rewrite any relative /uploads response. docker compose restart does not apply new environment variables—use docker compose up -d --force-recreate api (and admin if needed).

Settings: Notifications

The route settings/notifications loads notificationTemplates (including bodyTemplate and variables) and notificationLogs via its Houdini query store in $lib/houdini/SettingsNotifications.ts. Inline editing uses the generated UpdateNotificationTemplateStore mutation; after a successful save, the page refetches by calling .fetch() on the main query store. Staff preferences for other users are read/updated with notificationPreferences(userId) and updateNotificationPreference (both require settings.edit).

Help System

Two components provide contextual help: HelpDrawer (full-panel, triggered by ? icon) and HelpTooltip (field-level). Content is stored in $lib/help/en.json and es.json:

{
  "products": {
    "title": "Products",
    "overview": "Manage your product catalog, pricing, and inventory.",
    "fields": {
      "sku": "Unique stock-keeping unit identifier.",
      "price": "Base price before customer-specific price lists."
    },
    "tips": ["Use bulk import to add products from a CSV file."]
  }
}

DataTable Component

All admin list pages use the shared DataTable component ($lib/components/DataTable.svelte). Examples below show the Orders, Products, and Customers list pages:

Orders list with charts

Products list

Customers list

Product detail uses the same patterns for child tables — here's the Variants tab of a product:

Product variants tab

It provides:

  • Server-side search — text input triggers URL-based search on Enter
  • Client-side fuzzy filter — "Quick filter this page" input uses trigram matching ($lib/utils/fuzzy.ts) across all visible columns
  • Sortable columns — all columns sort by default (sortable: false to disable). Headers with key === 'actions' are never sortable. Click toggles direction.
  • sortMode="server" (default) — updates URL sortBy / sortDir; the loader passes them to GraphQL. Server column names are derived with $lib/utils/dataTableSort.ts (resolveServerSortKey: camelCase → snake_case plus a few aliases like grandTotalAmountgrand_total).
  • sortMode="client" — for pages that load the full list in one request (brands, collections, CMS pages, audit, etc.): sorts the rows already in the browser (after the quick fuzzy filter).
  • Filters — togglable filter bar with configurable dropdowns (type: 'select' or type: 'date'). The Customers list mirrors Orders: multi-criteria server filters (status, credit hold, tax exempt, currency, risk level, customer group, created/updated date ranges) with URL-backed state.
  • Column visibility chooser — Columns button opens a popover with checkboxes for non-standard columns. Choices persist server-side in StaffProfile.column_preferences JSON column via updateMyProfile mutation (debounced 500ms)
  • Permission-gated export — Export CSV button only visible when user has the required permission codename
  • Bottom pagination — always at the bottom: "Showing X-Y of Z", per-page selector (20/40/80/120), page navigation

Column Visibility

Each column definition has a visibility field:

  • standard — always shown, cannot be hidden (e.g., Order #, Product Name)
  • default — shown by default, user can hide
  • optional — hidden by default, user can opt in (e.g., PO #, Carrier)

Usage

<script lang="ts">
  import DataTable from '$lib/components/DataTable.svelte';
  import type { ColumnDef } from '$lib/components/DataTable.svelte';

  const columns: ColumnDef[] = [
    { key: 'name', label: 'Name', visibility: 'standard' },
    { key: 'status', label: 'Status', visibility: 'default' },
    { key: 'slug', label: 'Slug', visibility: 'optional' },
  ];
</script>

{#snippet renderCell({ item, column, value })}
  {#if column.key === 'status'}
    <span class="badge">{value}</span>
  {:else}
    {value ?? '—'}
  {/if}
{/snippet}

<DataTable
  items={data.items}
  {columns}
  pageId="my-page"
  total={data.total}
  currentPage={data.page}
  pageSize={data.pageSize}
  sortBy={data.sortBy}
  sortDir={data.sortDir}
  searchQuery={data.search}
  columnPreferences={data.preferences?.columnPreferences?.['my-page']}
  permissions={data.permissions}
  exportPermission="import_export.export"
  exportFn={handleExport}
  onRowClick={(item) => goto(`/my-page/${item.id}`)}
  {renderCell}
/>

Shared Utilities

  • Fuzzy search$lib/utils/fuzzy.ts: fuzzyFilter(), fuzzyMatch(), trigrams(), trigramSim(), tokenize()
  • Server sort keys$lib/utils/dataTableSort.ts: resolveServerSortKey(), camelToSnake()
  • CSV export$lib/utils/export.ts: exportCsv(filename, headers, rows)

Cmd-K Federation (Decided #226, #227)

Extensions contribute quick actions and search results into the global Cmd-K palette. The +layout.svelte palette reads:

  • installedExtensions { adminQuickActions { href label iconName permission } } — flat list of registered quick actions, filtered by the caller's permissions
  • federatedSearch(query) — async, merges results from every extension's registered search_indexes provider into the palette's results

Quick actions show in the "Extensions" group of the palette by default. Use the iconName field (Lucide icon string) so the palette stays icon-consistent without each extension bundling assets. Federated search results are grouped by extension display name and capped at 10 per source.

Refund Approval Inbox (OQ #27)

/refund-approvals is the queue page for refunds that exceed the channel threshold or that the policy requires an approver for. The inbox uses Houdini stores in $lib/houdini/RefundApprovals.ts:

  • RefundRequestsListStore — paginated list with status filter (pending, decided, executed, failed)
  • DecideRefundApprovalStore — approve/reject mutation
  • RetryRefundExecutionStore — retries the Temporal workflow for failed rows

The detail panel shows per-tender progress (each card/ACH/store-credit tender separately) so the approver can see which legs already settled before deciding. Self-approval is blocked at the API; the UI hides the Approve button when the requester matches the current user.

Workflow Faults (Decided #200)

/workflow-faults lists rows from the workflow_faults table (sourced from vectis.workflow.fault.v1). Filters by severity, fault_code, and date range. Each row deep-links to the underlying entity (cart, order, refund request) and exposes a "Retry workflow" action where applicable.

Customer Onboarding Playbook

The application repo carries vectis/docs/CUSTOMER_ONBOARDING_PILOT.md — a structured runbook used by support / customer-success during white-glove onboarding (store-credit seeding, demo accounts, OAuth providers, Houdini schema regeneration). There is no in-admin route for this yet; reference the markdown directly.

Adding a New Admin Page

  1. Define GraphQL ops — create src/lib/houdini/MyPage.ts with a graphql(...) query store and any mutation stores
  2. Generate typesnpx houdini generate (runs as part of npm run check)
  3. Create the routesrc/routes/my-page/+page.ts (loader calling load_MyPage(event)) + +page.svelte
  4. Add navigation — append to mainSections in +layout.svelte:
    { href: '/my-page', label: 'My Page', icon: SomeIcon }
    
  5. Use DataTable — for list pages, define columns with visibility levels, use the DataTable component
  6. Add help content — add entries to both $lib/help/en.json and es.json
  7. Update docs — add a usage guide if the page is user-facing

Tip

Use Lucide icons consistently — 16px for sidebar nav, 24px for page headers. Import from @lucide/svelte.