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.

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.

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. Current drill-down groups:

  • Catalog (/products) — Categories, Brands, Collections, Attributes, Traits, Inventory, Price Lists, QuickLinks
  • Content (/cms) — CMS Pages (parent), Media, Navigation, Tags
  • Customer Service (/fraud) — Fraud, Returns, Store Credit, Registrations, Pending Changes
  • Settings — full settings list, with further drill-downs for Checkout and Accounts

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.

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). 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)

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.