feat(clients): scaffold top-level Clients section with sub-nav around Accounts
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add the `clients` top-level feature flag.
|
||||||
|
*
|
||||||
|
* Introduces a parent flag for the "Clients" sidebar section, which
|
||||||
|
* groups customer accounts today and will host calendar / quotes /
|
||||||
|
* bills / messaging in future PRs. The existing `customerPortal` flag
|
||||||
|
* is unchanged and continues to gate the /customer/* surface plus the
|
||||||
|
* Accounts sub-page; it now lives logically beneath `clients` in the
|
||||||
|
* Features tab.
|
||||||
|
*
|
||||||
|
* Initial value: mirrors the install's current `customerPortal` value
|
||||||
|
* so an admin who had the customer portal enabled keeps seeing the
|
||||||
|
* Clients sidebar entry after upgrade, and an admin who had it off
|
||||||
|
* doesn't suddenly see a new sidebar entry.
|
||||||
|
*
|
||||||
|
* Idempotent: re-runs are no-ops.
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
if (!(await knex.schema.hasTable('feature_flags'))) return;
|
||||||
|
|
||||||
|
const existing = await knex('feature_flags').where({ key: 'clients' }).first();
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
const portalRow = await knex('feature_flags').where({ key: 'customerPortal' }).first();
|
||||||
|
let initialValue = false;
|
||||||
|
if (portalRow) {
|
||||||
|
const raw = portalRow.value;
|
||||||
|
initialValue = raw === true || raw === 1 || raw === '1' || raw === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
await knex('feature_flags').insert({ key: 'clients', value: initialValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
if (!(await knex.schema.hasTable('feature_flags'))) return;
|
||||||
|
await knex('feature_flags').where({ key: 'clients' }).del();
|
||||||
|
};
|
||||||
+15
-11
@@ -568,19 +568,23 @@ app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
|
|||||||
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
|
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
|
||||||
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
|
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
|
||||||
app.use('/api/admin/users', require('./src/routes/adminUsers'));
|
app.use('/api/admin/users', require('./src/routes/adminUsers'));
|
||||||
// Customer portal (#354). The customerPortal feature flag is enforced
|
// Customer portal (#354). When the `customerPortal` feature flag is
|
||||||
// on the frontend via <RequireFeature flag="customerPortal" /> route
|
// OFF, both the admin-facing /api/admin/customers/* surface AND the
|
||||||
// guards (App.tsx) and AdminSidebar visibility — when the flag is off,
|
// customer-facing /api/customer/* surface return 410 Gone — turning
|
||||||
// users never reach these endpoints. Defence in depth is provided by
|
// the toggle off in Settings → Features cleanly kills the feature
|
||||||
// customerAccountsService.isCustomerPortalEnabled() in the few backend
|
// everywhere, not just in the UI. The frontend RequireFeature guard
|
||||||
// paths that matter (e.g. adminEvents customer_account_ids handling).
|
// + AdminSidebar visibility still apply for navigation, but a stale
|
||||||
// Admin routes are protected by adminAuth; customer routes by
|
// tab or third-party API client can't bypass the gate.
|
||||||
// customerAuth — so no additional route-level gate is needed.
|
const {
|
||||||
app.use('/api/admin/customers', require('./src/routes/adminCustomers'));
|
requireCustomerPortalEnabled,
|
||||||
|
requireCustomerPortalEnabledAdmin,
|
||||||
|
} = require('./src/middleware/requireCustomerPortal');
|
||||||
|
|
||||||
|
app.use('/api/admin/customers', requireCustomerPortalEnabledAdmin, require('./src/routes/adminCustomers'));
|
||||||
// Customer-side surface (#354). Strictly separate from /api/admin/* —
|
// Customer-side surface (#354). Strictly separate from /api/admin/* —
|
||||||
// distinct token type, distinct cookie, distinct middleware.
|
// distinct token type, distinct cookie, distinct middleware.
|
||||||
app.use('/api/customer/auth', require('./src/routes/customerAuth'));
|
app.use('/api/customer/auth', requireCustomerPortalEnabled, require('./src/routes/customerAuth'));
|
||||||
app.use('/api/customer', require('./src/routes/customer'));
|
app.use('/api/customer', requireCustomerPortalEnabled, require('./src/routes/customer'));
|
||||||
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
|
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
|
||||||
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
|
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
|
||||||
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
|
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Customer portal feature-flag gate.
|
||||||
|
*
|
||||||
|
* Blocks every /api/customer/* and /api/admin/customers/* endpoint
|
||||||
|
* when the `customerPortal` flag is off. Returns 410 Gone so the
|
||||||
|
* frontend can distinguish "feature has been disabled" from "you
|
||||||
|
* don't have access" (which would be 403) — useful for the customer
|
||||||
|
* dashboard's auto-redirect on a soft-kill scenario.
|
||||||
|
*
|
||||||
|
* Reads the flag via customerAccountsService.isCustomerPortalEnabled
|
||||||
|
* (which itself reads from the maintainer's feature_flags table),
|
||||||
|
* so a single source of truth.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const customerAccountsService = require('../services/customerAccountsService');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
async function isEnabled() {
|
||||||
|
try {
|
||||||
|
return await customerAccountsService.isCustomerPortalEnabled();
|
||||||
|
} catch (err) {
|
||||||
|
// Defensive: if the lookup throws (DB unavailable, table missing
|
||||||
|
// mid-migration), fail closed so an enabled-by-default fallback
|
||||||
|
// can't accidentally expose customer surfaces during boot.
|
||||||
|
logger.warn('requireCustomerPortal: feature flag lookup failed, treating as off', {
|
||||||
|
error: err?.message,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customer-facing endpoints. Returns 410 with a code the frontend
|
||||||
|
* can interpret to clear stale session storage + redirect to
|
||||||
|
* /admin/login.
|
||||||
|
*/
|
||||||
|
async function requireCustomerPortalEnabled(req, res, next) {
|
||||||
|
if (await isEnabled()) return next();
|
||||||
|
return res.status(410).json({
|
||||||
|
error: 'Customer portal is disabled',
|
||||||
|
code: 'CUSTOMER_PORTAL_DISABLED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-facing /api/admin/customers/* endpoints. Same gate, same
|
||||||
|
* status code — keeps the contract consistent across both halves of
|
||||||
|
* the customer-portal surface. The sidebar UI already hides the
|
||||||
|
* entry, but a stale tab or direct API call must also be blocked.
|
||||||
|
*/
|
||||||
|
async function requireCustomerPortalEnabledAdmin(req, res, next) {
|
||||||
|
if (await isEnabled()) return next();
|
||||||
|
return res.status(410).json({
|
||||||
|
error: 'Customer portal is disabled',
|
||||||
|
code: 'CUSTOMER_PORTAL_DISABLED',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
requireCustomerPortalEnabled,
|
||||||
|
requireCustomerPortalEnabledAdmin,
|
||||||
|
};
|
||||||
@@ -32,8 +32,14 @@ const KNOWN_FLAGS = [
|
|||||||
'messaging',
|
'messaging',
|
||||||
'analytics',
|
'analytics',
|
||||||
'userManagement',
|
'userManagement',
|
||||||
// Foundation flag for the customer-side surface (#354). See migration
|
// Top-level "Clients" section (#354 follow-up). Parent flag that
|
||||||
// 094 for the seeding rule.
|
// gates the /admin/clients/* sidebar entry. customerPortal,
|
||||||
|
// calendar, quotes, bills and messaging are conceptually its
|
||||||
|
// children — when `clients` is off none of them surface in the
|
||||||
|
// admin UI even if their individual flags are on.
|
||||||
|
'clients',
|
||||||
|
// Customer-side portal surface (#354). Gates /customer/* routes
|
||||||
|
// and the Accounts sub-page under Clients. See migration 095.
|
||||||
'customerPortal',
|
'customerPortal',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -49,6 +55,7 @@ const DEFAULT_FLAGS = {
|
|||||||
messaging: false,
|
messaging: false,
|
||||||
analytics: true,
|
analytics: true,
|
||||||
userManagement: true,
|
userManagement: true,
|
||||||
|
clients: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
async function readAllFlags() {
|
async function readAllFlags() {
|
||||||
@@ -69,13 +76,27 @@ function applyDependencyRules(flags) {
|
|||||||
// Sub-features can't outlive their parents.
|
// Sub-features can't outlive their parents.
|
||||||
if (out.quotes === false) out.bills = false;
|
if (out.quotes === false) out.bills = false;
|
||||||
if (out.calendar === false) out.calendarBooking = false;
|
if (out.calendar === false) out.calendarBooking = false;
|
||||||
|
// Clients parent flag is DERIVED from its children. Admins don't
|
||||||
|
// toggle it directly in the Features tab — they enable a specific
|
||||||
|
// sub-feature (Accounts today; Calendar/Quotes/Bills/Messaging
|
||||||
|
// later) and the Clients sidebar section lights up automatically.
|
||||||
|
// Computing the value here (rather than only on writes) means GET
|
||||||
|
// /admin/feature-flags also returns a consistent state if the DB
|
||||||
|
// ever drifts (e.g. partial migration run).
|
||||||
|
out.clients = Boolean(
|
||||||
|
out.customerPortal
|
||||||
|
// future siblings (out.calendar || out.quotes || out.bills || out.messaging) go here
|
||||||
|
);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const flags = await readAllFlags();
|
const flags = await readAllFlags();
|
||||||
res.json(flags);
|
// Always run the rules so derived flags (e.g. `clients`) and
|
||||||
|
// hard invariants (galleries always on) are consistent even if
|
||||||
|
// the DB row is stale or missing.
|
||||||
|
res.json(applyDependencyRules(flags));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to read feature flags', { error: error.message });
|
logger.error('Failed to read feature flags', { error: error.message });
|
||||||
res.status(500).json({ error: 'Failed to read feature flags' });
|
res.status(500).json({ error: 'Failed to read feature flags' });
|
||||||
|
|||||||
+42
-7
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Navigate, useParams } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ToastContainer } from 'react-toastify';
|
import { ToastContainer } from 'react-toastify';
|
||||||
import 'react-toastify/dist/ReactToastify.css';
|
import 'react-toastify/dist/ReactToastify.css';
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
} from './pages/customer';
|
} from './pages/customer';
|
||||||
import { CustomerAuthProvider } from './contexts/CustomerAuthContext';
|
import { CustomerAuthProvider } from './contexts/CustomerAuthContext';
|
||||||
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||||
|
import { ClientsLayout } from './components/admin/ClientsLayout';
|
||||||
import { RequireFeature } from './components/admin/RequireFeature';
|
import { RequireFeature } from './components/admin/RequireFeature';
|
||||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common';
|
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common';
|
||||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||||
@@ -90,6 +91,17 @@ function AnalyticsBootstrap() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backward-compat redirect for /admin/customers/:id → /admin/clients/accounts/:id.
|
||||||
|
* Needed because <Navigate to="..."> can't interpolate route params and we
|
||||||
|
* want stale bookmarks / email links to keep working after the Clients
|
||||||
|
* section reorg.
|
||||||
|
*/
|
||||||
|
function RedirectCustomerDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
return <Navigate to={`/admin/clients/accounts/${id}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// Track dark mode for toast theming
|
// Track dark mode for toast theming
|
||||||
const [toastTheme, setToastTheme] = useState<'light' | 'dark'>('light');
|
const [toastTheme, setToastTheme] = useState<'light' | 'dark'>('light');
|
||||||
@@ -146,14 +158,37 @@ function App() {
|
|||||||
<Route element={<RequireFeature flag="userManagement" />}>
|
<Route element={<RequireFeature flag="userManagement" />}>
|
||||||
<Route path="users" element={<UserManagementPage />} />
|
<Route path="users" element={<UserManagementPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
{/* Customer accounts (#354) — admin-side management.
|
{/* Clients section (#354 follow-up). Parent route
|
||||||
Hidden from sidebar + redirected away when the
|
gated by the top-level `clients` flag — when off
|
||||||
customerPortal flag is off. */}
|
the sidebar entry is hidden and every /admin/clients/*
|
||||||
<Route element={<RequireFeature flag="customerPortal" />}>
|
URL redirects to /admin/dashboard. Inside, the
|
||||||
<Route path="customers" element={<CustomerManagementPage />} />
|
ClientsLayout renders a Settings-style sub-nav
|
||||||
<Route path="customers/:id" element={<CustomerDetailPage />} />
|
and the active sub-feature's page through an
|
||||||
|
Outlet. Each sub-route is feature-flagged
|
||||||
|
independently. */}
|
||||||
|
<Route element={<RequireFeature flag="clients" />}>
|
||||||
|
<Route path="clients" element={<ClientsLayout />}>
|
||||||
|
<Route element={<RequireFeature flag="customerPortal" />}>
|
||||||
|
<Route path="accounts" element={<CustomerManagementPage />} />
|
||||||
|
<Route path="accounts/:id" element={<CustomerDetailPage />} />
|
||||||
|
</Route>
|
||||||
|
{/* Default: send /admin/clients (no sub-path) to
|
||||||
|
the first available sub-feature. Today that's
|
||||||
|
always accounts; when calendar/quotes ship they
|
||||||
|
get their own routes here and the empty-state
|
||||||
|
in ClientsLayout handles the rare "parent on,
|
||||||
|
all children off" case. */}
|
||||||
|
<Route index element={<Navigate to="/admin/clients/accounts" replace />} />
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* Old /admin/customers paths now live under
|
||||||
|
/admin/clients/accounts. Kept indefinitely as
|
||||||
|
redirects so existing bookmarks and email links
|
||||||
|
don't 404. */}
|
||||||
|
<Route path="customers" element={<Navigate to="/admin/clients/accounts" replace />} />
|
||||||
|
<Route path="customers/:id" element={<RedirectCustomerDetail />} />
|
||||||
|
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
|
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
X,
|
X,
|
||||||
Users,
|
Users,
|
||||||
UserCog,
|
Briefcase,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -27,7 +27,15 @@ interface NavItem {
|
|||||||
href: string;
|
href: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
permission?: string | false;
|
permission?: string | false;
|
||||||
|
/** Single required flag — entry hidden when this is false. */
|
||||||
featureFlag?: FeatureKey;
|
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).
|
// 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: '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.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
|
||||||
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view', featureFlag: 'userManagement' },
|
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view', featureFlag: 'userManagement' },
|
||||||
// Customer accounts (#354) — separate from admin users (#users.view)
|
// Clients section (#354 follow-up) — admin-side surface for the
|
||||||
// by design: customers log in at /customer/login with their own
|
// CRM-area sub-features. Today this entry leads to /admin/clients
|
||||||
// cookie + token type. Hidden when the customerPortal feature flag
|
// which renders a Settings-style sub-nav with one item (Accounts).
|
||||||
// is OFF (Settings → Features). The corresponding /customer/* routes
|
// When calendar / quotes / bills / messaging ship they slot in as
|
||||||
// also redirect away in that case (see RequireFeature in App.tsx).
|
// additional sub-nav items inside ClientsLayout without needing
|
||||||
{ nameKey: 'navigation.customers', href: '/admin/customers', icon: UserCog, permission: 'customers.view', featureFlag: 'customerPortal' },
|
// 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 }) => {
|
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) => {
|
const filteredNavigation = navigation.filter((item) => {
|
||||||
if (item.permission && !hasPermission(item.permission as string)) return false;
|
if (item.permission && !hasPermission(item.permission as string)) return false;
|
||||||
if (item.featureFlag && !flags[item.featureFlag]) 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;
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -19,11 +19,14 @@ export const DEFAULT_FLAGS: FeatureFlags = {
|
|||||||
messaging: false,
|
messaging: false,
|
||||||
analytics: true,
|
analytics: true,
|
||||||
userManagement: true,
|
userManagement: true,
|
||||||
// Customer portal (#354) defaults OFF on a fresh install — picpeak
|
// Top-level Clients section (#354 follow-up). Migration 097 mirrors
|
||||||
|
// the install's current customerPortal value so admins who already
|
||||||
|
// had the portal enabled keep seeing the section after upgrade.
|
||||||
|
clients: false,
|
||||||
|
// Customer portal (#354). Defaults OFF on a fresh install — picpeak
|
||||||
// ships as a focused gallery delivery tool, recurring-customer
|
// ships as a focused gallery delivery tool, recurring-customer
|
||||||
// logins are opt-in. Migration 094 flips this to TRUE on existing
|
// logins are opt-in. Migration 095 flips this to TRUE on existing
|
||||||
// installs (events>0) so the customer-portal foundation isn't
|
// installs (events>0).
|
||||||
// silently disabled mid-deployment.
|
|
||||||
customerPortal: false,
|
customerPortal: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -56,13 +59,15 @@ function applyDependencyRules(flags: FeatureFlags): FeatureFlags {
|
|||||||
out.galleries = true; // foundation — always on
|
out.galleries = true; // foundation — always on
|
||||||
if (out.quotes === false) out.bills = false; // bills depend on quotes
|
if (out.quotes === false) out.bills = false; // bills depend on quotes
|
||||||
if (out.calendar === false) out.calendarBooking = false; // booking depends on calendar
|
if (out.calendar === false) out.calendarBooking = false; // booking depends on calendar
|
||||||
// Customer-portal-dependent flags: if the customer portal is OFF the
|
// Clients parent flag is DERIVED from its children. Admins don't
|
||||||
// customer-side dashboard never renders, so the calendar/quotes/bills/
|
// toggle it directly — enabling any CRM-area sub-feature
|
||||||
// messaging customer-side surfaces have nowhere to live. Their server
|
// (Accounts today; future Calendar / Quotes / Bills / Messaging)
|
||||||
// toggles can stay at whatever the admin set (so re-enabling the
|
// lights up the Clients sidebar section automatically, and
|
||||||
// portal restores the previous state), but the dependency is
|
// disabling all of them hides it again.
|
||||||
// documented here for the FeaturesTab UI to disable child cards
|
out.clients = Boolean(
|
||||||
// visually when customerPortal is off.
|
out.customerPortal
|
||||||
|
// future siblings: || out.calendar || out.quotes || out.bills || out.messaging
|
||||||
|
);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
BarChart3,
|
BarChart3,
|
||||||
Users,
|
Users,
|
||||||
UserCog,
|
UserCog,
|
||||||
|
Briefcase,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Card } from '../../../components/common';
|
import { Button, Card } from '../../../components/common';
|
||||||
@@ -108,24 +109,30 @@ export const FeaturesTab: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Customer accounts (#354) — recurring customer logins. The
|
{/* Clients (#354 follow-up). Visual grouping for the CRM-area
|
||||||
calendar / quotes / bills / messaging cards below are
|
sub-features. The "Clients" sidebar section itself is gated
|
||||||
customer-side surfaces; they only render in the customer
|
by a derived `clients` flag (computed from whether any
|
||||||
dashboard when this is on. */}
|
child below is on), so there's no explicit parent toggle —
|
||||||
<Section title={t('settings.features.sections.customers', 'Customers')}>
|
admins just enable the specific feature they want and the
|
||||||
|
section appears automatically. */}
|
||||||
|
<Section title={t('settings.features.sections.clients', 'Clients')}>
|
||||||
<FeatureCard
|
<FeatureCard
|
||||||
icon={UserCog}
|
icon={UserCog}
|
||||||
title={t('settings.features.customerPortal.title', 'Customer portal')}
|
title={t('settings.features.customerPortal.title', 'Accounts')}
|
||||||
description={t(
|
description={t(
|
||||||
'settings.features.customerPortal.description',
|
'settings.features.customerPortal.description',
|
||||||
'Persistent customer logins. Recurring clients see all their assigned galleries from one place — no per-event passwords. Foundation for Calendar / Quotes / Bills (which only render in the customer dashboard when this is on).',
|
'Persistent customer logins. Recurring clients see all their assigned galleries from one place — no per-event passwords. Customers log in at /customer/login and you manage them under Clients → Accounts.',
|
||||||
)}
|
)}
|
||||||
status="beta"
|
status="beta"
|
||||||
statusLabel={statusLabel('beta')}
|
statusLabel={statusLabel('beta')}
|
||||||
sidebarLabel={t('navigation.customers', 'Customers')}
|
sidebarLabel={t('clients.subnav.accounts', 'Accounts')}
|
||||||
enabled={staged.customerPortal}
|
enabled={staged.customerPortal}
|
||||||
onToggle={(next) => setFlag('customerPortal', next)}
|
onToggle={(next) => setFlag('customerPortal', next)}
|
||||||
/>
|
/>
|
||||||
|
{/* Future sub-features (Calendar / Quotes / Bills / Messaging)
|
||||||
|
slot in here as FeatureCard entries when they ship. No
|
||||||
|
placeholder cards today — the Clients section just shows
|
||||||
|
what's actually built. */}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Communication */}
|
{/* Communication */}
|
||||||
|
|||||||
@@ -168,7 +168,8 @@
|
|||||||
"cmsPages": "CMS-Seiten",
|
"cmsPages": "CMS-Seiten",
|
||||||
"users": "Benutzer",
|
"users": "Benutzer",
|
||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
"customers": "Kunden"
|
"customers": "Kunden",
|
||||||
|
"clients": "Kunden"
|
||||||
},
|
},
|
||||||
"eventTypes": {
|
"eventTypes": {
|
||||||
"title": "Veranstaltungsarten",
|
"title": "Veranstaltungsarten",
|
||||||
@@ -1449,7 +1450,8 @@
|
|||||||
"scheduling": "Terminplanung",
|
"scheduling": "Terminplanung",
|
||||||
"sales": "Vertrieb",
|
"sales": "Vertrieb",
|
||||||
"insights": "Auswertungen & Zugriff",
|
"insights": "Auswertungen & Zugriff",
|
||||||
"customers": "Kunden"
|
"customers": "Kunden",
|
||||||
|
"clients": "Kunden"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"stable": "stabil",
|
"stable": "stabil",
|
||||||
@@ -1497,8 +1499,12 @@
|
|||||||
"warning": "Bestehende Benutzerkonten bleiben gültig; die Admin-Oberfläche für deren Verwaltung wird ausgeblendet, bis Sie dies wieder aktivieren."
|
"warning": "Bestehende Benutzerkonten bleiben gültig; die Admin-Oberfläche für deren Verwaltung wird ausgeblendet, bis Sie dies wieder aktivieren."
|
||||||
},
|
},
|
||||||
"customerPortal": {
|
"customerPortal": {
|
||||||
"title": "Kundenportal",
|
"title": "Konten",
|
||||||
"description": "Persistente Kunden-Logins. Wiederkehrende Kunden sehen alle zugeordneten Galerien an einem Ort — keine Passwörter pro Event. Grundlage für Kalender / Angebote / Rechnungen (die nur im Kundendashboard erscheinen, wenn diese Option aktiviert ist)."
|
"description": "Persistente Kunden-Logins. Wiederkehrende Kunden sehen alle zugewiesenen Galerien an einem Ort — keine Passwörter pro Event. Kunden melden sich unter /customer/login an, verwaltet werden sie unter Kunden → Konten."
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"title": "Kunden",
|
||||||
|
"description": "Hauptschalter für den Kunden-Bereich in der Seitenleiste. Aus blendet den Bereich und alle Unterfunktionen aus, unabhängig von deren individuellen Schaltern – beim erneuten Einschalten wird der vorherige Zustand wiederhergestellt."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"customerSurface": {
|
"customerSurface": {
|
||||||
@@ -2854,5 +2860,17 @@
|
|||||||
"success": "Kunde gelöscht",
|
"success": "Kunde gelöscht",
|
||||||
"error": "Kunde konnte nicht gelöscht werden"
|
"error": "Kunde konnte nicht gelöscht werden"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"title": "Kunden",
|
||||||
|
"subtitle": "Kundenkonten, Termine, Angebote und Rechnungen für wiederkehrende Kunden.",
|
||||||
|
"navAriaLabel": "Kunden-Navigation",
|
||||||
|
"empty": {
|
||||||
|
"title": "Keine Kunden-Funktionen aktiviert",
|
||||||
|
"body": "Aktiviere „Konten\" (oder eine andere Kunden-Unterfunktion) unter Einstellungen → Funktionen, um loszulegen."
|
||||||
|
},
|
||||||
|
"subnav": {
|
||||||
|
"accounts": "Konten"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,7 +168,8 @@
|
|||||||
"cmsPages": "CMS Pages",
|
"cmsPages": "CMS Pages",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
"customers": "Customers"
|
"customers": "Customers",
|
||||||
|
"clients": "Clients"
|
||||||
},
|
},
|
||||||
"archives": {
|
"archives": {
|
||||||
"title": "Archives",
|
"title": "Archives",
|
||||||
@@ -1088,7 +1089,8 @@
|
|||||||
"scheduling": "Scheduling",
|
"scheduling": "Scheduling",
|
||||||
"sales": "Sales",
|
"sales": "Sales",
|
||||||
"insights": "Insights & Access",
|
"insights": "Insights & Access",
|
||||||
"customers": "Customers"
|
"customers": "Customers",
|
||||||
|
"clients": "Clients"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"stable": "stable",
|
"stable": "stable",
|
||||||
@@ -1136,8 +1138,12 @@
|
|||||||
"warning": "Existing user accounts stay valid; the admin UI for managing them will be hidden until you re-enable this."
|
"warning": "Existing user accounts stay valid; the admin UI for managing them will be hidden until you re-enable this."
|
||||||
},
|
},
|
||||||
"customerPortal": {
|
"customerPortal": {
|
||||||
"title": "Customer portal",
|
"title": "Accounts",
|
||||||
"description": "Persistent customer logins. Recurring clients see all their assigned galleries from one place — no per-event passwords. Foundation for Calendar / Quotes / Bills (which only render in the customer dashboard when this is on)."
|
"description": "Persistent customer logins. Recurring clients see all their assigned galleries from one place — no per-event passwords. Customers log in at /customer/login and you manage them under Clients → Accounts."
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"title": "Clients",
|
||||||
|
"description": "Master switch for the Clients sidebar section. Off hides the section and every sub-feature below regardless of their individual toggles — re-enable to restore them to whatever you set last."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"customerSurface": {
|
"customerSurface": {
|
||||||
@@ -2854,5 +2860,17 @@
|
|||||||
"success": "Customer erased",
|
"success": "Customer erased",
|
||||||
"error": "Could not erase customer"
|
"error": "Could not erase customer"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"title": "Clients",
|
||||||
|
"subtitle": "Customer accounts, scheduling, quotes and billing for recurring clients.",
|
||||||
|
"navAriaLabel": "Clients navigation",
|
||||||
|
"empty": {
|
||||||
|
"title": "No client features enabled",
|
||||||
|
"body": "Enable Accounts (or another Clients sub-feature) under Settings → Features to get started."
|
||||||
|
},
|
||||||
|
"subnav": {
|
||||||
|
"accounts": "Accounts"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,7 +169,8 @@
|
|||||||
"backup": "Sauvegarde et Restauration",
|
"backup": "Sauvegarde et Restauration",
|
||||||
"cmsPages": "Pages CMS",
|
"cmsPages": "Pages CMS",
|
||||||
"users": "Utilisateurs",
|
"users": "Utilisateurs",
|
||||||
"customers": "Clients"
|
"customers": "Clients",
|
||||||
|
"clients": "Clients"
|
||||||
},
|
},
|
||||||
"archives": {
|
"archives": {
|
||||||
"title": "Archives",
|
"title": "Archives",
|
||||||
@@ -1071,11 +1072,16 @@
|
|||||||
"navAriaLabel": "Navigation des paramètres",
|
"navAriaLabel": "Navigation des paramètres",
|
||||||
"features": {
|
"features": {
|
||||||
"sections": {
|
"sections": {
|
||||||
"customers": "Clients"
|
"customers": "Clients",
|
||||||
|
"clients": "Clients"
|
||||||
},
|
},
|
||||||
"customerPortal": {
|
"customerPortal": {
|
||||||
"title": "Portail client",
|
"title": "Comptes",
|
||||||
"description": "Connexions client persistantes. Les clients récurrents voient toutes leurs galeries assignées au même endroit — plus de mots de passe par événement. Base pour Calendrier / Devis / Factures (qui n'apparaissent dans le tableau de bord client que lorsque cette option est activée)."
|
"description": "Connexions client persistantes. Les clients récurrents voient toutes leurs galeries assignées au même endroit — pas de mots de passe par événement. Les clients se connectent sur /customer/login et vous les gérez sous Clients → Comptes."
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"title": "Clients",
|
||||||
|
"description": "Interrupteur principal pour la section Clients de la barre latérale. Désactivé, masque la section et toutes les sous-fonctionnalités quelles que soient leurs bascules individuelles — réactiver les restaure dans leur état précédent."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"customerSurface": {
|
"customerSurface": {
|
||||||
@@ -2808,5 +2814,17 @@
|
|||||||
"success": "Client effacé",
|
"success": "Client effacé",
|
||||||
"error": "Impossible d'effacer le client"
|
"error": "Impossible d'effacer le client"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"title": "Clients",
|
||||||
|
"subtitle": "Comptes clients, planification, devis et facturation pour les clients récurrents.",
|
||||||
|
"navAriaLabel": "Navigation Clients",
|
||||||
|
"empty": {
|
||||||
|
"title": "Aucune fonctionnalité client activée",
|
||||||
|
"body": "Activez Comptes (ou une autre sous-fonctionnalité Clients) dans Paramètres → Fonctionnalités pour commencer."
|
||||||
|
},
|
||||||
|
"subnav": {
|
||||||
|
"accounts": "Comptes"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,7 +168,8 @@
|
|||||||
"cmsPages": "CMS-pagina's",
|
"cmsPages": "CMS-pagina's",
|
||||||
"users": "Gebruikers",
|
"users": "Gebruikers",
|
||||||
"calendar": "Agenda",
|
"calendar": "Agenda",
|
||||||
"customers": "Klanten"
|
"customers": "Klanten",
|
||||||
|
"clients": "Klanten"
|
||||||
},
|
},
|
||||||
"archives": {
|
"archives": {
|
||||||
"title": "Archieven",
|
"title": "Archieven",
|
||||||
@@ -1088,7 +1089,8 @@
|
|||||||
"scheduling": "Planning",
|
"scheduling": "Planning",
|
||||||
"sales": "Verkoop",
|
"sales": "Verkoop",
|
||||||
"insights": "Inzichten & Toegang",
|
"insights": "Inzichten & Toegang",
|
||||||
"customers": "Klanten"
|
"customers": "Klanten",
|
||||||
|
"clients": "Klanten"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"stable": "stabiel",
|
"stable": "stabiel",
|
||||||
@@ -1136,8 +1138,12 @@
|
|||||||
"warning": "Bestaande gebruikersaccounts blijven geldig; de admin-interface om ze te beheren wordt verborgen totdat u dit weer inschakelt."
|
"warning": "Bestaande gebruikersaccounts blijven geldig; de admin-interface om ze te beheren wordt verborgen totdat u dit weer inschakelt."
|
||||||
},
|
},
|
||||||
"customerPortal": {
|
"customerPortal": {
|
||||||
"title": "Klantenportaal",
|
"title": "Accounts",
|
||||||
"description": "Permanente klantlogins. Terugkerende klanten zien al hun toegewezen galerijen op één plek — geen wachtwoorden per evenement. Basis voor Agenda / Offertes / Facturen (die alleen in het klantdashboard verschijnen als dit aan staat)."
|
"description": "Permanente klantlogins. Terugkerende klanten zien al hun toegewezen galerijen op één plek — geen wachtwoorden per evenement. Klanten loggen in op /customer/login en je beheert ze onder Klanten → Accounts."
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"title": "Klanten",
|
||||||
|
"description": "Hoofdschakelaar voor het Klanten-onderdeel in de zijbalk. Uitgeschakeld verbergt het onderdeel en alle subfuncties ongeacht hun individuele schakelaars — opnieuw inschakelen herstelt de vorige status."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"customerSurface": {
|
"customerSurface": {
|
||||||
@@ -2854,5 +2860,17 @@
|
|||||||
"success": "Klant gewist",
|
"success": "Klant gewist",
|
||||||
"error": "Klant kon niet worden gewist"
|
"error": "Klant kon niet worden gewist"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"title": "Klanten",
|
||||||
|
"subtitle": "Klantaccounts, planning, offertes en facturering voor terugkerende klanten.",
|
||||||
|
"navAriaLabel": "Klanten-navigatie",
|
||||||
|
"empty": {
|
||||||
|
"title": "Geen klantfuncties ingeschakeld",
|
||||||
|
"body": "Schakel Accounts (of een andere Klanten-subfunctie) in onder Instellingen → Functies om te beginnen."
|
||||||
|
},
|
||||||
|
"subnav": {
|
||||||
|
"accounts": "Accounts"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,8 @@
|
|||||||
"cmsPages": "Páginas CMS",
|
"cmsPages": "Páginas CMS",
|
||||||
"users": "Utilizadores",
|
"users": "Utilizadores",
|
||||||
"calendar": "Calendário",
|
"calendar": "Calendário",
|
||||||
"customers": "Clientes"
|
"customers": "Clientes",
|
||||||
|
"clients": "Clientes"
|
||||||
},
|
},
|
||||||
"archives": {
|
"archives": {
|
||||||
"title": "Arquivos",
|
"title": "Arquivos",
|
||||||
@@ -1105,7 +1106,8 @@
|
|||||||
"scheduling": "Agendamento",
|
"scheduling": "Agendamento",
|
||||||
"sales": "Vendas",
|
"sales": "Vendas",
|
||||||
"insights": "Análises & Acesso",
|
"insights": "Análises & Acesso",
|
||||||
"customers": "Clientes"
|
"customers": "Clientes",
|
||||||
|
"clients": "Clientes"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"stable": "estável",
|
"stable": "estável",
|
||||||
@@ -1153,8 +1155,12 @@
|
|||||||
"warning": "Contas de usuário existentes permanecem válidas; a interface administrativa para gerenciá-las ficará oculta até você reativar isto."
|
"warning": "Contas de usuário existentes permanecem válidas; a interface administrativa para gerenciá-las ficará oculta até você reativar isto."
|
||||||
},
|
},
|
||||||
"customerPortal": {
|
"customerPortal": {
|
||||||
"title": "Portal do cliente",
|
"title": "Contas",
|
||||||
"description": "Logins persistentes para clientes. Clientes recorrentes veem todas as galerias atribuídas em um só lugar — sem senhas por evento. Base para Calendário / Orçamentos / Faturas (que só aparecem no painel do cliente quando isto está ativo)."
|
"description": "Logins persistentes para clientes. Clientes recorrentes veem todas as galerias atribuídas em um só lugar — sem senhas por evento. Os clientes entram em /customer/login e você os gerencia em Clientes → Contas."
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"title": "Clientes",
|
||||||
|
"description": "Chave principal da seção Clientes na barra lateral. Desligado oculta a seção e todas as sub-funcionalidades independentemente dos seus controles individuais — reativar restaura o estado anterior."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"customerSurface": {
|
"customerSurface": {
|
||||||
@@ -2887,5 +2893,17 @@
|
|||||||
"success": "Cliente apagado",
|
"success": "Cliente apagado",
|
||||||
"error": "Não foi possível apagar o cliente"
|
"error": "Não foi possível apagar o cliente"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"title": "Clientes",
|
||||||
|
"subtitle": "Contas de clientes, agendamento, orçamentos e faturamento para clientes recorrentes.",
|
||||||
|
"navAriaLabel": "Navegação de Clientes",
|
||||||
|
"empty": {
|
||||||
|
"title": "Nenhuma funcionalidade de cliente ativada",
|
||||||
|
"body": "Ative Contas (ou outra sub-funcionalidade de Clientes) em Configurações → Recursos para começar."
|
||||||
|
},
|
||||||
|
"subnav": {
|
||||||
|
"accounts": "Contas"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,7 +174,8 @@
|
|||||||
"cmsPages": "CMS-страницы",
|
"cmsPages": "CMS-страницы",
|
||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
"calendar": "Календарь",
|
"calendar": "Календарь",
|
||||||
"customers": "Клиенты"
|
"customers": "Клиенты",
|
||||||
|
"clients": "Клиенты"
|
||||||
},
|
},
|
||||||
"archives": {
|
"archives": {
|
||||||
"title": "Архивы",
|
"title": "Архивы",
|
||||||
@@ -1122,7 +1123,8 @@
|
|||||||
"scheduling": "Планирование",
|
"scheduling": "Планирование",
|
||||||
"sales": "Продажи",
|
"sales": "Продажи",
|
||||||
"insights": "Аналитика и доступ",
|
"insights": "Аналитика и доступ",
|
||||||
"customers": "Клиенты"
|
"customers": "Клиенты",
|
||||||
|
"clients": "Клиенты"
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"stable": "стабильно",
|
"stable": "стабильно",
|
||||||
@@ -1170,8 +1172,12 @@
|
|||||||
"warning": "Существующие учётные записи остаются действительными; интерфейс управления ими будет скрыт до повторного включения."
|
"warning": "Существующие учётные записи остаются действительными; интерфейс управления ими будет скрыт до повторного включения."
|
||||||
},
|
},
|
||||||
"customerPortal": {
|
"customerPortal": {
|
||||||
"title": "Клиентский портал",
|
"title": "Учётные записи",
|
||||||
"description": "Постоянные логины клиентов. Постоянные клиенты видят все назначенные галереи в одном месте — без паролей для каждого события. Основа для Календаря / Смет / Счетов (которые отображаются в клиентской панели только при включённой опции)."
|
"description": "Постоянные учётные записи клиентов. Постоянные клиенты видят все назначенные галереи в одном месте — без паролей для каждого события. Клиенты входят на /customer/login, а вы управляете ими в разделе Клиенты → Учётные записи."
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"title": "Клиенты",
|
||||||
|
"description": "Главный переключатель раздела «Клиенты» в боковой панели. Выключено — раздел и все подфункции скрыты независимо от их отдельных переключателей. При повторном включении состояние подфункций восстанавливается."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"customerSurface": {
|
"customerSurface": {
|
||||||
@@ -2920,5 +2926,17 @@
|
|||||||
"success": "Данные клиента стёрты",
|
"success": "Данные клиента стёрты",
|
||||||
"error": "Не удалось стереть данные клиента"
|
"error": "Не удалось стереть данные клиента"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"clients": {
|
||||||
|
"title": "Клиенты",
|
||||||
|
"subtitle": "Учётные записи клиентов, расписание, сметы и счета для постоянных клиентов.",
|
||||||
|
"navAriaLabel": "Навигация по клиентам",
|
||||||
|
"empty": {
|
||||||
|
"title": "Нет включённых клиентских функций",
|
||||||
|
"body": "Включите «Учётные записи» (или другую подфункцию «Клиенты») в Настройки → Функции, чтобы начать."
|
||||||
|
},
|
||||||
|
"subnav": {
|
||||||
|
"accounts": "Учётные записи"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export const CustomerDetailPage: React.FC = () => {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
|
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
|
||||||
toast.success(t('customers.deactivate.success', 'Customer deactivated'));
|
toast.success(t('customers.deactivate.success', 'Customer deactivated'));
|
||||||
navigate('/admin/customers');
|
navigate('/admin/clients/accounts');
|
||||||
},
|
},
|
||||||
onError: () => toast.error(t('customers.deactivate.error', 'Could not deactivate customer')),
|
onError: () => toast.error(t('customers.deactivate.error', 'Could not deactivate customer')),
|
||||||
});
|
});
|
||||||
@@ -152,7 +152,7 @@ export const CustomerDetailPage: React.FC = () => {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
|
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
|
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
|
||||||
toast.success(t('customers.erase.success', 'Customer erased'));
|
toast.success(t('customers.erase.success', 'Customer erased'));
|
||||||
navigate('/admin/customers');
|
navigate('/admin/clients/accounts');
|
||||||
},
|
},
|
||||||
onError: () => toast.error(t('customers.erase.error', 'Could not erase customer')),
|
onError: () => toast.error(t('customers.erase.error', 'Could not erase customer')),
|
||||||
});
|
});
|
||||||
@@ -176,7 +176,7 @@ export const CustomerDetailPage: React.FC = () => {
|
|||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<Link
|
<Link
|
||||||
to="/admin/customers"
|
to="/admin/clients/accounts"
|
||||||
className="p-2 -ml-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
className="p-2 -ml-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
||||||
aria-label={t('common.back', 'Back')}
|
aria-label={t('common.back', 'Back')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ export const CustomerManagementPage: React.FC = () => {
|
|||||||
{filteredCustomers.map((c) => (
|
{filteredCustomers.map((c) => (
|
||||||
<tr key={c.id} className="border-t" style={{ borderColor: 'var(--color-surface-border)' }}>
|
<tr key={c.id} className="border-t" style={{ borderColor: 'var(--color-surface-border)' }}>
|
||||||
<td className="px-3 py-3">
|
<td className="px-3 py-3">
|
||||||
<Link to={`/admin/customers/${c.id}`} className="text-theme hover:underline">
|
<Link to={`/admin/clients/accounts/${c.id}`} className="text-theme hover:underline">
|
||||||
{renderCustomerName(c)}
|
{renderCustomerName(c)}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ export type FeatureKey =
|
|||||||
| 'messaging'
|
| 'messaging'
|
||||||
| 'analytics'
|
| 'analytics'
|
||||||
| 'userManagement'
|
| 'userManagement'
|
||||||
// Foundation flag for the customer-side surface (#354). Gates the
|
// Top-level "Clients" section (#354 follow-up). Parent flag that
|
||||||
// /customer/* routes (login, dashboard, profile, accept-invite,
|
// gates the /admin/clients/* sidebar entry. customerPortal,
|
||||||
// reset-password) and the admin Customers management page. The
|
// calendar, quotes, bills and messaging are conceptually its
|
||||||
// calendar / calendarBooking / quotes / bills / messaging flags
|
// children — when `clients` is off none of them surface in the
|
||||||
// above hang off this — they only appear in the customer dashboard
|
// admin UI even if their individual flags are on.
|
||||||
// when customerPortal is also ON.
|
| 'clients'
|
||||||
|
// Customer-side portal surface (#354). Gates /customer/* routes
|
||||||
|
// (login, dashboard, profile, accept-invite, reset-password) and
|
||||||
|
// the Accounts sub-page under Clients in the admin UI.
|
||||||
| 'customerPortal';
|
| 'customerPortal';
|
||||||
|
|
||||||
export type FeatureFlags = Record<FeatureKey, boolean>;
|
export type FeatureFlags = Record<FeatureKey, boolean>;
|
||||||
|
|||||||
Reference in New Issue
Block a user