feat(clients): scaffold top-level Clients section with sub-nav around Accounts

This commit is contained in:
Luca
2026-05-11 16:17:14 +02:00
parent 35f5b86d0f
commit 9091ed4012
18 changed files with 562 additions and 81 deletions
+41 -7
View File
@@ -8,7 +8,7 @@ import {
Settings,
X,
Users,
UserCog,
Briefcase,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -27,7 +27,15 @@ interface NavItem {
href: string;
icon: React.ComponentType<{ className?: string }>;
permission?: string | false;
/** Single required flag — entry hidden when this is false. */
featureFlag?: FeatureKey;
/**
* "At least one of these must be on" — used by the Clients section
* to hide the sidebar entry when the parent flag is on but no
* child sub-feature is enabled. Empty arrays are treated as no
* constraint.
*/
featureFlagsAny?: FeatureKey[];
}
// Sidebar shape after the Settings reorg (#feature-flags-settings-reorg).
@@ -47,12 +55,30 @@ const navigation: NavItem[] = [
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view', featureFlag: 'userManagement' },
// Customer accounts (#354) — separate from admin users (#users.view)
// by design: customers log in at /customer/login with their own
// cookie + token type. Hidden when the customerPortal feature flag
// is OFF (Settings → Features). The corresponding /customer/* routes
// also redirect away in that case (see RequireFeature in App.tsx).
{ nameKey: 'navigation.customers', href: '/admin/customers', icon: UserCog, permission: 'customers.view', featureFlag: 'customerPortal' },
// Clients section (#354 follow-up) — admin-side surface for the
// CRM-area sub-features. Today this entry leads to /admin/clients
// which renders a Settings-style sub-nav with one item (Accounts).
// When calendar / quotes / bills / messaging ship they slot in as
// additional sub-nav items inside ClientsLayout without needing
// their own top-level sidebar entry.
//
// Gate uses the parent `clients` flag (master). The Accounts page
// itself is independently gated by `customerPortal` inside the
// route tree — that nested check is invisible from here.
//
// `permission: 'customers.view'` is the only Clients-area
// permission today; future sub-features (booking, billing) get
// their own permission keys and the gate here grows into an OR.
{
nameKey: 'navigation.clients', href: '/admin/clients', icon: Briefcase,
permission: 'customers.view',
featureFlag: 'clients',
// Hide the entry when the parent is on but no sub-feature is —
// there's nothing inside ClientsLayout to link to. Add future
// sub-flags (calendar, quotes, bills, messaging) here as they
// ship; the entry reappears the moment any of them is enabled.
featureFlagsAny: ['customerPortal'],
},
];
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) => {
@@ -64,6 +90,14 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
const filteredNavigation = navigation.filter((item) => {
if (item.permission && !hasPermission(item.permission as string)) return false;
if (item.featureFlag && !flags[item.featureFlag]) return false;
// featureFlagsAny: entry is hidden when none of the listed
// sub-flags are on, even if the parent flag IS on. Used by
// the Clients section so the sidebar entry only appears when
// there's at least one sub-feature it can link to.
if (item.featureFlagsAny && item.featureFlagsAny.length > 0
&& !item.featureFlagsAny.some((k) => flags[k])) {
return false;
}
return true;
});
@@ -0,0 +1,164 @@
/**
* Clients section layout (#354 follow-up).
*
* Wraps /admin/clients/* routes with a Settings-style left sub-nav.
* Today the only sub-nav entry is "Accounts" — when calendar / quotes
* / bills / messaging ship they get added to `navItems` below and
* mounted as nested routes in App.tsx. No placeholder UI; absent
* entries simply don't render.
*
* Visual pattern intentionally mirrors SettingsPage: 220px left rail
* on desktop, native <select> on mobile, accent-dark pill for the
* active item with white icon + label.
*/
import React from 'react';
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Briefcase, UserCog } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
interface NavItem {
key: string;
to: string;
label: string;
icon: LucideIcon;
/**
* Feature flag that must be ON for this entry to render. The
* parent `clients` flag has already been verified by the
* RequireFeature gate around this layout, so children only need
* to declare their own sub-flag here.
*/
featureFlag: FeatureKey;
}
export const ClientsLayout: React.FC = () => {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { flags } = useFeatureFlags();
const navItems: NavItem[] = [
{
key: 'accounts',
to: '/admin/clients/accounts',
label: t('clients.subnav.accounts', 'Accounts'),
icon: UserCog,
featureFlag: 'customerPortal',
},
// Add future sub-features here as they ship:
// { key: 'calendar', to: '/admin/clients/calendar', ... featureFlag: 'calendar' }
// { key: 'quotes', to: '/admin/clients/quotes', ... featureFlag: 'quotes' }
// { key: 'bills', to: '/admin/clients/bills', ... featureFlag: 'bills' }
// etc. The empty-state below disappears automatically once any of
// these is enabled.
];
const enabledItems = navItems.filter((item) => flags[item.featureFlag]);
// When the parent `clients` flag is on but no sub-feature is enabled,
// there's nothing to render. Settings → Features is one click away
// and tells the admin exactly what to flip on.
if (enabledItems.length === 0) {
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{t('clients.title', 'Clients')}
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing for recurring clients.')}
</p>
</div>
<div className="rounded-xl border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900 p-8 text-center">
<Briefcase className="w-10 h-10 mx-auto mb-3 text-neutral-400" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
{t('clients.empty.title', 'No client features enabled')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t(
'clients.empty.body',
'Enable Accounts (or another Clients sub-feature) under Settings → Features to get started.',
)}
</p>
</div>
</div>
);
}
return (
<div>
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{t('clients.title', 'Clients')}
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing for recurring clients.')}
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-6 lg:gap-8">
{/* Mobile: native select dropdown — keeps every option reachable
in one tap on touch devices, no horizontal scroll. */}
<div className="lg:hidden">
<label htmlFor="clients-section" className="sr-only">
{t('clients.navAriaLabel', 'Clients navigation')}
</label>
<select
id="clients-section"
value={location.pathname}
onChange={(e) => navigate(e.target.value)}
className="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm font-medium text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
{enabledItems.map((item) => (
<option key={item.key} value={item.to}>{item.label}</option>
))}
</select>
</div>
{/* Desktop: sticky left rail */}
<aside className="hidden lg:block">
<nav
aria-label={t('clients.navAriaLabel', 'Clients navigation')}
className="sticky top-6 space-y-1"
>
{enabledItems.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.key}
to={item.to}
className={({ isActive }) =>
`group w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
isActive
? 'bg-accent-dark text-white'
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800'
}`
}
>
{({ isActive }) => (
<>
<Icon
className={`w-4 h-4 flex-shrink-0 ${
isActive
? 'text-white'
: 'text-neutral-500 dark:text-neutral-400 group-hover:text-neutral-700 dark:group-hover:text-neutral-200'
}`}
/>
<span className="truncate">{item.label}</span>
</>
)}
</NavLink>
);
})}
</nav>
</aside>
<div className="min-w-0">
<Outlet />
</div>
</div>
</div>
);
};