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.
Sidebar drill-down submenus¶
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: falseto disable). Headers withkey === 'actions'are never sortable. Click toggles direction. sortMode="server"(default) — updates URLsortBy/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 likegrandTotalAmount→grand_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'ortype: '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_preferencesJSON column viaupdateMyProfilemutation (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 hideoptional— 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¶
- Define GraphQL ops — create
src/lib/houdini/MyPage.tswith agraphql(...)query store and any mutation stores - Generate types —
npx houdini generate(runs as part ofnpm run check) - Create the route —
src/routes/my-page/+page.ts(loader callingload_MyPage(event)) ++page.svelte - Add navigation — append to
mainSectionsin+layout.svelte: - Use DataTable — for list pages, define columns with visibility levels, use the DataTable component
- Add help content — add entries to both
$lib/help/en.jsonandes.json - 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.