screenshot: admin github button (#778)

This commit is contained in:
Paul Nothaft
2026-07-10 09:50:18 +02:00
commit e94e440858
1160 changed files with 291466 additions and 0 deletions
@@ -0,0 +1,27 @@
import React, { useEffect, useRef } from 'react';
import { useTheme } from '../contexts/ThemeContext';
import { usePublicSettings } from '../hooks/usePublicSettings';
interface GlobalThemeProviderProps {
children: React.ReactNode;
}
export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ children }) => {
const { setTheme } = useTheme();
const themeAppliedRef = useRef(false);
const { data: settingsData } = usePublicSettings();
// Apply global theme when settings are loaded (but not on gallery pages)
useEffect(() => {
// Skip if we're on a gallery page - gallery pages handle their own themes
const isGalleryPage = window.location.pathname.includes('/gallery/');
if (!themeAppliedRef.current && settingsData?.theme_config && !isGalleryPage) {
themeAppliedRef.current = true;
// Instance-wide force color mode is enforced inside ThemeContext.applyTheme.
setTheme(settingsData.theme_config);
}
}, [settingsData, setTheme]);
return <>{children}</>;
};
@@ -0,0 +1,88 @@
import React, { useEffect } from 'react';
import { AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { buildResourceUrl } from '../utils/url';
export const MaintenanceMode: React.FC = () => {
const { t, i18n } = useTranslation();
const { data: settings } = usePublicSettings({ retry: false });
// Set language based on system settings
useEffect(() => {
if (settings?.default_language && settings.default_language !== i18n.language) {
i18n.changeLanguage(settings.default_language);
}
}, [settings?.default_language, i18n]);
return (
<div className="min-h-screen bg-neutral-50 flex flex-col">
{/* Header with branding - Always show, with PicPeak logo as fallback */}
<div className="bg-white border-b border-neutral-200 py-4">
<div className="container">
<div className="flex items-center justify-center">
<img
src={settings?.branding_logo_url ?
(settings.branding_logo_url.startsWith('http')
? settings.branding_logo_url
: buildResourceUrl(settings.branding_logo_url))
: '/picpeak-logo-transparent.png'
}
alt={settings?.branding_company_name || 'PicPeak'}
className="h-12 w-auto object-contain"
/>
{settings?.branding_company_name && settings.branding_company_name !== 'PicPeak' && (
<div className="ml-4 text-center">
<h2 className="text-xl font-semibold text-neutral-800">{settings.branding_company_name}</h2>
{settings.branding_company_tagline && (
<p className="text-sm text-neutral-600">{settings.branding_company_tagline}</p>
)}
</div>
)}
</div>
</div>
</div>
{/* Main content */}
<div className="flex-1 flex items-center justify-center p-4">
<div className="max-w-md w-full text-center">
<div className="inline-flex items-center justify-center w-20 h-20 bg-amber-100 rounded-full mb-6">
<AlertTriangle className="w-10 h-10 text-amber-600" />
</div>
<h1 className="text-3xl font-bold text-neutral-900 mb-4">
{t('maintenance.title')}
</h1>
<p className="text-lg text-neutral-600 mb-8">
{t('maintenance.message')}
</p>
{settings?.branding_support_email && (
<p className="text-sm text-neutral-500 mt-8">
{t('maintenance.urgentMatters')}{' '}
<a
href={`mailto:${settings.branding_support_email}`}
className="text-primary-600 hover:text-primary-700"
>
{settings.branding_support_email}
</a>
</p>
)}
</div>
</div>
{/* Footer */}
{settings?.branding_footer_text && (
<footer className="py-4 border-t border-neutral-200">
<div className="container text-center">
<p className="text-sm text-neutral-500">
{settings.branding_footer_text}
</p>
</div>
</footer>
)}
</div>
);
};
@@ -0,0 +1,41 @@
import React, { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { MaintenanceMode } from './MaintenanceMode';
import { useMaintenanceMode } from '../contexts/MaintenanceContext';
import { setMaintenanceModeCallback } from '../config/api';
interface MaintenanceWrapperProps {
children: React.ReactNode;
}
// Maintenance detection lives in two places:
// 1. The axios interceptor in config/api.ts flips the flag on any 503 response.
// 2. MaintenanceContext polls /public/settings every 30s and reads the explicit
// maintenance_mode field (via the shared usePublicSettings hook).
//
// The maintenance screen ONLY blocks customer/gallery/public routes. Admin
// routes (/admin/*) are never blocked: an admin must always be able to reach
// the panel to turn maintenance back off, and the admin auth layer already
// handles access (AdminLayout redirects a logged-out admin to /admin/login).
// Gating /admin/* here on an "is the admin logged in?" check is what caused the
// lockout — it hid the login page itself, and after login the check went stale
// (login → dashboard is a client-side nav within /admin, so it never re-ran),
// leaving a logged-in admin stuck on the maintenance screen.
export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => {
const location = useLocation();
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
const isAdminRoute = location.pathname.startsWith('/admin');
useEffect(() => {
setMaintenanceModeCallback((enabled: boolean) => {
setMaintenanceMode(enabled);
});
}, [setMaintenanceMode]);
if (isMaintenanceMode && !isAdminRoute) {
return <MaintenanceMode />;
}
return <>{children}</>;
};
@@ -0,0 +1,169 @@
/**
* Accounting section layout (migration 122).
*
* Wraps /admin/accounting/* routes with a Settings-style left sub-nav,
* mirroring ClientsLayout. Today it hosts the Tax report (relocated here
* from CRM when the `accounting` flag is on); the inbound-document inbox and
* expenses pages slot in as additional sub-nav entries when their UIs land.
*/
import React from 'react';
import { NavLink, Outlet, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Landmark, Calculator, Inbox, Wallet } 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. */
featureFlag: FeatureKey;
}
export const AccountingLayout: React.FC = () => {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { flags } = useFeatureFlags();
const navItems: NavItem[] = [
{
key: 'inbox',
to: '/admin/accounting/inbox',
label: t('accounting.subnav.incomingInvoices', 'Incoming invoices'),
icon: Inbox,
featureFlag: 'incomingInvoices',
},
{
key: 'expenses',
to: '/admin/accounting/expenses',
label: t('accounting.subnav.expenses', 'Expenses'),
icon: Wallet,
featureFlag: 'expenses',
},
{
key: 'tax-report',
// The Treuhänder export now lives ON the Tax page (same period/currency
// filters, same data) instead of a separate sub-tab — see TaxReportPage.
to: '/admin/accounting/tax-report',
label: t('accounting.subnav.taxReport', 'Tax'),
icon: Calculator,
featureFlag: 'taxReport',
},
// Chart of accounts moved to Settings → Accounting (all accounting config
// lives there now); this section keeps only the operational pages.
// Future: Erfolgsrechnung (Layer B).
];
const enabledItems = navItems.filter((item) => flags[item.featureFlag]);
const header = (
<div className="mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{t('accounting.title', 'Accounting')}
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
{t('accounting.subtitle', 'Inbound supplier invoices, expenses and reporting.')}
</p>
</div>
);
if (enabledItems.length === 0) {
return (
<div>
{header}
<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">
<Landmark 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('accounting.empty.title', 'No accounting features enabled')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t('accounting.empty.body', 'Enable the Tax report (or another accounting sub-feature) under Settings → Features to get started.')}
</p>
</div>
</div>
);
}
return (
<div>
{header}
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-6 lg:gap-8">
{/* Mobile: native select dropdown */}
<div className="lg:hidden">
<label htmlFor="accounting-section" className="sr-only">
{t('accounting.navAriaLabel', 'Accounting navigation')}
</label>
<select
id="accounting-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('accounting.navAriaLabel', 'Accounting 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>
);
};
/**
* Index redirect for /admin/accounting — send to the first enabled
* sub-feature (Incoming invoices preferred, then Tax export). When none
* are on, render nothing; AccountingLayout shows its empty state.
*/
export const AccountingIndex: React.FC = () => {
const { flags } = useFeatureFlags();
if (flags.incomingInvoices) return <Navigate to="/admin/accounting/inbox" replace />;
if (flags.expenses) return <Navigate to="/admin/accounting/expenses" replace />;
if (flags.taxReport) return <Navigate to="/admin/accounting/tax-report" replace />;
return null;
};
@@ -0,0 +1,18 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
import { AdminAuthProvider, PermissionsProvider } from '../../contexts';
import { AdminDarkModeProvider } from '../../contexts/AdminDarkModeContext';
export const AdminAuthWrapper: React.FC = () => {
return (
<AdminAuthProvider>
<PermissionsProvider>
<AdminDarkModeProvider>
<Outlet />
</AdminDarkModeProvider>
</PermissionsProvider>
</AdminAuthProvider>
);
};
AdminAuthWrapper.displayName = 'AdminAuthWrapper';
@@ -0,0 +1,79 @@
import React, { useState, useEffect } from 'react';
import { api } from '../../config/api';
interface AdminAuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
fallback?: React.ReactNode;
}
export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = ({
src,
fallback,
alt,
...props
}) => {
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
let objectUrl: string | null = null;
const loadImage = async () => {
try {
setLoading(true);
setError(false);
setImageSrc(null);
// Make authenticated request to get the image
const response = await api.get(src, {
responseType: 'blob',
});
if (!cancelled) {
// Create object URL from blob
objectUrl = URL.createObjectURL(response.data);
setImageSrc(objectUrl);
setLoading(false);
}
} catch {
// Image loading failed - handled by error state
if (!cancelled) {
setError(true);
setLoading(false);
}
}
};
if (src) {
loadImage();
}
// Cleanup function
return () => {
cancelled = true;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [src]);
if (loading) {
return (
<div className="w-full h-full bg-neutral-200 animate-pulse" />
);
}
if (error) {
return fallback ? (
<>{fallback}</>
) : (
<div className="w-full h-full bg-neutral-100 flex items-center justify-center text-neutral-400">
<span className="text-xs">Failed to load</span>
</div>
);
}
return <img src={imageSrc || ''} alt={alt} {...props} />;
};
@@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react';
import { api } from '../../config/api';
interface AdminAuthenticatedVideoProps extends React.VideoHTMLAttributes<HTMLVideoElement> {
src: string;
fallback?: React.ReactNode;
}
export const AdminAuthenticatedVideo: React.FC<AdminAuthenticatedVideoProps> = ({
src,
fallback,
...props
}) => {
const [videoSrc, setVideoSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
let objectUrl: string | null = null;
const loadVideo = async () => {
try {
setLoading(true);
setError(false);
setVideoSrc(null);
const response = await api.get(src, { responseType: 'blob' });
if (!cancelled) {
objectUrl = URL.createObjectURL(response.data);
setVideoSrc(objectUrl);
setLoading(false);
}
} catch {
if (!cancelled) {
setError(true);
setLoading(false);
}
}
};
if (src) {
loadVideo();
}
return () => {
cancelled = true;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [src]);
if (loading) {
return <div className="w-full h-full bg-neutral-200 animate-pulse" />;
}
if (error || !videoSrc) {
return fallback ? (
<>{fallback}</>
) : (
<div className="w-full h-full bg-neutral-100 flex items-center justify-center text-neutral-400">
<span className="text-xs">Failed to load</span>
</div>
);
}
return (
<video
src={videoSrc}
controls
preload="metadata"
{...props}
/>
);
};
@@ -0,0 +1,207 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Heart, Bookmark, Star, MessageCircle } from 'lucide-react';
import { Loading } from '../common';
import { guestsService, AdminGuest } from '../../services/guests.service';
import { AuthenticatedImage } from '../common/AuthenticatedImage';
import { buildResourceUrl } from '../../utils/url';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
interface AdminGuestDetailProps {
eventId: number;
guest: AdminGuest;
onClose: () => void;
}
type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented';
export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, guest, onClose }) => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const [tab, setTab] = useState<Tab>('all');
const { data, isLoading } = useQuery({
queryKey: ['admin-guest-detail', eventId, guest.id],
queryFn: () => guestsService.getGuestDetail(eventId, guest.id),
});
const selections = data?.selections;
const liked = selections?.liked || [];
const favorited = selections?.favorited || [];
const rated = selections?.rated || [];
const commented = selections?.commented || [];
// "all" view combines the three visual selection types.
type GridItem = { photo: { id: number; filename: string; thumbnail_url: string }; badges: string[] };
const allItems: GridItem[] = [];
const seen = new Map<number, GridItem>();
const add = (photo: { id: number; filename: string; thumbnail_url: string }, badge: string) => {
if (!seen.has(photo.id)) {
const item: GridItem = { photo, badges: [badge] };
seen.set(photo.id, item);
allItems.push(item);
} else {
seen.get(photo.id)!.badges.push(badge);
}
};
liked.forEach((p) => add(p, 'like'));
favorited.forEach((p) => add(p, 'favorite'));
rated.forEach((r) => add(r.photo, 'rating'));
const visibleItems: GridItem[] =
tab === 'all'
? allItems
: tab === 'liked'
? liked.map((p) => ({ photo: p, badges: ['like'] }))
: tab === 'favorited'
? favorited.map((p) => ({ photo: p, badges: ['favorite'] }))
: tab === 'rated'
? rated.map((r) => ({ photo: r.photo, badges: [`${r.rating}`] }))
: [];
return (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto p-4 pt-16">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{guest.name}</h2>
{guest.email && (
<p className="text-sm text-neutral-500 dark:text-neutral-400">{guest.email}</p>
)}
</div>
<button
type="button"
onClick={onClose}
className="p-1 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
>
<X className="w-5 h-5" />
</button>
</div>
{isLoading ? (
<div className="p-8">
<Loading size="lg" text={t('admin.guests.loadingDetail', 'Loading selections...')} />
</div>
) : (
<div className="overflow-y-auto p-4">
{/* Stats */}
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{liked.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<Heart className="w-3 h-3" />
{t('admin.guests.columns.likes', 'Likes')}
</div>
</div>
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{favorited.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<Bookmark className="w-3 h-3" />
{t('admin.guests.columns.favorites', 'Favorites')}
</div>
</div>
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{rated.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<Star className="w-3 h-3" />
{t('admin.guests.columns.ratings', 'Ratings')}
</div>
</div>
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{commented.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<MessageCircle className="w-3 h-3" />
{t('admin.guests.columns.comments', 'Comments')}
</div>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-neutral-200 dark:border-neutral-700 mb-4">
{(['all', 'liked', 'favorited', 'rated', 'commented'] as const).map((k) => (
<button
key={k}
type="button"
onClick={() => setTab(k)}
className={`px-3 py-2 text-sm font-medium border-b-2 transition ${
tab === k
? 'border-accent text-accent'
: 'border-transparent text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100'
}`}
>
{t(`admin.guests.detail.${k}`, k)}
</button>
))}
</div>
{/* Content */}
{tab === 'commented' ? (
commented.length === 0 ? (
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
{t('admin.guests.detail.noComments', 'No comments')}
</div>
) : (
<div className="space-y-3">
{commented.map((c, idx) => (
<div key={idx} className="flex gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded">
<AuthenticatedImage
src={buildResourceUrl(c.photo.thumbnail_url)}
alt={c.photo.filename}
className="w-16 h-16 object-cover rounded flex-shrink-0"
/>
<div className="flex-1">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{c.photo.filename} · {fmtDateTime(c.created_at)}
</div>
<p className="text-sm text-neutral-900 dark:text-neutral-100 mt-1">{c.comment}</p>
</div>
</div>
))}
</div>
)
) : visibleItems.length === 0 ? (
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
{t('admin.guests.detail.empty', 'No selections in this category')}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
{visibleItems.map((item) => (
<div key={item.photo.id} className="relative group">
<AuthenticatedImage
src={buildResourceUrl(item.photo.thumbnail_url)}
alt={item.photo.filename}
className="w-full aspect-square object-cover rounded"
/>
<div className="absolute top-1 right-1 flex gap-1">
{item.badges.map((b, i) => (
<span
key={i}
className="bg-black/60 text-white text-xs px-1.5 py-0.5 rounded"
>
{b === 'like' ? '♥' : b === 'favorite' ? '★' : b}
</span>
))}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity text-white text-xs p-2 rounded-b">
{item.photo.filename}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,344 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Trash2, Eye, Download, UserPlus, Grid3x3, List } from 'lucide-react';
import { Card, Button, Loading } from '../common';
import { guestsService, AdminGuest } from '../../services/guests.service';
import { AdminGuestDetail } from './AdminGuestDetail';
import { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
import { GuestInviteDialog } from './GuestInviteDialog';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast, useModal } from '../../hooks';
interface AdminGuestsListProps {
eventId: number;
eventName?: string;
}
type View = 'list' | 'aggregate';
export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, eventName }) => {
const { t } = useTranslation();
const { format: fmtDate } = useLocalizedDate();
const [view, setView] = useState<View>('list');
const [selectedGuest, setSelectedGuest] = useState<AdminGuest | null>(null);
const [mergeMode, setMergeMode] = useState(false);
const [mergeSelection, setMergeSelection] = useState<number[]>([]);
const inviteModal = useModal();
const { data, isLoading, refetch } = useQuery({
queryKey: ['admin-guests', eventId],
queryFn: () => guestsService.getEventGuests(eventId),
});
const deleteMutation = useMutationWithToast({
mutationFn: (guestId: number) => guestsService.deleteGuest(eventId, guestId),
successMessage: t('admin.guests.deletedToast', 'Guest removed'),
invalidateKeys: [['admin-guests', eventId]],
errorMessage: () => t('admin.guests.deletedError', 'Failed to remove guest'),
});
const mergeMutation = useMutationWithToast({
mutationFn: ({ keepId, mergeIds }: { keepId: number; mergeIds: number[] }) =>
guestsService.mergeGuests(eventId, keepId, mergeIds),
successMessage: t('admin.guests.mergedToast', 'Guests merged'),
invalidateKeys: [['admin-guests', eventId]],
onSuccess: () => {
setMergeMode(false);
setMergeSelection([]);
},
errorMessage: () => t('admin.guests.mergedError', 'Failed to merge guests'),
});
const handleDelete = (guest: AdminGuest) => {
if (window.confirm(t('admin.guests.forgetGuestConfirm', 'Remove this guest? Their picks will be anonymized but kept in aggregate totals.'))) {
deleteMutation.mutate(guest.id);
}
};
const handleExport = async (guest: AdminGuest, format: 'txt' | 'csv' | 'json') => {
try {
const blob = await guestsService.exportGuest(eventId, guest.id, format);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${guest.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.${format}`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch {
toast.error(t('admin.guests.exportError', 'Export failed'));
}
};
const handleExportAll = async (format: 'txt' | 'csv' | 'json') => {
try {
const blob = await guestsService.exportAllGuests(eventId, format);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `event-${eventId}-guests.zip`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch {
toast.error(t('admin.guests.exportError', 'Export failed'));
}
};
const toggleMergeSelection = (id: number) => {
setMergeSelection((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
const performMerge = () => {
if (mergeSelection.length < 2) {
toast.warning(t('admin.guests.mergeSelectAtLeastTwo', 'Select at least 2 guests to merge'));
return;
}
const [keepId, ...mergeIds] = mergeSelection;
const keepName = data?.guests.find((g) => g.id === keepId)?.name;
const confirmMsg = t(
'admin.guests.mergeConfirm',
'Merge {{count}} guests into {{name}}? This cannot be undone.',
{ count: mergeSelection.length, name: keepName || '#' + keepId }
);
if (window.confirm(confirmMsg)) {
mergeMutation.mutate({ keepId, mergeIds });
}
};
if (isLoading) {
return <Loading size="lg" text={t('admin.guests.loading', 'Loading guests...')} />;
}
const guests = data?.guests || [];
if (view === 'aggregate') {
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setView('list')} leftIcon={<List className="w-4 h-4" />}>
{t('admin.guests.backToList', 'Back to list')}
</Button>
</div>
</div>
<GuestSelectionsAggregate eventId={eventId} />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('admin.guests.title', 'Guests')} ({guests.length})
</h3>
<div className="flex items-center gap-2">
{mergeMode ? (
<>
<span className="text-sm text-neutral-600 dark:text-neutral-400">
{t('admin.guests.mergeSelected', '{{count}} selected', { count: mergeSelection.length })}
</span>
<Button variant="primary" size="sm" onClick={performMerge} disabled={mergeSelection.length < 2}>
{t('admin.guests.mergeNow', 'Merge selected')}
</Button>
<Button variant="ghost" size="sm" onClick={() => { setMergeMode(false); setMergeSelection([]); }}>
{t('common.cancel', 'Cancel')}
</Button>
</>
) : (
<>
<Button
variant="outline"
size="sm"
leftIcon={<UserPlus className="w-4 h-4" />}
onClick={inviteModal.open}
>
{t('admin.guests.createInvite', 'Create invite')}
</Button>
<Button
variant="outline"
size="sm"
leftIcon={<Grid3x3 className="w-4 h-4" />}
onClick={() => setView('aggregate')}
>
{t('admin.guests.aggregateView', 'By popularity')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setMergeMode(true)}
disabled={guests.length < 2}
>
{t('admin.guests.mergeMode', 'Merge')}
</Button>
<div className="relative group">
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />}>
{t('admin.guests.exportAll', 'Export all')}
</Button>
<div className="absolute right-0 top-full mt-1 hidden group-hover:block bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded shadow-lg z-10 min-w-[120px]">
{(['csv', 'txt', 'json'] as const).map((fmt) => (
<button
key={fmt}
onClick={() => handleExportAll(fmt)}
className="block w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
>
{fmt.toUpperCase()}
</button>
))}
</div>
</div>
</>
)}
</div>
</div>
{guests.length === 0 ? (
<Card>
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">
{t('admin.guests.empty', 'No guests have registered yet.')}
</div>
</Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<tr>
{mergeMode && <th className="px-4 py-3 w-8" />}
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.name', 'Name')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.email', 'Email')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.likes', 'Likes')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.favorites', 'Favorites')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.comments', 'Comments')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.ratings', 'Ratings')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.lastSeen', 'Last seen')}
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-700">
{guests.map((guest) => (
<tr key={guest.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-800">
{mergeMode && (
<td className="px-4 py-3">
<input
type="checkbox"
checked={mergeSelection.includes(guest.id)}
onChange={() => toggleMergeSelection(guest.id)}
className="w-4 h-4 text-accent rounded focus:ring-primary-500"
/>
</td>
)}
<td className="px-4 py-3 font-medium text-neutral-900 dark:text-neutral-100">
{guest.name}
{guest.email_verified_at && (
<span className="ml-2 text-xs text-green-600"></span>
)}
</td>
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
{guest.email || '—'}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.likes}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.favorites}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.comments}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.ratings}
</td>
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
{fmtDate(guest.last_seen_at)}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => setSelectedGuest(guest)}
className="p-1 text-neutral-500 hover:text-accent"
title={t('admin.guests.view', 'View details')}
>
<Eye className="w-4 h-4" />
</button>
<div className="relative group">
<button
type="button"
className="p-1 text-neutral-500 hover:text-accent"
title={t('admin.guests.export', 'Export')}
>
<Download className="w-4 h-4" />
</button>
<div className="absolute right-0 top-full mt-1 hidden group-hover:block bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded shadow-lg z-10 min-w-[100px]">
{(['csv', 'txt', 'json'] as const).map((fmt) => (
<button
key={fmt}
onClick={() => handleExport(guest, fmt)}
className="block w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
>
{fmt.toUpperCase()}
</button>
))}
</div>
</div>
<button
type="button"
onClick={() => handleDelete(guest)}
className="p-1 text-neutral-500 hover:text-red-600"
title={t('admin.guests.forgetGuest', 'Remove guest')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{selectedGuest && (
<AdminGuestDetail
eventId={eventId}
guest={selectedGuest}
onClose={() => setSelectedGuest(null)}
/>
)}
{inviteModal.isOpen && (
<GuestInviteDialog
eventId={eventId}
eventName={eventName}
onClose={() => {
inviteModal.close();
refetch();
}}
/>
)}
</div>
);
};
@@ -0,0 +1,481 @@
import React, { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2, Sun, Moon, Globe, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useAdminAuth } from '../../contexts';
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
import { useOnClickOutside } from '../../hooks/useOnClickOutside';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useModal } from '../../hooks';
import { PasswordChangeModal } from './PasswordChangeModal';
import { LanguageSelector, SUPPORTED_LANGUAGES } from '../common';
import { notificationsService } from '../../services/notifications.service';
import { toast } from 'react-toastify';
import { buildResourceUrl } from '../../utils/url';
interface AdminHeaderProps {
onMenuClick: () => void;
}
export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const navigate = useNavigate();
const { user, logout } = useAdminAuth();
const { isDark, toggle: toggleDarkMode, forcedMode } = useAdminDarkMode();
const { t, i18n } = useTranslation();
const { format, formatDistanceToNow } = useLocalizedDate();
const userMenuModal = useModal();
const userMenuLangSectionModal = useModal();
const notificationsModal = useModal();
const passwordModal = useModal();
const queryClient = useQueryClient();
const { data: brandingSettings, isLoading: brandingLoading } = usePublicSettings();
const currentLanguage = SUPPORTED_LANGUAGES.find(lang => lang.code === i18n.language) || SUPPORTED_LANGUAGES[0];
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
// Dark-mode logo variant. Symmetric fallback: if only one logo is set,
// use it for both modes (dark → dark||light, light → light||dark).
const lightLogo = brandingSettings?.branding_logo_url?.trim();
const darkLogo = brandingSettings?.branding_logo_url_dark?.trim();
const logoUrl = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
const logoDisplayMode = brandingSettings?.branding_logo_display_mode || 'logo_and_text';
// Logo placement honours the same Branding > Logo Position setting
// the gallery does. 'sidepanel' moves the logo into the AdminSidebar
// brand row — suppress it here so it doesn't double up. left /
// center / right reposition the logo block within this header bar.
const logoPosition = brandingSettings?.branding_logo_position || 'left';
const logoInSidebar = logoPosition === 'sidepanel';
const resolvedLogoUrl = logoUrl
? (logoUrl.startsWith('http') ? logoUrl : buildResourceUrl(logoUrl))
: '/picpeak-kamera-transparent.png';
// #523 follow-up 2: graceful fallback when the configured logo URL
// 404s or stalls. Without an error handler, the <img> failure draws
// the browser's default broken-image-icon + alt text rendering — see
// Rekoo-PS's 3.60.3-beta.0 screenshot where "Arkan Studio" appeared
// as the alt text of a broken icon, not the real wordmark span. The
// chain:
// 1. configured URL fails → try the bundled picpeak fallback
// 2. bundled fallback fails → hide the image entirely, let the
// wordmark carry the brand
// Reset on URL change so a dark-mode toggle (which can flip lightLogo
// ↔ darkLogo) retries the new URL instead of being permanently sad.
const [logoLoadError, setLogoLoadError] = useState(false);
const [fallbackLoadError, setFallbackLoadError] = useState(false);
useEffect(() => {
setLogoLoadError(false);
setFallbackLoadError(false);
}, [resolvedLogoUrl]);
const logoImgSrc = logoLoadError ? '/picpeak-kamera-transparent.png' : resolvedLogoUrl;
// Renders the logo + wordmark block per the current logo_display_mode.
// Re-used in left / center / right slots below so all three positions
// produce visually identical brand chrome.
const showLogo = !logoInSidebar && (logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text');
const showText = logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text';
const logoEffectivelyVisible = showLogo && !fallbackLoadError;
// On <sm the wordmark hides when the logo carries the brand identity
// (logo_and_text). Same pattern LanguageSelector uses for its language
// name (#527). Without this, even with truncate, a phone-width admin
// shows things like "Ar..." after the logo image — readable but ugly,
// and on accounts whose company name lets the text reach the right
// cluster it overlaps the LanguageSelector button (#523 follow-up,
// Rekoo-PS's "Arkan Studio" screenshot in v3.59.0-beta.0). text_only
// mode keeps the wordmark on every width — nothing else would render.
//
// #523 follow-up 2: when both the configured URL AND the bundled
// fallback have failed (fallbackLoadError → logoEffectivelyVisible
// false), unhide the wordmark on <sm too — otherwise the phone header
// shows nothing at all for the brand block.
const wordmarkVisibilityClass = logoEffectivelyVisible ? 'hidden sm:inline' : 'inline';
const renderBrandBlock = () => {
// Skeleton placeholder while `usePublicSettings()` is in flight (#523
// follow-up — Rekoo-PS's "logo took some time to load" screenshot in
// 3.60.1-beta.0). The previous code used the static fallback image
// /picpeak-kamera-transparent.png as the during-loading state, and
// because the wordmark is `hidden sm:inline` whenever a logo is
// *intended* to be shown, a phone-width admin saw an empty header
// for the ~hundreds-of-ms window before the real branding payload
// arrived. The skeleton block holds the same h-8 (so no layout
// shift when the real content lands) and is wider on sm+ to hint
// at the wordmark slot.
if (brandingLoading) {
return (
<div className="flex items-center gap-2 min-w-0">
<div className="h-8 w-8 sm:w-32 bg-neutral-200 dark:bg-neutral-700 rounded animate-pulse" />
</div>
);
}
// min-w-0 + truncate on the name span so long company names shrink
// within the left cluster instead of pushing into the right-side
// action buttons on narrow mobile widths (#523 regression).
return (
<div className="flex items-center gap-2 min-w-0">
{logoEffectivelyVisible && (
<img
src={logoImgSrc}
alt={companyName}
className="h-8 w-auto object-contain flex-shrink-0"
onError={() => {
// First failure: configured URL → try the bundled fallback.
// Second failure: bundled fallback → hide entirely, let
// the wordmark carry the brand (#523 follow-up 2).
if (!logoLoadError) setLogoLoadError(true);
else setFallbackLoadError(true);
}}
/>
)}
{showText && (
<span className={`${wordmarkVisibilityClass} text-xl sm:text-2xl truncate`} style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
)}
</div>
);
};
// #523 follow-up: closes the user-menu lang sub-section whenever the
// outer dropdown closes, so re-opening it doesn't surprise the user
// with the language list already expanded from the previous session.
const closeUserMenu = () => {
userMenuModal.close();
userMenuLangSectionModal.close();
};
const handleUserMenuLangSelect = (languageCode: string) => {
i18n.changeLanguage(languageCode);
closeUserMenu();
};
const userMenuRef = useRef<HTMLDivElement>(null);
const notificationRef = useRef<HTMLDivElement>(null);
useOnClickOutside(userMenuRef, closeUserMenu);
useOnClickOutside(notificationRef, notificationsModal.close);
const handleLogout = () => {
logout();
navigate('/admin/login');
};
// Fetch notifications
const { data: notificationsData } = useQuery({
queryKey: ['notifications', notificationsModal.isOpen],
queryFn: () => notificationsService.getNotifications(notificationsModal.isOpen, 20),
refetchInterval: 60000, // Refetch every minute
});
// Mark all as read mutation
const markAllAsReadMutation = useMutation({
mutationFn: notificationsService.markAllAsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast.success(t('admin.notificationToasts.markedAllRead'));
},
});
// Clear notifications mutation
const clearAllMutation = useMutation({
mutationFn: notificationsService.clearAllNotifications,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast.success(t('admin.notificationToasts.clearedAll', { count: data.deletedCount }));
},
});
const notifications = notificationsData?.notifications || [];
const unreadCount = notificationsData?.unreadCount || 0;
return (
// border-b is on the OUTER <header> so it spans the full header
// width — putting it on the inner row instead left a 32px gap on
// the left (the px-4/sm:px-6/lg:px-8 padding) before the divider
// started, visible as a missing segment between the sidebar's
// brand-row bottom border and the header's bottom border.
//
// The outer header is explicitly h-16 with the default border-box,
// so the 1px border is painted INSIDE the 64px height (y=63..64)
// — same model as the sidebar's brand row (also h-16 border-b
// border-box). Both bottom borders meet at the exact same
// y-coordinate.
<header className="sticky top-0 z-30 bg-white dark:bg-neutral-900 h-16 border-b border-neutral-200 dark:border-neutral-700">
<div className="px-4 sm:px-6 lg:px-8 h-full">
<div className="relative flex items-center justify-between h-full gap-3">
{/* Left side - Menu button, optional left-positioned logo, Date.
Logo block appears here when logo_position = 'left' (the
default). For 'center' it's absolutely positioned across
the whole header; for 'right' it sits in the right-side
cluster just before the action widgets. */}
<div className="flex items-center gap-3 min-w-0">
<button
onClick={onMenuClick}
className="lg:hidden text-neutral-500 hover:text-neutral-700"
>
<Menu className="w-6 h-6" />
</button>
{!logoInSidebar && logoPosition === 'left' && renderBrandBlock()}
{/* Narrow-viewport fallback for center / right positions.
On screens where the centered (lg+) or right-anchored
(md+) brand block is hidden, render it on the left so
the admin chrome doesn't go logo-less on phones. */}
{!logoInSidebar && logoPosition === 'center' && (
<div className="flex lg:hidden">{renderBrandBlock()}</div>
)}
{!logoInSidebar && logoPosition === 'right' && (
<div className="flex md:hidden">{renderBrandBlock()}</div>
)}
{/* Date display - hidden on smaller screens.
The vertical divider + left padding only render when
the logo sits on the left of the date (logo_position
= 'left'). For 'center' / 'right' / 'sidepanel' the
left cluster has only the mobile menu (which is
hidden on xl+), so a divider would be floating on
its own with nothing to separate. */}
{/* Explicit `flex items-center` (not just block) + the
self-stretch on the border-l variant so the divider
covers the full header row, AND the text baseline sits
exactly on the same y-axis as the brand-block / sidebar
logo to its left. The previous `hidden xl:block` left
the <p> inheriting its block-level vertical position,
which read as slightly off-centre next to the larger
logo image. */}
<div className={`hidden xl:flex items-center self-stretch ml-1 ${
logoPosition === 'left' && !logoInSidebar
? 'pl-3 border-l border-neutral-200 dark:border-neutral-700'
: ''
}`}>
<p className="text-base leading-none text-neutral-700 dark:text-neutral-300 m-0">
{format(new Date(), 'PPPP')}
</p>
</div>
</div>
{/* Centered logo. Absolutely positioned so the existing
left/right clusters keep their natural sizing; hidden on
sub-lg widths to avoid colliding with the right-side
action cluster on narrow screens. pointer-events-none on
the wrapper passes hover/click through (the logo itself
has no interactive children today). */}
{!logoInSidebar && logoPosition === 'center' && (
<div className="hidden lg:flex absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
{renderBrandBlock()}
</div>
)}
{/* Right side actions (preceded by the brand block when
logo_position = 'right'). The right-anchored logo sits
before the language / dark-mode / notifications / user
cluster so the widgets stay where admins expect them. */}
<div className="flex items-center gap-3">
{!logoInSidebar && logoPosition === 'right' && (
<div className="hidden md:flex mr-1 pr-2 border-r border-neutral-200 dark:border-neutral-700">
{renderBrandBlock()}
</div>
)}
{/* Language Selector — hidden on <sm where it's surfaced
via the user dropdown instead (#523 follow-up: phone
view header was too crowded with 4 widgets; language
is a set-once preference so it doesn't deserve permanent
header real estate on mobile per Rekoo-PS's feedback). */}
<div className="hidden sm:block">
<LanguageSelector />
</div>
{/* Dark Mode Toggle — hidden entirely when an admin has locked
the instance to a specific mode via Branding > Force color mode. */}
{!forcedMode && (
<button
onClick={toggleDarkMode}
className="p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
title={isDark ? t('admin.lightMode', 'Switch to light mode') : t('admin.darkMode', 'Switch to dark mode')}
>
{isDark ? <Sun className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
)}
{/* Notifications */}
<div className="relative" ref={notificationRef}>
<button
onClick={notificationsModal.toggle}
className="relative p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
>
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
)}
</button>
{/* Notifications dropdown */}
{notificationsModal.isOpen && (
<div className="absolute right-0 mt-2 w-96 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700">
<div className="px-4 py-3 border-b border-neutral-100 dark:border-neutral-700 flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{t('admin.notifications')}</h3>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={() => markAllAsReadMutation.mutate()}
className="text-xs text-accent hover:opacity-80 flex items-center gap-1"
title={t('admin.markAllRead')}
>
<CheckCircle className="w-3 h-3" />
{t('admin.markAllRead')}
</button>
)}
<button
onClick={() => clearAllMutation.mutate()}
className="text-xs text-neutral-600 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 flex items-center gap-1"
title={t('admin.clearAll')}
>
<Trash2 className="w-3 h-3" />
{t('admin.clearAll')}
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-neutral-500 dark:text-neutral-400">
{t('admin.noNotificationsMessage')}
</div>
) : (
notifications.map((notification) => {
const style = notificationsService.getNotificationStyle(notification.type);
return (
<div
key={notification.id}
className={`px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-700 cursor-pointer border-l-4 ${
notification.isRead ? 'border-transparent opacity-75' : 'border-accent-dark'
}`}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 ${style.color}`}>
<Bell className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-neutral-900 dark:text-neutral-100">
{notificationsService.formatNotificationMessage(notification)}
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{formatDistanceToNow(notification.createdAt, { addSuffix: true })}
</p>
</div>
</div>
</div>
);
})
)}
</div>
{notifications.length > 0 && (
<div className="px-4 py-2 border-t border-neutral-100 dark:border-neutral-700 text-center">
<button
onClick={notificationsModal.close}
className="text-sm text-accent hover:opacity-80"
>
{t('admin.close')}
</button>
</div>
)}
</div>
)}
</div>
{/* User menu */}
<div className="relative" ref={userMenuRef}>
<button
onClick={userMenuModal.toggle}
className="flex items-center gap-3 p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
>
<div className="text-right hidden sm:block">
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{user?.username}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{user?.email}</p>
</div>
<div className="w-8 h-8 bg-accent-dark rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-white" />
</div>
</button>
{/* User dropdown */}
{userMenuModal.isOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700 py-1">
<div className="px-4 py-2 border-b border-neutral-100 dark:border-neutral-700 sm:hidden">
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{user?.username}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{user?.email}</p>
</div>
{/* Language sub-section — phone-only (#523 follow-up).
Rekoo-PS asked for language to live inside the profile
menu since it's a set-once preference; on sm+ it
stays in the header cluster where it's been. Collapsible
so the menu isn't 8 rows taller by default. */}
<div className="sm:hidden border-b border-neutral-100 dark:border-neutral-700">
<button
onClick={userMenuLangSectionModal.toggle}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
aria-expanded={userMenuLangSectionModal.isOpen}
>
<Globe className="w-4 h-4" />
<span className="flex-1">{t('common.language', 'Language')}</span>
<currentLanguage.Flag className="w-4 h-4" />
<ChevronDown className={`w-4 h-4 transition-transform ${userMenuLangSectionModal.isOpen ? 'rotate-180' : ''}`} />
</button>
{userMenuLangSectionModal.isOpen && (
<div className="bg-neutral-50 dark:bg-neutral-900 py-1">
{SUPPORTED_LANGUAGES.map((language) => (
<button
key={language.code}
onClick={() => handleUserMenuLangSelect(language.code)}
className={`w-full pl-11 pr-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-neutral-100 dark:hover:bg-neutral-700 ${
language.code === i18n.language
? 'text-accent bg-accent-dark/15'
: 'text-neutral-700 dark:text-neutral-300'
}`}
>
<language.Flag className="w-4 h-4" />
<span>{language.name}</span>
</button>
))}
</div>
)}
</div>
<button
onClick={() => {
closeUserMenu();
navigate('/admin/settings');
}}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
>
<Settings className="w-4 h-4" />
{t('navigation.settings')}
</button>
<button
onClick={() => {
closeUserMenu();
passwordModal.open();
}}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
>
<Lock className="w-4 h-4" />
{t('admin.changePassword')}
</button>
<button
onClick={handleLogout}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
>
<LogOut className="w-4 h-4" />
{t('common.logout')}
</button>
</div>
)}
</div>
</div>
</div>
</div>
{/* Password Change Modal */}
<PasswordChangeModal
isOpen={passwordModal.isOpen}
onClose={passwordModal.close}
/>
</header>
);
};
@@ -0,0 +1,135 @@
import React, { useState } from 'react';
import { Outlet, Navigate } from 'react-router-dom';
import { useAdminAuth } from '../../contexts';
import { FeatureFlagsProvider } from '../../contexts/FeatureFlagsContext';
import { useSessionTimeout } from '../../hooks/useSessionTimeout';
import { AdminSidebar } from './AdminSidebar';
import { AdminHeader } from './AdminHeader';
import { MaintenanceBanner } from './MaintenanceBanner';
import { MigrationBanner } from './MigrationBanner';
import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal';
const SIDEBAR_COLLAPSED_KEY = 'admin-sidebar-collapsed';
export const AdminLayout: React.FC = () => {
const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsedState] = useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === '1';
});
const setSidebarCollapsed = (v: boolean) => {
setSidebarCollapsedState(v);
if (typeof window !== 'undefined') {
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, v ? '1' : '0');
}
};
// Handle session timeout
useSessionTimeout();
if (isLoading) {
return (
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-950 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-accent-dark border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-neutral-600">Loading...</p>
</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/admin/login" replace />;
}
// FeatureFlagsProvider wraps the entire admin chrome — sidebar reads
// flags to decide which surfaces to render, the Features tab reads/writes
// the same source. Mounted INSIDE the auth-required tree so the GET to
// /api/admin/feature-flags has a session cookie attached.
return (
<FeatureFlagsProvider>
<AdminLayoutInner
sidebarOpen={sidebarOpen}
setSidebarOpen={setSidebarOpen}
sidebarCollapsed={sidebarCollapsed}
setSidebarCollapsed={setSidebarCollapsed}
mustChangePassword={mustChangePassword}
/>
</FeatureFlagsProvider>
);
};
interface AdminLayoutInnerProps {
sidebarOpen: boolean;
setSidebarOpen: (v: boolean) => void;
sidebarCollapsed: boolean;
setSidebarCollapsed: (v: boolean) => void;
mustChangePassword: boolean;
}
const AdminLayoutInner: React.FC<AdminLayoutInnerProps> = ({ sidebarOpen, setSidebarOpen, sidebarCollapsed, setSidebarCollapsed, mustChangePassword }) => {
return (
<div className="h-screen bg-neutral-50 dark:bg-neutral-950 flex overflow-hidden">
{/* Mandatory Password Change Modal */}
{mustChangePassword && <MandatoryPasswordChangeModal />}
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar - disabled when password change required */}
<div className={mustChangePassword ? 'pointer-events-none opacity-50' : ''}>
<AdminSidebar
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
</div>
{/* Main content. `scrollbar-gutter: stable` on the column itself
(via the inline style) reserves the scrollbar gutter once at
the column level — so the header sits in the full column
width AND lines up with the sidebar's right edge, while
<main>'s scroll content honors the same gutter and never
shifts when content overflows. Without this, the header and
main each made their own decisions about the gutter, leaving
a visible ~15px notch on the right edge of the header's
border between the column's content area and the scrollbar. */}
<div
className="flex-1 flex flex-col min-w-0 h-screen overflow-y-auto"
style={{ scrollbarGutter: 'stable' }}
>
{/* Header - disabled when password change required */}
<div className={mustChangePassword ? 'pointer-events-none opacity-50' : ''}>
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
</div>
{/* Maintenance mode banner */}
<MaintenanceBanner />
{/* One-time migration banner — flip the constant in MigrationBanner.tsx
(or remove this mount) after operators have had time to update their
docker-compose.yml. See #669. */}
<MigrationBanner />
{/* Page content - disabled when password change required.
overflow moved up to the column so the scrollbar gutter is
reserved once at the column level (see above). main now
just contributes its content + padding. */}
<main id="main-content" className={`flex-1 px-4 sm:px-6 lg:px-8 py-8 ${mustChangePassword ? 'opacity-50 pointer-events-none' : ''}`}>
<Outlet />
</main>
</div>
</div>
);
};
AdminLayout.displayName = 'AdminLayout';
@@ -0,0 +1,712 @@
import React, { useState } from 'react';
import { Check, Download, Trash2, Eye, EyeOff, Heart, Package, MessageSquare, Star, Video, FolderOpen, Cog, AlertTriangle, RefreshCw, LayoutGrid, List } from 'lucide-react';
import { toast } from 'react-toastify';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service';
import { uploadsService } from '../../services/uploads.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { getPhotoViewMode, setPhotoViewMode, type PhotoViewMode } from '../../utils/photoViewPrefs';
import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { BulkCategoryModal } from './BulkCategoryModal';
interface CategoryOption {
id: number;
name: string;
}
interface AdminPhotoGridProps {
photos: AdminPhoto[];
eventId: number;
onPhotoClick: (photo: AdminPhoto, index: number) => void;
onPhotosDeleted: () => void;
onSelectionChange?: (selectedIds: number[]) => void;
categories?: CategoryOption[];
}
export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
photos,
eventId,
onPhotoClick,
onPhotosDeleted,
onSelectionChange,
categories = []
}) => {
const { t } = useTranslation();
const { format: formatDate } = useLocalizedDate();
const queryClient = useQueryClient();
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [deletingPhotos, setDeletingPhotos] = useState<Set<number>>(new Set());
const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false);
const [isUpdatingCategory, setIsUpdatingCategory] = useState(false);
// Layout toggle (Grid / List) persisted per admin via localStorage.
const [viewMode, setViewMode] = useState<PhotoViewMode>(() => getPhotoViewMode());
// Persist on user action only — writing in an effect would re-save the
// value on every mount (i.e. each time the Photos tab is opened), even
// when the user never touched the toggle.
const selectView = (mode: PhotoViewMode) => {
setViewMode(mode);
setPhotoViewMode(mode);
};
const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => {
if (e) {
e.stopPropagation();
}
// Auto-enable selection mode when selecting via checkbox
if (!isSelectionMode) {
setIsSelectionMode(true);
}
const newSelected = new Set(selectedPhotos);
if (newSelected.has(photoId)) {
newSelected.delete(photoId);
} else {
newSelected.add(photoId);
}
setSelectedPhotos(newSelected);
onSelectionChange?.(Array.from(newSelected));
};
const handleSelectAll = () => {
let newSelected: Set<number>;
if (selectedPhotos.size === photos.length) {
newSelected = new Set();
} else {
newSelected = new Set(photos.map(p => p.id));
}
setSelectedPhotos(newSelected);
onSelectionChange?.(Array.from(newSelected));
};
const handleDeleteSingle = async (photo: AdminPhoto, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm(`Are you sure you want to delete "${photo.filename}"?`)) {
return;
}
setDeletingPhotos(prev => new Set(prev).add(photo.id));
try {
await photosService.deletePhoto(eventId, photo.id);
toast.success('Photo deleted successfully');
onPhotosDeleted();
} catch {
toast.error('Failed to delete photo');
setDeletingPhotos(prev => {
const newSet = new Set(prev);
newSet.delete(photo.id);
return newSet;
});
}
};
const handleDeleteSelected = async () => {
if (selectedPhotos.size === 0) return;
const count = selectedPhotos.size;
if (!confirm(`Are you sure you want to delete ${count} photo${count > 1 ? 's' : ''}?`)) {
return;
}
setIsDeleting(true);
const selectedIds = Array.from(selectedPhotos);
setDeletingPhotos(new Set(selectedIds));
try {
await photosService.deletePhotos(eventId, selectedIds);
toast.success(`${count} photo${count > 1 ? 's' : ''} deleted successfully`);
setSelectedPhotos(new Set());
setIsSelectionMode(false);
onSelectionChange?.([]);
onPhotosDeleted();
} catch {
toast.error('Failed to delete photos');
setDeletingPhotos(new Set());
} finally {
setIsDeleting(false);
}
};
const handleDownload = async (photo: AdminPhoto, e: React.MouseEvent) => {
e.stopPropagation();
try {
await photosService.downloadPhoto(eventId, photo.id, photo.filename);
toast.success('Download started');
} catch {
toast.error('Failed to download photo');
}
};
const toggleSelectionMode = () => {
setIsSelectionMode(!isSelectionMode);
if (isSelectionMode) {
setSelectedPhotos(new Set());
onSelectionChange?.([]);
}
};
const handleMoveToCategory = async (categoryId: number | null) => {
if (selectedPhotos.size === 0) return;
setIsUpdatingCategory(true);
const selectedIds = Array.from(selectedPhotos);
try {
await photosService.updatePhotosCategory(eventId, selectedIds, categoryId);
const categoryName = categoryId
? categories.find(c => Number(c.id) === categoryId)?.name || t('photos.selectedCategory', 'selected category')
: t('photos.uncategorized', 'Uncategorized');
toast.success(
t('photos.movedToCategory', '{{count}} photos moved to {{category}}', {
count: selectedIds.length,
category: categoryName
})
);
setSelectedPhotos(new Set());
setIsSelectionMode(false);
onSelectionChange?.([]);
setIsCategoryModalOpen(false);
onPhotosDeleted(); // Refresh the photo list
} catch {
toast.error(t('photos.moveToCategoryFailed', 'Failed to move photos to category'));
} finally {
setIsUpdatingCategory(false);
}
};
return (
<div>
{/* Action Bar */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<Button
variant={isSelectionMode ? "primary" : "outline"}
size="sm"
onClick={toggleSelectionMode}
leftIcon={<Package className="w-4 h-4" />}
>
{isSelectionMode ? t('gallery.cancelSelection', 'Cancel Selection') : t('gallery.selectPhotos', 'Select Photos')}
</Button>
{(isSelectionMode || selectedPhotos.size > 0) && (
<>
<Button
variant="ghost"
size="sm"
onClick={handleSelectAll}
>
{selectedPhotos.size === photos.length ? t('gallery.deselectAll', 'Deselect All') : t('gallery.selectAll', 'Select All')}
</Button>
{selectedPhotos.size > 0 && (
<>
<span className="text-sm text-neutral-600 dark:text-neutral-400">
{t('gallery.photosSelected', { count: selectedPhotos.size })}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setIsCategoryModalOpen(true)}
leftIcon={<FolderOpen className="w-4 h-4" />}
>
{t('photos.moveToCategory', 'Move to Category')}
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await photosService.bulkUpdatePhotos(eventId, Array.from(selectedPhotos), { visibility: 'hidden' });
toast.success(t('admin.photos.hiddenSuccess', 'Photos hidden'));
onPhotosDeleted();
} catch { toast.error(t('common.error')); }
}}
leftIcon={<EyeOff className="w-4 h-4" />}
>
{t('admin.photos.hideSelected', 'Hide')}
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await photosService.bulkUpdatePhotos(eventId, Array.from(selectedPhotos), { visibility: 'visible' });
toast.success(t('admin.photos.visibleSuccess', 'Photos visible'));
onPhotosDeleted();
} catch { toast.error(t('common.error')); }
}}
leftIcon={<Eye className="w-4 h-4" />}
>
{t('admin.photos.showSelected', 'Show')}
</Button>
<button
onClick={handleDeleteSelected}
disabled={isDeleting}
className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
{t('gallery.deleteSelected', 'Delete Selected')}
</button>
</>
)}
</>
)}
</div>
<div className="flex items-center gap-3">
<div className="text-sm text-neutral-600 dark:text-neutral-400">
{t('gallery.photosCount', { count: photos.length })}
</div>
{/* Layout toggle: Grid / List — radiogroup so a screen reader
announces the two options as one mutually-exclusive set. */}
<div className="inline-flex rounded-lg border border-neutral-300 dark:border-neutral-600 overflow-hidden" role="radiogroup" aria-label={t('admin.photos.viewMode', 'View mode')}>
<button
type="button"
role="radio"
onClick={() => selectView('grid')}
aria-checked={viewMode === 'grid'}
title={t('admin.photos.gridView', 'Grid view')}
className={`p-1.5 transition-colors ${
viewMode === 'grid'
? 'bg-primary-500 text-white'
: 'bg-white dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700'
}`}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
type="button"
role="radio"
onClick={() => selectView('list')}
aria-checked={viewMode === 'list'}
title={t('admin.photos.listView', 'List view')}
className={`p-1.5 transition-colors border-l border-neutral-300 dark:border-neutral-600 ${
viewMode === 'list'
? 'bg-primary-500 text-white'
: 'bg-white dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700'
}`}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Photo Grid */}
{viewMode === 'grid' && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{photos.map((photo, index) => {
const isDeleting = deletingPhotos.has(photo.id);
const commentCount = photo.comment_count ?? 0;
const averageRating = photo.average_rating ?? 0;
const likeCount = photo.like_count ?? 0;
const isVideo = (photo.media_type === 'video') ||
(photo.mime_type && photo.mime_type.startsWith('video/')) ||
photo.type === 'video';
return (
<div
key={photo.id}
data-testid={`admin-photo-tile-${photo.id}`}
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 dark:bg-neutral-800 transition-opacity ${
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
} ${isDeleting ? 'opacity-50' : ''}`}
onClick={() => !isDeleting && onPhotoClick(photo, index)}
>
{/* Selection Checkbox (top-right) */}
<button
type="button"
aria-label={`Select ${photo.filename}`}
role="checkbox"
aria-checked={selectedPhotos.has(photo.id)}
data-testid={`admin-photo-checkbox-${photo.id}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${
selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => handlePhotoSelect(photo.id, e)}
>
<div className={`w-6 h-6 rounded border-2 flex items-center justify-center ${
selectedPhotos.has(photo.id)
? 'bg-accent-dark border-accent-dark'
: 'bg-white/90 border-white'
}`}>
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
</div>
</button>
{/* Visibility badge (#172) */}
{(photo as any).visibility === 'hidden' && (
<div className="absolute top-2 left-2 z-20">
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-500/90 text-white text-[10px] font-medium">
<EyeOff className="w-3 h-3" />
{t('admin.photos.hidden', 'Hidden')}
</span>
</div>
)}
{/* Thumbnail (or processing placeholder for in-flight photos) */}
<div className="aspect-square">
{(photo as any).processing_status === 'pending' ||
(photo as any).processing_status === 'processing' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 gap-1 px-2 text-center">
<Cog className="w-7 h-7 animate-spin" />
<p className="text-[10px] font-medium leading-tight">
{t('admin.photos.processingStatus', 'Processing…')}
</p>
</div>
) : (photo as any).processing_status === 'failed' ? (
<div className="w-full h-full flex flex-col items-center justify-center bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 gap-1 px-2 text-center">
<AlertTriangle className="w-7 h-7" />
<p className="text-[10px] font-medium leading-tight">
{t('admin.photos.processingFailed', 'Failed')}
</p>
<button
onClick={async (e) => {
e.stopPropagation();
try {
await uploadsService.retryPhoto(photo.id);
toast.success(t('admin.photos.retryQueued', 'Retry queued'));
// Refetch grid via React Query so the placeholder
// updates without a full reload.
queryClient.invalidateQueries({ queryKey: ['admin-event-photos'] });
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Retry failed');
}
}}
className="mt-1 px-2 py-0.5 rounded bg-red-200 dark:bg-red-800 text-[10px] inline-flex items-center gap-1"
>
<RefreshCw className="w-2.5 h-2.5" />
{t('upload.retryFailed', 'Retry')}
</button>
</div>
) : photo.thumbnail_url ? (
<AdminAuthenticatedImage
src={photo.thumbnail_url}
alt={photo.filename}
className="w-full h-full object-cover"
loading="lazy"
fallback={
<div className="w-full h-full flex items-center justify-center text-neutral-400">
<Eye className="w-8 h-8" />
</div>
}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-neutral-400">
<Eye className="w-8 h-8" />
</div>
)}
</div>
{/* Overlay with actions */}
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
<div className="absolute bottom-0 left-0 right-0 p-3">
<p className="text-white text-xs font-medium truncate mb-1">
{photo.filename}
</p>
{photo.original_filename && photo.original_filename !== photo.filename && (
<p className="text-white/60 text-[10px] truncate mb-1">
Original: {photo.original_filename}
</p>
)}
<p className="text-white/80 text-xs mb-2">
{photosService.formatBytes(photo.size)}
</p>
{!isSelectionMode && (
<div className="flex gap-1">
<button
onClick={(e) => handleDownload(photo, e)}
className="p-1 text-white hover:bg-white/20 rounded"
>
<Download className="w-3 h-3" />
</button>
<button
onClick={(e) => handleDeleteSingle(photo, e)}
className="p-1 text-white hover:bg-white/20 rounded disabled:opacity-50"
disabled={isDeleting}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
)}
</div>
</div>
{/* Category Badge - move to top-left and prevent overlap with select checkbox */}
{photo.category_name && (
<div className="absolute left-2 top-2 pointer-events-none">
<span className="px-2 py-1 text-xs font-medium bg-white/90 text-neutral-700 rounded max-w-[70%] whitespace-nowrap overflow-hidden text-ellipsis">
{photo.category_name}
</span>
</div>
)}
{isVideo && (
<div className="absolute bottom-2 left-2 pointer-events-none">
<span className="px-2 py-1 text-[11px] font-semibold bg-black/70 text-white rounded flex items-center gap-1">
<Video className="w-3 h-3" />
{t('common.video', 'Video')}
</span>
</div>
)}
{/* Feedback Indicators (moved to bottom-right to avoid covering category) */}
{(commentCount > 0 || averageRating > 0 || likeCount > 0) && (
<div className="absolute bottom-2 right-2 flex items-center gap-1 z-10">
{averageRating > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(averageRating).toFixed(1)}`}>
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{Number(averageRating).toFixed(1)}</span>
</div>
)}
{commentCount > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${commentCount} comments`}>
<MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{commentCount}</span>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
{/* Photo List */}
{viewMode === 'list' && (
<div className="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-700">
<table className="w-full">
<thead className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<tr>
<th className="w-8 px-3 py-2" />
<th className="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('admin.photos.columns.photo', 'Photo')}
</th>
<th className="hidden lg:table-cell px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('admin.photos.columns.category', 'Category')}
</th>
<th className="hidden md:table-cell px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('admin.photos.columns.uploaded', 'Uploaded')}
</th>
<th className="hidden xl:table-cell px-3 py-2 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('admin.photos.columns.engagement', 'Engagement')}
</th>
<th className="hidden sm:table-cell px-3 py-2 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('admin.photos.columns.feedback', 'Feedback')}
</th>
<th className="px-3 py-2 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('admin.photos.columns.size', 'Size')}
</th>
<th className="w-px px-3 py-2 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('admin.photos.columns.actions', 'Actions')}
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
{photos.map((photo, index) => {
const isRowDeleting = deletingPhotos.has(photo.id);
const commentCount = photo.comment_count ?? 0;
const averageRating = photo.average_rating ?? 0;
const viewCount = photo.view_count ?? 0;
const downloadCount = photo.download_count ?? 0;
const likeCount = photo.like_count ?? 0;
const isSelected = selectedPhotos.has(photo.id);
const isVideo = (photo.media_type === 'video') ||
(photo.mime_type && photo.mime_type.startsWith('video/')) ||
photo.type === 'video';
const isHidden = (photo as any).visibility === 'hidden';
const status = (photo as any).processing_status;
return (
<tr
key={photo.id}
data-testid={`admin-photo-row-${photo.id}`}
className={`group cursor-pointer transition-colors ${
isSelected ? 'bg-primary-50 dark:bg-primary-900/20' : 'hover:bg-neutral-50 dark:hover:bg-neutral-700/50'
} ${isRowDeleting ? 'opacity-50' : ''}`}
onClick={() => !isRowDeleting && onPhotoClick(photo, index)}
>
{/* Selection checkbox */}
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
<button
type="button"
aria-label={`Select ${photo.filename}`}
role="checkbox"
aria-checked={isSelected}
data-testid={`admin-photo-row-checkbox-${photo.id}`}
onClick={(e) => handlePhotoSelect(photo.id, e)}
>
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
isSelected
? 'bg-accent-dark border-accent-dark'
: 'border-neutral-300 dark:border-neutral-500 group-hover:border-neutral-400'
}`}>
{isSelected && <Check className="w-3.5 h-3.5 text-white" />}
</div>
</button>
</td>
{/* Thumbnail + filename + badges */}
<td className="px-3 py-2">
<div className="flex items-center gap-3 min-w-0">
<div className="flex-shrink-0 w-10 h-10 rounded overflow-hidden bg-neutral-100 dark:bg-neutral-700">
{status === 'pending' || status === 'processing' ? (
<div className="w-full h-full flex items-center justify-center text-amber-600 dark:text-amber-300">
<Cog className="w-4 h-4 animate-spin" />
</div>
) : status === 'failed' ? (
<div className="w-full h-full flex items-center justify-center text-red-600 dark:text-red-300">
<AlertTriangle className="w-4 h-4" />
</div>
) : photo.thumbnail_url ? (
<AdminAuthenticatedImage
src={photo.thumbnail_url}
alt={photo.filename}
className="w-full h-full object-cover"
loading="lazy"
fallback={
<div className="w-full h-full flex items-center justify-center text-neutral-400">
<Eye className="w-4 h-4" />
</div>
}
/>
) : (
<div className="w-full h-full flex items-center justify-center text-neutral-400">
<Eye className="w-4 h-4" />
</div>
)}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate">
{photo.filename}
</p>
{isVideo && (
<span className="flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-600 text-neutral-700 dark:text-neutral-200 text-[10px] font-medium">
<Video className="w-3 h-3" />
{t('common.video', 'Video')}
</span>
)}
{isHidden && (
<span className="flex-shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 text-[10px] font-medium">
<EyeOff className="w-3 h-3" />
{t('admin.photos.hidden', 'Hidden')}
</span>
)}
</div>
{photo.original_filename && photo.original_filename !== photo.filename && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 truncate">
{photo.original_filename}
</p>
)}
</div>
</div>
</td>
{/* Category */}
<td className="hidden lg:table-cell px-3 py-2 max-w-[12rem] truncate text-sm text-neutral-600 dark:text-neutral-400">
{photo.category_name || '—'}
</td>
{/* Uploaded date */}
<td className="hidden md:table-cell px-3 py-2 whitespace-nowrap text-sm text-neutral-600 dark:text-neutral-400">
{photo.uploaded_at ? formatDate(photo.uploaded_at) : '—'}
</td>
{/* Engagement: views / downloads / likes */}
<td className="hidden xl:table-cell px-3 py-2 text-right text-xs text-neutral-500 dark:text-neutral-400 tabular-nums">
<div className="flex items-center justify-end gap-3">
<span className="inline-flex items-center gap-1" title={t('admin.photos.columns.views', 'Views')}>
<Eye className="w-3.5 h-3.5" />
{viewCount}
</span>
<span className="inline-flex items-center gap-1" title={t('admin.photos.columns.downloads', 'Downloads')}>
<Download className="w-3.5 h-3.5" />
{downloadCount}
</span>
<span className="inline-flex items-center gap-1" title={t('admin.photos.columns.likes', 'Likes')}>
<Heart className="w-3.5 h-3.5" />
{likeCount}
</span>
</div>
</td>
{/* Feedback: rating + comments */}
<td className="hidden sm:table-cell px-3 py-2 text-right text-xs text-neutral-600 dark:text-neutral-400">
{averageRating > 0 || commentCount > 0 ? (
<div className="flex items-center justify-end gap-2">
{averageRating > 0 && (
<span className="inline-flex items-center gap-0.5" title={`Rating: ${Number(averageRating).toFixed(1)}`}>
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
{Number(averageRating).toFixed(1)}
</span>
)}
{commentCount > 0 && (
<span className="inline-flex items-center gap-0.5" title={`${commentCount} comments`}>
<MessageSquare className="w-3.5 h-3.5 text-accent" fill="currentColor" />
{commentCount}
</span>
)}
</div>
) : '—'}
</td>
{/* Size */}
<td className="px-3 py-2 text-right text-sm text-neutral-500 dark:text-neutral-400 whitespace-nowrap tabular-nums">
{photosService.formatBytes(photo.size)}
</td>
{/* Actions */}
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
{!isSelectionMode && (
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
<button
onClick={(e) => handleDownload(photo, e)}
className="p-1.5 text-neutral-500 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-600 rounded"
title={t('common.download', 'Download')}
>
<Download className="w-4 h-4" />
</button>
<button
onClick={(e) => handleDeleteSingle(photo, e)}
className="p-1.5 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/30 rounded disabled:opacity-50"
disabled={isRowDeleting}
title={t('common.delete', 'Delete')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{photos.length === 0 && (
<div className="text-center py-12">
<p className="text-neutral-500 dark:text-neutral-400">{t('gallery.noMedia', 'No media uploaded yet')}</p>
</div>
)}
{/* Bulk Category Modal */}
<BulkCategoryModal
isOpen={isCategoryModalOpen}
onClose={() => setIsCategoryModalOpen(false)}
onConfirm={handleMoveToCategory}
photoCount={selectedPhotos.size}
categories={categories}
isLoading={isUpdatingCategory}
/>
</div>
);
};
@@ -0,0 +1,513 @@
import React, { useState } from 'react';
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service';
import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../../services/feedback.service';
import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast, useModal } from '../../hooks';
type AdminFeedbackResponse = {
feedback: PhotoFeedback[];
summary?: FeedbackSummary;
};
interface AdminPhotoViewerProps {
photos: AdminPhoto[];
initialIndex: number;
eventId: number;
onClose: () => void;
onPhotoDeleted: () => void;
categories: Array<{ id: number; name: string; slug: string }>;
}
export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
photos,
initialIndex,
eventId,
onClose,
onPhotoDeleted,
categories
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [isDeleting, setIsDeleting] = useState(false);
const categoryMenuModal = useModal();
const commentsModal = useModal();
const queryClient = useQueryClient();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const currentPhoto = photos[currentIndex];
const isVideo = currentPhoto
? (currentPhoto.media_type === 'video' ||
(currentPhoto.mime_type && String(currentPhoto.mime_type).startsWith('video/')) ||
currentPhoto.type === 'video')
: false;
const averageRating = currentPhoto?.average_rating ?? 0;
const likeCount = currentPhoto?.like_count ?? 0;
const favoriteCount = currentPhoto?.favorite_count ?? 0;
if (!currentPhoto) {
return null;
}
// Fetch feedback for current photo
const { data: feedbackData } = useQuery<AdminFeedbackResponse>({
queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id],
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
photoId: currentPhoto?.id.toString(),
status: 'all' // Get all comments including unapproved
}),
enabled: !!currentPhoto
});
const comments = (feedbackData?.feedback ?? []).filter((item): item is PhotoFeedback => item.feedback_type === 'comment');
const goToPrevious = () => {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
};
const goToNext = () => {
setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0));
};
const handleDelete = async () => {
if (!confirm(`Are you sure you want to delete "${currentPhoto.filename}"?`)) {
return;
}
setIsDeleting(true);
try {
await photosService.deletePhoto(eventId, currentPhoto.id);
toast.success('Photo deleted successfully');
// Close viewer if this was the last photo
if (photos.length === 1) {
onClose();
} else {
// Move to next photo if available, otherwise previous
if (currentIndex === photos.length - 1) {
setCurrentIndex(currentIndex - 1);
}
}
onPhotoDeleted();
} catch (error) {
toast.error('Failed to delete photo');
} finally {
setIsDeleting(false);
}
};
const handleDownload = async () => {
try {
await photosService.downloadPhoto(eventId, currentPhoto.id, currentPhoto.filename);
toast.success('Download started');
} catch (error) {
toast.error('Failed to download photo');
}
};
const handleCategoryChange = async (categoryId: number | null) => {
try {
await photosService.updatePhotoCategory(eventId, currentPhoto.id, categoryId);
toast.success('Category updated');
categoryMenuModal.close();
// Invalidate photos query to refresh data
await queryClient.invalidateQueries({ queryKey: ['admin-event-photos', eventId.toString()] });
await queryClient.invalidateQueries({ queryKey: ['admin-event-photos', eventId] });
// Also trigger the parent's refresh callback
onPhotoDeleted();
} catch (error) {
toast.error('Failed to update category');
}
};
// Mutations for feedback moderation
const moderateFeedbackMutation = useMutationWithToast({
mutationFn: ({ feedbackId, action }: { feedbackId: string; action: 'approve' | 'hide' | 'reject' }) =>
feedbackService.moderateFeedback(feedbackId, action),
invalidateKeys: [['admin-photo-feedback', eventId, currentPhoto?.id]],
successMessage: 'Feedback moderated successfully',
errorMessage: () => 'Failed to moderate feedback'
});
const deleteFeedbackMutation = useMutationWithToast({
mutationFn: (feedbackId: string) => feedbackService.deleteFeedback(feedbackId),
invalidateKeys: [['admin-photo-feedback', eventId, currentPhoto?.id]],
successMessage: 'Feedback deleted successfully',
errorMessage: () => 'Failed to delete feedback'
});
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
onClose();
break;
case 'ArrowLeft':
goToPrevious();
break;
case 'ArrowRight':
goToNext();
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [currentIndex]);
return (
<div className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center">
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<X className="w-6 h-6" />
</button>
{/* Navigation */}
<button
onClick={goToPrevious}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<ChevronLeft className="w-8 h-8" />
</button>
<button
onClick={goToNext}
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white p-2 rounded-lg hover:bg-white/10 transition-colors"
>
<ChevronRight className="w-8 h-8" />
</button>
{/* Main content */}
<div className="flex flex-col lg:flex-row gap-6 max-w-7xl mx-auto p-4 w-full h-full">
{/* Image */}
<div className="flex-1 flex items-center justify-center min-h-0">
{isVideo ? (
<AdminAuthenticatedVideo
src={currentPhoto.url}
className="max-w-full max-h-full bg-black"
poster={currentPhoto.thumbnail_url || undefined}
fallback={
<div className="flex items-center justify-center text-neutral-400">
<div className="text-center">
<Eye className="w-12 h-12 mx-auto mb-2" />
<p className="text-sm">Failed to load media</p>
</div>
</div>
}
/>
) : (
<AdminAuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
className="max-w-full max-h-full object-contain"
fallback={
<div className="flex items-center justify-center text-neutral-400">
<div className="text-center">
<Eye className="w-12 h-12 mx-auto mb-2" />
<p className="text-sm">Failed to load image</p>
</div>
</div>
}
/>
)}
</div>
{/* Sidebar */}
<div className="lg:w-80 bg-neutral-900 rounded-lg p-6 overflow-y-auto">
<h3 className="text-white font-medium text-lg">{currentPhoto.filename}</h3>
{currentPhoto.original_filename && currentPhoto.original_filename !== currentPhoto.filename && (
<p className="text-neutral-400 text-sm">Original: {currentPhoto.original_filename}</p>
)}
<div className="mb-4" />
{/* Actions */}
<div className="flex gap-2 mb-6">
<Button
variant="primary"
size="sm"
onClick={handleDownload}
leftIcon={<Download className="w-4 h-4" />}
className="flex-1"
>
Download
</Button>
<button
onClick={handleDelete}
disabled={isDeleting}
className="flex-1 px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center justify-center gap-2"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
{/* Category */}
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-neutral-400 text-sm flex items-center gap-1">
<Tag className="w-4 h-4" />
Category
</span>
<button
onClick={categoryMenuModal.toggle}
className="text-xs text-accent hover:text-accent-dark"
>
Change
</button>
</div>
<p className="text-white">
{currentPhoto.category_name || 'Uncategorized'}
</p>
{categoryMenuModal.isOpen && (
<div className="mt-2 bg-neutral-800 rounded-lg p-2">
<button
onClick={() => handleCategoryChange(null)}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-neutral-700 rounded"
>
Uncategorized
</button>
{categories.map(cat => (
<button
key={cat.id}
onClick={() => handleCategoryChange(cat.id)}
className="w-full text-left px-3 py-2 text-sm text-white hover:bg-neutral-700 rounded"
>
{cat.name}
</button>
))}
</div>
)}
</div>
{/* Metadata */}
<div className="space-y-4 text-sm">
<div>
<span className="text-neutral-400 flex items-center gap-1 mb-1">
<HardDrive className="w-4 h-4" />
File Size
</span>
<p className="text-white">{photosService.formatBytes(currentPhoto.size)}</p>
</div>
<div>
<span className="text-neutral-400 flex items-center gap-1 mb-1">
<Calendar className="w-4 h-4" />
Uploaded
</span>
<p className="text-white">
{fmtDateTime(currentPhoto.uploaded_at)}
</p>
</div>
{currentPhoto.view_count !== undefined && (
<div>
<span className="text-neutral-400 flex items-center gap-1 mb-1">
<Eye className="w-4 h-4" />
Views
</span>
<p className="text-white">{currentPhoto.view_count}</p>
</div>
)}
{currentPhoto.download_count !== undefined && (
<div>
<span className="text-neutral-400 flex items-center gap-1 mb-1">
<MousePointer className="w-4 h-4" />
Downloads
</span>
<p className="text-white">{currentPhoto.download_count}</p>
</div>
)}
</div>
{/* Feedback Section */}
{feedbackData && (
<div className="mt-6 pt-6 border-t border-neutral-700">
<h4 className="text-white font-medium mb-4 flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Feedback & Comments
</h4>
{/* Feedback Stats */}
<div className="grid grid-cols-2 gap-3 mb-4">
{averageRating > 0 && (
<div className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-center gap-1 text-yellow-400 mb-1">
<Star className="w-4 h-4" fill="currentColor" />
<span className="text-white font-medium">{Number(averageRating).toFixed(1)}</span>
</div>
<p className="text-xs text-neutral-400">Avg Rating</p>
</div>
)}
{likeCount > 0 && (
<div className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-center gap-1 text-red-400 mb-1">
<Heart className="w-4 h-4" fill="currentColor" />
<span className="text-white font-medium">{likeCount}</span>
</div>
<p className="text-xs text-neutral-400">Likes</p>
</div>
)}
{favoriteCount > 0 && (
<div className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-center gap-1 text-blue-400 mb-1">
<Star className="w-4 h-4" />
<span className="text-white font-medium">{favoriteCount}</span>
</div>
<p className="text-xs text-neutral-400">Favorites</p>
</div>
)}
{comments.length > 0 && (
<div className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-center gap-1 text-green-400 mb-1">
<MessageSquare className="w-4 h-4" />
<span className="text-white font-medium">{comments.length}</span>
</div>
<p className="text-xs text-neutral-400">Comments</p>
</div>
)}
</div>
{/* Comments List */}
{comments.length > 0 && (
<div className="space-y-2">
<button
onClick={commentsModal.toggle}
className="text-xs text-accent hover:text-accent-dark mb-2"
>
{commentsModal.isOpen ? 'Hide' : 'Show'} Comments ({comments.length})
</button>
{commentsModal.isOpen && (
<div className="space-y-3 max-h-64 overflow-y-auto">
{comments.map((comment) => (
<div key={comment.id} className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<p className="text-sm font-medium text-white">
{comment.guest_name || 'Anonymous'}
</p>
<p className="text-xs text-neutral-400">
{fmtDateTime(comment.created_at)}
</p>
</div>
{/* Comment Status Badge */}
<div className="flex items-center gap-1">
{!comment.is_approved && !comment.is_hidden && (
<span className="text-xs bg-yellow-500/20 text-yellow-400 px-2 py-1 rounded flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Pending
</span>
)}
{comment.is_approved && !comment.is_hidden && (
<span className="text-xs bg-green-500/20 text-green-400 px-2 py-1 rounded flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Approved
</span>
)}
{comment.is_hidden && (
<span className="text-xs bg-red-500/20 text-red-400 px-2 py-1 rounded flex items-center gap-1">
<XCircle className="w-3 h-3" />
Hidden
</span>
)}
</div>
</div>
<p className="text-sm text-neutral-300 mb-3">
{comment.comment_text}
</p>
{/* Moderation Actions */}
<div className="flex gap-2">
{!comment.is_approved && (
<button
onClick={() => moderateFeedbackMutation.mutate({
feedbackId: comment.id.toString(),
action: 'approve'
})}
disabled={moderateFeedbackMutation.isPending}
className="text-xs px-2 py-1 bg-green-600 hover:bg-green-700 text-white rounded"
>
Approve
</button>
)}
{!comment.is_hidden && (
<button
onClick={() => moderateFeedbackMutation.mutate({
feedbackId: comment.id.toString(),
action: 'hide'
})}
disabled={moderateFeedbackMutation.isPending}
className="text-xs px-2 py-1 bg-yellow-600 hover:bg-yellow-700 text-white rounded"
>
Hide
</button>
)}
{comment.is_hidden && (
<button
onClick={() => moderateFeedbackMutation.mutate({
feedbackId: comment.id.toString(),
action: 'approve'
})}
disabled={moderateFeedbackMutation.isPending}
className="text-xs px-2 py-1 bg-green-600 hover:bg-green-700 text-white rounded"
>
Unhide
</button>
)}
<button
onClick={() => {
if (confirm('Are you sure you want to delete this comment?')) {
deleteFeedbackMutation.mutate(comment.id.toString());
}
}}
disabled={deleteFeedbackMutation.isPending}
className="text-xs px-2 py-1 bg-red-600 hover:bg-red-700 text-white rounded"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* No feedback message */}
{comments.length === 0 && (
<p className="text-neutral-400 text-sm">No feedback for this photo yet.</p>
)}
</div>
)}
{/* Navigation info */}
<div className="mt-6 pt-6 border-t border-neutral-700">
<p className="text-neutral-400 text-sm text-center">
{currentIndex + 1} of {photos.length}
</p>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,377 @@
import React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
Calendar,
Archive,
BarChart3,
Settings,
Activity,
X,
Users,
Briefcase,
Landmark,
Workflow,
PanelLeftClose,
PanelLeftOpen,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { settingsService } from '../../services/settings.service';
import { VersionInfo } from './VersionInfo';
import { usePermissions } from '../../contexts/PermissionsContext';
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { buildResourceUrl } from '../../utils/url';
interface AdminSidebarProps {
isOpen: boolean;
onClose: () => void;
/** Desktop-only: collapse to icon-rail when true. Persisted by parent. */
collapsed?: boolean;
/** Desktop-only: toggle for the collapse button rendered in the title bar. */
onToggleCollapse?: () => void;
}
interface NavItem {
nameKey: string;
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).
//
// Removed (now live as Settings tabs, with redirects from the old
// top-level paths so bookmarks keep working):
// /admin/email, /admin/branding, /admin/event-types, /admin/backup,
// /admin/cms.
//
// Feature-gated (only render when the corresponding feature flag is on):
// Analytics → flags.analytics
// Users → flags.userManagement
const navigation: NavItem[] = [
{ nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard, permission: false },
{ nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' },
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' },
{ 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.systemHealth', href: '/admin/system-health', icon: Activity, permission: 'settings.view' },
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view', featureFlag: 'userManagement' },
// 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. Mirror the same
// set used to derive the parent `clients` flag in
// FeatureFlagsContext (see clientsDependsOn) so the two checks
// can't disagree: any sub-feature on lights up the entry, all off
// hides it. Future siblings (e.g. `messaging`) get appended here
// AND in the context derivation.
// taxReport intentionally excluded — Tax moved to the Accounting section
// and is not a Clients sub-nav item, so it must not reveal Clients (would
// open an empty ClientsLayout). Mirrors the context's `clients` derivation.
featureFlagsAny: [
'customerPortal', 'crmDevelopment', 'quotes', 'bills',
'hoursLogging', 'contracts', 'calendar', 'projects',
],
},
// Accounting section (migration 122) — inbound supplier invoices,
// expenses + re-bill, and the tax report (which relocates here from
// the CRM sub-nav when `accounting` is on). Gated by the `accounting`
// master flag; the sub-pages inside AccountingLayout are each
// independently feature-gated.
{
nameKey: 'navigation.accounting', href: '/admin/accounting', icon: Landmark,
permission: 'accounting.view',
featureFlag: 'accounting',
},
// Workflows (automation engine) — top-level, gated by the `workflows` flag.
{
nameKey: 'navigation.workflows', href: '/admin/workflows', icon: Workflow,
permission: 'workflows.view',
featureFlag: 'workflows',
},
];
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => {
const location = useLocation();
const { t } = useTranslation();
const { hasPermission, isLoading: permissionsLoading } = usePermissions();
const { flags } = useFeatureFlags();
// Branding lookup for the "logo_position = sidepanel" mode — when
// chosen, the logo replaces the "PicPeak Admin" text in the brand
// row, and the favicon takes over in the collapsed icon rail.
const { data: publicSettings } = usePublicSettings();
const { isDark } = useAdminDarkMode();
const logoInSidebar = publicSettings?.branding_logo_position === 'sidepanel';
// Theme-aware logo with symmetric fallback (one logo serves both modes).
const lightLogo = publicSettings?.branding_logo_url?.trim();
const darkLogo = publicSettings?.branding_logo_url_dark?.trim();
const rawLogoUrl = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
const rawFaviconUrl = publicSettings?.branding_favicon_url?.trim();
const resolvedLogoUrl = rawLogoUrl
? (rawLogoUrl.startsWith('http') ? rawLogoUrl : buildResourceUrl(rawLogoUrl))
: null;
const resolvedFaviconUrl = rawFaviconUrl
? (rawFaviconUrl.startsWith('http') ? rawFaviconUrl : buildResourceUrl(rawFaviconUrl))
: null;
// In collapsed rail, prefer the favicon (it's already a square,
// tight crop). Fall back to the logo when no favicon is set, then
// to nothing — better an empty rail than a stretched logo.
const sidebarBrandImageUrl = collapsed
? (resolvedFaviconUrl || resolvedLogoUrl)
: (resolvedLogoUrl || resolvedFaviconUrl);
const showLogoBrand = logoInSidebar && !!sidebarBrandImageUrl;
const brandAlt = publicSettings?.branding_company_name?.trim() || t('admin.title');
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;
});
// Desktop width: full nav (w-64) vs icon rail (w-16). Mobile is always
// w-64 since the collapse affordance only applies on lg+ viewports.
const widthClasses = collapsed ? 'w-64 lg:w-16' : 'w-64';
const showLabels = !collapsed;
return (
<div
// Right edge drawn via box-shadow rather than `border-r` so the
// brand row's `border-b` can extend to the sidebar's full width
// and meet the header's `border-b` cleanly at the L-junction. A
// 1px border-r would shrink the brand row's content by 1px and
// leave a visible step in the horizontal divider where the
// sidebar meets the main column. Shadow uses the same neutral
// border colors so it looks identical to the previous border.
className={`fixed inset-y-0 left-0 z-50 ${widthClasses} bg-white dark:bg-neutral-900 shadow-[1px_0_0_0_theme(colors.neutral.200)] dark:shadow-[1px_0_0_0_theme(colors.neutral.700)] transform transition-all duration-200 ease-in-out lg:relative lg:translate-x-0 lg:h-screen ${
isOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
<div className="flex flex-col h-screen lg:h-full">
{/* Brand row: title on the left, mobile close (X) on the
right. The desktop collapse toggle used to live here but
was moved down next to the version / storage widgets so
it sits in admins' muscle-memory zone for chrome controls.
When collapsed on desktop the title hides and the row
becomes an empty spacer (no rail-width fight). */}
<div className={`flex items-center h-16 border-b border-neutral-200 dark:border-neutral-700 flex-shrink-0 ${
collapsed ? 'lg:justify-center lg:px-2 px-6 justify-between' : 'justify-between px-6'
}`}>
<div className="flex items-center gap-2 min-w-0">
{showLogoBrand ? (
<>
{/* Logo brand variant — fed by Branding > Logo
Position = "Sidebar". On the collapsed rail, only
the favicon (or logo as fallback) is shown — sized
to fit the 64px-wide rail. Expanded shows the full
logo at the same h-8 the admin header uses for
visual continuity. */}
<img
src={sidebarBrandImageUrl!}
alt={brandAlt}
className={collapsed ? 'h-8 w-8 object-contain lg:h-9 lg:w-9' : 'h-8 w-auto object-contain max-w-full'}
/>
{/* On mobile the rail-narrow style only applies at
lg+, so when collapsed=true the mobile view still
has the regular w-64 width — show the company name
next to the logo so the brand row doesn't feel
empty there. */}
{collapsed && (
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100 lg:hidden truncate">
{brandAlt}
</span>
)}
</>
) : (
<>
{showLabels && (
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('admin.title')}</span>
)}
{/* When collapsed on desktop the title is hidden; on mobile we
always show it because the rail-narrow style only applies at lg+ */}
{collapsed && (
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100 lg:hidden">{t('admin.title')}</span>
)}
</>
)}
</div>
<button
onClick={onClose}
className="lg:hidden text-neutral-400 hover:text-neutral-600"
aria-label="Close sidebar"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Navigation */}
<nav className={`flex-1 py-4 space-y-1 overflow-y-auto overflow-x-hidden min-h-0 ${
collapsed ? 'px-4 lg:px-2' : 'px-4'
}`}>
{filteredNavigation.map((item) => {
const isActive = location.pathname === item.href ||
(item.href !== '/admin/dashboard' && location.pathname.startsWith(item.href));
const label = t(item.nameKey);
return (
<NavLink
key={item.nameKey}
to={item.href}
onClick={() => onClose()}
title={collapsed ? label : undefined}
className={`flex items-center py-2 text-sm font-medium rounded-lg transition-colors ${
collapsed ? 'px-3 lg:px-0 lg:justify-center' : 'px-3'
} ${
isActive
? 'bg-accent-dark text-white'
: 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-neutral-100'
}`}
>
{/* Selected item: solid accent-dark fill with white text/icon
for unambiguous high-contrast selection — matches the
.tile-selected pattern used in the customizer. The accent
-dark token defaults to the legacy primary green so users
who haven't set CI colours yet see no migration regression. */}
<item.icon className={`w-5 h-5 flex-shrink-0 ${
collapsed ? 'mr-3 lg:mr-0' : 'mr-3'
} ${
isActive ? 'text-white' : 'text-neutral-400'
}`} />
<span className={collapsed ? 'lg:hidden' : ''}>{label}</span>
</NavLink>
);
})}
</nav>
{/* Desktop collapse / expand toggle.
Lives directly above the version + storage widgets — sits
in admins' muscle-memory zone for chrome controls and
stays visible even when the sidebar is collapsed so the
rail can always be re-expanded. Hidden on mobile (the X
in the brand row already closes the sheet there). */}
{onToggleCollapse && (
<div className={`hidden lg:flex flex-shrink-0 border-t border-neutral-200 dark:border-neutral-700 py-2 ${
collapsed ? 'justify-center px-2' : 'justify-end px-4'
}`}>
<button
type="button"
onClick={onToggleCollapse}
className="inline-flex items-center justify-center w-9 h-9 rounded-md text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
aria-label={collapsed ? t('admin.expandSidebar', 'Expand sidebar') : t('admin.collapseSidebar', 'Collapse sidebar')}
title={collapsed ? t('admin.expandSidebar', 'Expand sidebar') : t('admin.collapseSidebar', 'Collapse sidebar')}
>
{collapsed
? <PanelLeftOpen className="w-5 h-5" />
: <PanelLeftClose className="w-5 h-5" />}
</button>
</div>
)}
{/* Bottom section - sticky to bottom (only for users with settings.view permission).
Hidden on desktop when collapsed since these widgets don't fit in the icon rail;
mobile keeps them visible because mobile width is always w-64.
#523 follow-up 2: render OPTIMISTICALLY while permissions are
still hydrating from the auth context (Rekoo-PS's 3.60.3-beta.0
screenshot showed the whole bottom block missing on first paint
right after a deploy — `hasPermission` returns false during the
~hundreds-of-ms hydration window, the widgets vanish entirely,
then re-appear). Only HIDE the block when we definitively know
the user lacks the permission. VersionInfo + StorageInfo each
have their own loading states so admins see "—" / a spinner
instead of nothing during the actual data fetch. */}
{(permissionsLoading || hasPermission('settings.view')) && (
<div className={`flex-shrink-0 ${collapsed ? 'lg:hidden' : ''}`}>
{/* Version Info */}
<VersionInfo />
{/* Storage Info */}
<StorageInfo />
</div>
)}
</div>
</div>
);
};
const StorageInfo: React.FC = () => {
const { t } = useTranslation();
const { data: storageInfo } = useQuery({
queryKey: ['storage-info'],
queryFn: () => settingsService.getStorageInfo(),
refetchInterval: 60000 // Refresh every minute
});
// Don't render anything while loading or if data failed to load
if (!storageInfo) {
return null;
}
const limitInUse = storageInfo.storage_soft_limit || storageInfo.storage_limit || 1;
const usagePercent = limitInUse
? Math.round((storageInfo.total_used / limitInUse) * 100)
: 0;
const isOverSoftLimit = limitInUse && storageInfo.total_used >= limitInUse;
const progressBarClass = isOverSoftLimit ? 'bg-red-600' : 'bg-accent-dark';
const containerClass = isOverSoftLimit
? 'bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800'
: 'bg-neutral-100 dark:bg-neutral-800';
const softLimitDisplay = settingsService.formatBytes(limitInUse);
return (
<div className="p-4 border-t border-neutral-200 dark:border-neutral-700">
<div className={`${containerClass} rounded-lg p-3 transition-colors duration-300`}>
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-700 dark:text-neutral-300">{t('admin.storageUsed')}</span>
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{settingsService.formatBytes(storageInfo.total_used)}
</span>
</div>
<div className="mt-2 w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
<div
className={`${progressBarClass} h-2 rounded-full transition-all duration-300`}
style={{ width: `${Math.min(usagePercent, 100)}%` }}
/>
</div>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
{t('admin.storagePercent', { percent: usagePercent, limit: softLimitDisplay })}
</p>
</div>
</div>
);
};
@@ -0,0 +1,334 @@
/**
* AssignedEventsDialog (#354 follow-up).
*
* Modal dialog that lets an admin replace the full set of events a
* single customer is assigned to. Mounted from the "Assigned events"
* card on CustomerDetailPage via the "Manage galleries" button.
*
* UX shape — multi-select autocomplete (mirrors CustomerAccountPicker):
* - Search box at the top filters available events (admin-side
* event list, debounced 200ms).
* - Currently-selected events render as chips above the search.
* - Click a chip to remove. Click a search result to add.
* - Save replaces the customer's full assignment list via
* PUT /admin/customers/:id/events.
*
* Access revocation: removing a chip + saving deletes the
* event_customer_assignments row. Gallery middleware re-checks that
* row on every customer-minted JWT, so the customer's next request
* to a removed gallery 403s with CUSTOMER_ASSIGNMENT_REVOKED — no
* token-blacklist step needed on the frontend.
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Search, X, Calendar as CalendarIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '../common';
import { customerAdminService } from '../../services/customerAdmin.service';
import { eventsService } from '../../services/events.service';
import type { Event as AdminEvent } from '../../services/events.service';
interface SelectedEvent {
id: number;
eventName: string;
eventDate: string | null;
}
interface Props {
customerId: number;
isOpen: boolean;
initial: SelectedEvent[];
onClose: () => void;
/** Called after a successful save so the parent can refetch. */
onSaved: () => void;
}
export const AssignedEventsDialog: React.FC<Props> = ({ customerId, isOpen, initial, onClose, onSaved }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<SelectedEvent[]>(initial);
const [query, setQuery] = useState('');
const [results, setResults] = useState<AdminEvent[]>([]);
const [isSearching, setIsSearching] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
// Re-seed selection whenever the dialog is opened so we always start
// from the server-current assignment list (not whatever the parent
// last refetched before the previous close).
useEffect(() => {
if (isOpen) {
setSelected(initial);
setQuery('');
setResults([]);
// Autofocus the search input after the open animation settles.
setTimeout(() => searchInputRef.current?.focus(), 50);
}
}, [isOpen, initial]);
// Debounced event search. Aborts in-flight responses so a fast typer
// doesn't see a stale result win the race.
useEffect(() => {
if (!isOpen) return undefined;
const term = query.trim();
if (!term) {
setResults([]);
setIsSearching(false);
return undefined;
}
setIsSearching(true);
let cancelled = false;
const handle = window.setTimeout(async () => {
try {
const resp = await eventsService.getEvents(1, 25, undefined, term);
const events = Array.isArray((resp as any)?.events)
? (resp as any).events as AdminEvent[]
: ([] as AdminEvent[]);
if (!cancelled) {
// Filter out already-selected ids client-side. Cheaper than
// round-tripping the selection state through the search API
// and keeps the matching logic in one place.
const selectedIds = new Set(selected.map((s) => s.id));
setResults(events.filter((e) => !selectedIds.has(e.id)));
}
} catch {
if (!cancelled) setResults([]);
} finally {
if (!cancelled) setIsSearching(false);
}
}, 200);
return () => { cancelled = true; window.clearTimeout(handle); };
}, [query, selected, isOpen]);
const add = (ev: AdminEvent) => {
setSelected((prev) => [
...prev,
{ id: ev.id, eventName: ev.event_name, eventDate: ev.event_date || null },
]);
// Keep the typed query around so the admin can continue picking
// additional matches from the same search (e.g. "Smith Wedding"
// returns both the engagement + the wedding event; adding one
// shouldn't force a re-type to add the other). The just-added
// event drops out of the results automatically — the search
// effect re-filters against the new `selected` set.
searchInputRef.current?.focus();
};
const clearQuery = () => {
setQuery('');
setResults([]);
searchInputRef.current?.focus();
};
const remove = (id: number) => {
setSelected((prev) => prev.filter((s) => s.id !== id));
};
const initialIds = useMemo(() => new Set(initial.map((s) => s.id)), [initial]);
const selectedIds = useMemo(() => new Set(selected.map((s) => s.id)), [selected]);
const isDirty = useMemo(() => {
if (selectedIds.size !== initialIds.size) return true;
for (const id of selectedIds) {
if (!initialIds.has(id)) return true;
}
return false;
}, [selectedIds, initialIds]);
const saveMutation = useMutation({
mutationFn: () => customerAdminService.setEvents(customerId, [...selectedIds]),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
// Surface the diff so it's obvious revocations took effect.
const parts: string[] = [];
if (result.added) parts.push(t('customers.assignedEvents.addedN', '{{count}} added', { count: result.added }));
if (result.removed) parts.push(t('customers.assignedEvents.removedN', '{{count}} removed', { count: result.removed }));
toast.success(parts.length
? t('customers.assignedEvents.savedDiff', 'Assignments updated: {{parts}}', { parts: parts.join(', ') })
: t('customers.assignedEvents.saved', 'Assignments updated'));
onSaved();
onClose();
},
onError: () => {
toast.error(t('customers.assignedEvents.error', 'Could not update assignments'));
},
});
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
role="dialog"
aria-modal="true"
onClick={(e) => {
// Click-outside to close — only when the click was actually on
// the backdrop, not on a child element that bubbled up.
if (e.target === e.currentTarget && !saveMutation.isPending) onClose();
}}
>
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('customers.assignedEvents.title', 'Manage assigned galleries')}
</h2>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
{t(
'customers.assignedEvents.subtitle',
'Pick every gallery this customer should be able to access from their dashboard. Removing a gallery here revokes access immediately on the customer\'s next request.',
)}
</p>
</div>
<button
type="button"
onClick={onClose}
disabled={saveMutation.isPending}
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 flex-shrink-0"
aria-label={t('common.close', 'Close')}
>
<X className="w-5 h-5 text-neutral-500" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Selected chips */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
{t('customers.assignedEvents.currentLabel', 'Assigned galleries')}
<span className="ml-1.5 normal-case text-neutral-400">({selected.length})</span>
</label>
{selected.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
{t('customers.assignedEvents.empty', 'No galleries assigned yet. Search below to add one.')}
</p>
) : (
<ul className="flex flex-wrap gap-2">
{selected.map((s) => (
<li
key={s.id}
className="inline-flex items-center gap-2 pl-2 pr-1 py-1 rounded-full text-sm bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700"
>
<CalendarIcon className="w-3.5 h-3.5 text-neutral-500" />
<span className="truncate max-w-[220px]">{s.eventName}</span>
<button
type="button"
onClick={() => remove(s.id)}
disabled={saveMutation.isPending}
aria-label={t('customers.assignedEvents.removeAria', 'Remove {{name}}', { name: s.eventName })}
className="p-0.5 rounded-full hover:bg-neutral-200 dark:hover:bg-neutral-700"
>
<X className="w-3.5 h-3.5 text-neutral-500" />
</button>
</li>
))}
</ul>
)}
</div>
{/* Search */}
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
{t('customers.assignedEvents.searchLabel', 'Add a gallery')}
</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400 pointer-events-none" />
<input
ref={searchInputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('customers.assignedEvents.searchPlaceholder', 'Search by event name')}
disabled={saveMutation.isPending}
className="w-full pl-9 pr-9 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
{/* Inline clear button — visible only while the query has
content. We keep the query through add() now so the
admin needs an explicit way to wipe it before starting
a new search. Esc would be lovely too but adding a
global key handler inside a modal is more risk than
this control is worth. */}
{query && (
<button
type="button"
onClick={clearQuery}
disabled={saveMutation.isPending}
aria-label={t('customers.assignedEvents.clearSearchAria', 'Clear search')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 disabled:opacity-50"
>
<X className="w-3.5 h-3.5 text-neutral-500" />
</button>
)}
</div>
{/* Results dropdown — inline (not a popover) since this is
already inside a modal, no nested-popover headaches. */}
<div className="mt-2 border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden bg-white dark:bg-neutral-800">
{!query.trim() ? (
<p className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
{t('customers.assignedEvents.searchHint', 'Start typing to find galleries.')}
</p>
) : isSearching ? (
<p className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
{t('common.searching', 'Searching…')}
</p>
) : results.length === 0 ? (
<p className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
{t('customers.assignedEvents.noResults', 'No matching galleries.')}
</p>
) : (
<ul role="listbox">
{results.map((ev) => (
<li key={ev.id}>
<button
type="button"
onClick={() => add(ev)}
disabled={saveMutation.isPending}
className="w-full text-left px-3 py-2 flex items-center justify-between gap-3 hover:bg-neutral-50 dark:hover:bg-neutral-700"
>
<span className="flex items-center gap-2 min-w-0">
<CalendarIcon className="w-4 h-4 flex-shrink-0 text-neutral-400" />
<span className="truncate text-sm font-medium text-neutral-900 dark:text-neutral-100">
{ev.event_name}
</span>
</span>
{ev.event_date && (
<span className="text-xs text-neutral-500 dark:text-neutral-400 flex-shrink-0">
{ev.event_date}
</span>
)}
</button>
</li>
))}
</ul>
)}
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-neutral-200 dark:border-neutral-700 flex items-center justify-end gap-2">
<Button
variant="outline"
onClick={onClose}
disabled={saveMutation.isPending}
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={() => saveMutation.mutate()}
disabled={!isDirty || saveMutation.isPending}
isLoading={saveMutation.isPending}
>
{t('customers.assignedEvents.save', 'Save assignments')}
</Button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,633 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Save,
Server,
Cloud,
HardDrive,
AlertCircle,
Eye,
EyeOff,
Wifi,
Loader2,
Database,
Image,
FileArchive
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Input } from '../common';
interface BackupFormData {
backup_enabled: boolean;
backup_destination_type: 'local' | 'rsync' | 's3';
backup_destination_path: string;
backup_rsync_host: string;
backup_rsync_user: string;
backup_rsync_path: string;
backup_rsync_ssh_key: string;
backup_s3_endpoint: string;
backup_s3_bucket: string;
backup_s3_access_key: string;
backup_s3_secret_key: string;
backup_s3_region: string;
backup_schedule: string;
backup_schedule_cron: string;
backup_retention_days: number;
backup_include_database: boolean;
backup_include_photos: boolean;
backup_include_archives: boolean;
backup_include_thumbnails: boolean;
backup_include_temp: boolean;
backup_compression: boolean;
backup_encryption: boolean;
backup_encryption_passphrase: string;
}
interface BackupConfigurationProps {
config?: Partial<BackupFormData>;
onSave: (data: BackupFormData) => void;
isSaving: boolean;
}
export const BackupConfiguration: React.FC<BackupConfigurationProps> = ({ config, onSave, isSaving }) => {
const { t } = useTranslation();
const destinationTypes = [
{
id: 'local' as const,
name: t('backup.configuration.destinationTypes.local.name'),
icon: HardDrive,
description: t('backup.configuration.destinationTypes.local.description'),
fields: ['backup_destination_path']
},
{
id: 'rsync' as const,
name: t('backup.configuration.destinationTypes.rsync.name'),
icon: Server,
description: t('backup.configuration.destinationTypes.rsync.description'),
fields: ['backup_rsync_host', 'backup_rsync_user', 'backup_rsync_path', 'backup_rsync_ssh_key']
},
{
id: 's3' as const,
name: t('backup.configuration.destinationTypes.s3.name'),
icon: Cloud,
description: t('backup.configuration.destinationTypes.s3.description'),
fields: ['backup_s3_endpoint', 'backup_s3_bucket', 'backup_s3_access_key', 'backup_s3_secret_key', 'backup_s3_region']
}
];
const scheduleOptions = [
{ value: 'hourly', label: t('backup.configuration.schedule.options.hourly') },
{ value: 'daily', label: t('backup.configuration.schedule.options.daily') },
{ value: 'weekly', label: t('backup.configuration.schedule.options.weekly') },
{ value: 'custom', label: t('backup.configuration.schedule.options.custom') }
];
const [formData, setFormData] = useState<BackupFormData>({
backup_enabled: false,
backup_destination_type: 'local',
backup_destination_path: '',
backup_rsync_host: '',
backup_rsync_user: '',
backup_rsync_path: '',
backup_rsync_ssh_key: '',
backup_s3_endpoint: '',
backup_s3_bucket: '',
backup_s3_access_key: '',
backup_s3_secret_key: '',
backup_s3_region: '',
backup_schedule: 'daily',
backup_schedule_cron: '0 3 * * *',
backup_retention_days: 30,
backup_include_database: true,
backup_include_photos: true,
backup_include_archives: true,
backup_include_thumbnails: false,
backup_include_temp: false,
backup_compression: true,
backup_encryption: false,
backup_encryption_passphrase: ''
});
const [showSecrets, setShowSecrets] = useState({
s3_secret_key: false,
ssh_key: false,
encryption_passphrase: false
});
const [testingConnection, setTestingConnection] = useState(false);
useEffect(() => {
if (config) {
setFormData(prev => ({
...prev,
...config
}));
}
}, [config]);
const handleChange = <K extends keyof BackupFormData>(field: K, value: BackupFormData[K]) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const destinationType = destinationTypes.find(dt => dt.id === formData.backup_destination_type);
const missingFields: string[] = [];
if (formData.backup_enabled && destinationType) {
destinationType.fields.forEach(field => {
if (!formData[field as keyof BackupFormData] && !field.includes('optional')) {
missingFields.push(field);
}
});
}
if (missingFields.length > 0) {
toast.error(t('backup.configuration.messages.requiredFields'));
return;
}
onSave(formData);
};
const testConnection = async () => {
setTestingConnection(true);
try {
// TODO: Implement connection test endpoint
await new Promise(resolve => setTimeout(resolve, 2000));
toast.success(t('backup.configuration.messages.connectionSuccess'));
} catch (error) {
toast.error(t('backup.configuration.messages.connectionFailed') + ': ' + (error as Error).message);
} finally {
setTestingConnection(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Enable/Disable Toggle */}
<Card className="p-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('backup.configuration.enableBackup')}</h3>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('backup.configuration.enableBackupHelp')}
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.backup_enabled}
onChange={(e) => handleChange('backup_enabled', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-neutral-200 dark:bg-neutral-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-neutral-300 dark:after:border-neutral-500 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
</Card>
{/* Destination Configuration */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.destinationType')}</h3>
{/* Destination Type Selection */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
{destinationTypes.map((type) => {
const Icon = type.icon;
return (
<button
key={type.id}
type="button"
onClick={() => handleChange('backup_destination_type', type.id)}
className={`p-4 rounded-lg border-2 transition-all ${
formData.backup_destination_type === type.id
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
<Icon className={`h-8 w-8 mb-2 mx-auto ${
formData.backup_destination_type === type.id
? 'text-primary'
: 'text-neutral-400'
}`} />
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{type.name}</h4>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{type.description}</p>
</button>
);
})}
</div>
{/* Destination-specific fields */}
<div className="space-y-4">
{formData.backup_destination_type === 'local' && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.destinationPath')}
</label>
<Input
type="text"
value={formData.backup_destination_path}
onChange={(e) => handleChange('backup_destination_path', e.target.value)}
placeholder={t('backup.configuration.fields.destinationPathPlaceholder')}
required
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('backup.configuration.fields.destinationPathHelp')}
</p>
</div>
</>
)}
{formData.backup_destination_type === 'rsync' && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.rsyncHost')}
</label>
<Input
type="text"
value={formData.backup_rsync_host}
onChange={(e) => handleChange('backup_rsync_host', e.target.value)}
placeholder={t('backup.configuration.fields.rsyncHostPlaceholder')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.rsyncUser')}
</label>
<Input
type="text"
value={formData.backup_rsync_user}
onChange={(e) => handleChange('backup_rsync_user', e.target.value)}
placeholder={t('backup.configuration.fields.rsyncUserPlaceholder')}
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.rsyncPath')}
</label>
<Input
type="text"
value={formData.backup_rsync_path}
onChange={(e) => handleChange('backup_rsync_path', e.target.value)}
placeholder={t('backup.configuration.fields.rsyncPathPlaceholder')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.rsyncSshKey')}
</label>
<div className="relative">
<textarea
value={formData.backup_rsync_ssh_key}
onChange={(e) => handleChange('backup_rsync_ssh_key', e.target.value)}
placeholder={t('backup.configuration.fields.rsyncSshKeyPlaceholder')}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md focus:outline-none focus:ring-primary focus:border-primary font-mono text-sm"
rows={4}
/>
<button
type="button"
onClick={() => setShowSecrets(prev => ({ ...prev, ssh_key: !prev.ssh_key }))}
className="absolute top-2 right-2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
>
{showSecrets.ssh_key ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('backup.configuration.fields.rsyncSshKeyHelp')}
</p>
</div>
</>
)}
{formData.backup_destination_type === 's3' && (
<>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.s3Endpoint')}
</label>
<Input
type="text"
value={formData.backup_s3_endpoint}
onChange={(e) => handleChange('backup_s3_endpoint', e.target.value)}
placeholder="https://s3.amazonaws.com"
required
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('backup.configuration.fields.s3EndpointHelp')}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.s3Bucket')}
</label>
<Input
type="text"
value={formData.backup_s3_bucket}
onChange={(e) => handleChange('backup_s3_bucket', e.target.value)}
placeholder="my-backup-bucket"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.s3Region')}
</label>
<Input
type="text"
value={formData.backup_s3_region}
onChange={(e) => handleChange('backup_s3_region', e.target.value)}
placeholder="us-east-1"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.s3AccessKey')}
</label>
<Input
type="text"
value={formData.backup_s3_access_key}
onChange={(e) => handleChange('backup_s3_access_key', e.target.value)}
placeholder="AKIAIOSFODNN7EXAMPLE"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.fields.s3SecretKey')}
</label>
<div className="relative">
<Input
type={showSecrets.s3_secret_key ? 'text' : 'password'}
value={formData.backup_s3_secret_key}
onChange={(e) => handleChange('backup_s3_secret_key', e.target.value)}
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
required
/>
<button
type="button"
onClick={() => setShowSecrets(prev => ({ ...prev, s3_secret_key: !prev.s3_secret_key }))}
className="absolute top-1/2 -translate-y-1/2 right-2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
>
{showSecrets.s3_secret_key ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
</div>
</>
)}
{/* Test Connection Button */}
{formData.backup_destination_type && (
<div className="pt-2">
<Button
type="button"
onClick={testConnection}
disabled={testingConnection}
variant="secondary"
size="sm"
>
{testingConnection ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('backup.configuration.testingConnection')}
</>
) : (
<>
<Wifi className="mr-2 h-4 w-4" />
{t('backup.actions.testConnection')}
</>
)}
</Button>
</div>
)}
</div>
</Card>
{/* Schedule Configuration */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.schedule.title')}</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.schedule.scheduleType')}
</label>
<select
value={formData.backup_schedule}
onChange={(e) => handleChange('backup_schedule', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md focus:outline-none focus:ring-primary focus:border-primary"
>
{scheduleOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{formData.backup_schedule === 'custom' && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.schedule.customCron')}
</label>
<Input
type="text"
value={formData.backup_schedule_cron}
onChange={(e) => handleChange('backup_schedule_cron', e.target.value)}
placeholder="0 3 * * *"
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('backup.configuration.schedule.customCronHelp')}
</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.schedule.retention')}
</label>
<Input
type="number"
value={formData.backup_retention_days}
onChange={(e) => handleChange('backup_retention_days', parseInt(e.target.value))}
min="1"
max="365"
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('backup.configuration.schedule.retentionHelp')}
</p>
</div>
</div>
</Card>
{/* Backup Content Selection */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.whatToBackup.title')}</h3>
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.backup_include_database}
onChange={(e) => handleChange('backup_include_database', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
/>
<div className="ml-3">
<div className="flex items-center space-x-2">
<Database className="h-4 w-4 text-neutral-400" />
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.database')}</span>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.whatToBackup.databaseHelp')}</p>
</div>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={formData.backup_include_photos}
onChange={(e) => handleChange('backup_include_photos', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
/>
<div className="ml-3">
<div className="flex items-center space-x-2">
<Image className="h-4 w-4 text-neutral-400" />
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.photos')}</span>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.whatToBackup.photosHelp')}</p>
</div>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={formData.backup_include_archives}
onChange={(e) => handleChange('backup_include_archives', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
/>
<div className="ml-3">
<div className="flex items-center space-x-2">
<FileArchive className="h-4 w-4 text-neutral-400" />
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.archives')}</span>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.whatToBackup.archivesHelp')}</p>
</div>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={formData.backup_include_thumbnails}
onChange={(e) => handleChange('backup_include_thumbnails', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
/>
<div className="ml-3">
<div className="flex items-center space-x-2">
<Image className="h-4 w-4 text-neutral-400" />
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.thumbnails')}</span>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.whatToBackup.thumbnailsHelp')}</p>
</div>
</label>
</div>
</Card>
{/* Advanced Options */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.advancedOptions.title')}</h3>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.backup_compression}
onChange={(e) => handleChange('backup_compression', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
/>
<div className="ml-3">
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.advancedOptions.compression')}</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.advancedOptions.compressionHelp')}</p>
</div>
</label>
<div>
<label className="flex items-center mb-3">
<input
type="checkbox"
checked={formData.backup_encryption}
onChange={(e) => handleChange('backup_encryption', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
/>
<div className="ml-3">
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.configuration.advancedOptions.encryption')}</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.advancedOptions.encryptionHelp')}</p>
</div>
</label>
{formData.backup_encryption && (
<div className="ml-7">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('backup.configuration.advancedOptions.encryptionPassphrase')}
</label>
<div className="relative">
<Input
type={showSecrets.encryption_passphrase ? 'text' : 'password'}
value={formData.backup_encryption_passphrase}
onChange={(e) => handleChange('backup_encryption_passphrase', e.target.value)}
placeholder={t('backup.configuration.advancedOptions.encryptionPassphraseHelp')}
required={formData.backup_encryption}
/>
<button
type="button"
onClick={() => setShowSecrets(prev => ({ ...prev, encryption_passphrase: !prev.encryption_passphrase }))}
className="absolute top-1/2 -translate-y-1/2 right-2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
>
{showSecrets.encryption_passphrase ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
<p className="mt-1 text-xs text-red-600">
<AlertCircle className="inline h-3 w-3 mr-1" />
{t('backup.configuration.advancedOptions.encryptionPassphraseHelp')}
</p>
</div>
)}
</div>
</div>
</Card>
{/* Save Button */}
<div className="flex justify-end">
<Button
type="submit"
disabled={isSaving}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('backup.configuration.savingSettings')}
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
{t('backup.configuration.saveSettings')}
</>
)}
</Button>
</div>
</form>
);
};
@@ -0,0 +1,433 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
ShieldCheck,
ShieldAlert,
Database,
FolderTree,
AlertTriangle,
CheckCircle2,
XCircle,
EyeOff,
Clock,
RefreshCw,
Loader2,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
// Locale-aware formatters per [[feedback_respect_general_format_settings]].
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Card, Button } from '../common';
import {
adminService,
BackupCoverageReport,
BackupPathCoverage,
} from '../../services/admin.service';
/**
* BackupCoverageCard — Stage C of the backup-hardening plan.
*
* Tells the admin what the next "Run Backup Now" will actually do:
*
* - Database: inline-dump or scheduled, last dump age, staleness
* - Configured paths: per-row coverage (will-scan / skipped by
* toggle / skipped by feature flag / missing on disk)
* - Drift: top-level subdirs under STORAGE_PATH that have no
* `backup_paths` row (the "feature shipped without a backup row"
* footgun this whole effort is designed to catch)
*
* Auto-fetches on mount — unlike the integrity verifier, this is
* a cheap query (no recursion) so admins should always see the
* current state when they open the tab.
*/
export const BackupCoverageCard: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime } = useLocalizedDate();
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
queryKey: ['backup-coverage'],
queryFn: () => adminService.getBackupCoverage(),
// The report changes only when (a) backup_paths is edited or
// (b) a new scheduled dump completes. Stale time of 30s keeps
// the UI snappy without hammering the endpoint.
staleTime: 30_000,
});
return (
<Card className="p-6">
<Header report={data} loading={isLoading} onRefresh={() => refetch()} refreshing={isFetching} />
{isError && (
<ErrorBanner message={(error as Error)?.message ?? 'unknown error'} />
)}
{data && (
<>
{data.summary.tableMissingFallbackInUse && (
<FallbackWarning />
)}
<SectionGrid>
<DatabaseStatusCard database={data.database} />
<SummaryCard summary={data.summary} />
</SectionGrid>
<PathsTable paths={data.paths} />
<DriftSection drift={data.drift} />
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-4">
{t('backup.coverage.generatedAt', 'Coverage generated: {{when}}', {
when: formatDateTime(new Date(data.generatedAt)),
})}
</p>
</>
)}
</Card>
);
};
const Header: React.FC<{
report: BackupCoverageReport | undefined;
loading: boolean;
onRefresh: () => void;
refreshing: boolean;
}> = ({ report, loading, onRefresh, refreshing }) => {
const { t } = useTranslation();
const healthy = report?.summary.overallOk;
return (
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
{loading || refreshing ? (
<Loader2 className="w-5 h-5 text-neutral-400 animate-spin" />
) : healthy ? (
<ShieldCheck className="w-5 h-5 text-green-600 dark:text-green-400" />
) : report ? (
<ShieldAlert className="w-5 h-5 text-amber-600 dark:text-amber-400" />
) : (
<ShieldCheck className="w-5 h-5 text-neutral-400" />
)}
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.coverage.title', 'Backup coverage')}
</h3>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 max-w-2xl">
{t(
'backup.coverage.description',
'Shows what the next backup will include, skip, or silently miss. The database block confirms the dump strategy. The "drift" section flags subdirectories that exist on disk but are not in the backup configuration — usually a sign that a new feature shipped without a matching backup_paths row.',
)}
</p>
</div>
<Button
variant="ghost"
onClick={onRefresh}
disabled={loading || refreshing}
leftIcon={
refreshing
? <Loader2 className="w-4 h-4 animate-spin" />
: <RefreshCw className="w-4 h-4" />
}
>
{t('backup.coverage.refresh', 'Refresh')}
</Button>
</div>
);
};
const ErrorBanner: React.FC<{ message: string }> = ({ message }) => {
const { t } = useTranslation();
return (
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 text-sm text-red-700 dark:text-red-300">
{t('backup.coverage.error', 'Could not load coverage report: {{message}}', { message })}
</div>
);
};
const FallbackWarning: React.FC = () => {
const { t } = useTranslation();
return (
<div className="mb-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 text-sm text-amber-800 dark:text-amber-200 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>
{t(
'backup.coverage.fallbackInUse',
'The backup_paths table is missing. The walker is using its legacy hard-coded fallback. Migration 108 may not have run — check server logs and re-run migrations.',
)}
</span>
</div>
);
};
const SectionGrid: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">{children}</div>
);
const DatabaseStatusCard: React.FC<{
database: BackupCoverageReport['database'];
}> = ({ database }) => {
const { t } = useTranslation();
const { formatDateTime } = useLocalizedDate();
const isInline = database.mode === 'inline';
const tone: Tone = database.ok ? 'green' : 'red';
const dumpAge = database.lastDumpAgeMs !== null
? formatAge(database.lastDumpAgeMs)
: null;
return (
<div className={`rounded-lg p-4 ${TONE_BG[tone]}`}>
<div className="flex items-center gap-2 mb-2">
<Database className="w-4 h-4" />
<h4 className="font-semibold text-sm uppercase tracking-wide">
{t('backup.coverage.database.title', 'Database')}
</h4>
{database.ok ? (
<CheckCircle2 className="w-4 h-4 ml-auto" />
) : (
<XCircle className="w-4 h-4 ml-auto" />
)}
</div>
<dl className="space-y-1 text-sm">
<Row
label={t('backup.coverage.database.mode', 'Mode')}
value={isInline
? t('backup.coverage.database.modeInline', 'Inline dump on every backup')
: t('backup.coverage.database.modeScheduled', 'Scheduled-only (inline opted out)')}
/>
{database.lastDumpAt ? (
<>
<Row
label={t('backup.coverage.database.lastDump', 'Last dump')}
value={`${formatDateTime(new Date(database.lastDumpAt))}${
dumpAge ? ` (${dumpAge})` : ''
}`}
/>
<Row
label={t('backup.coverage.database.lastDumpSize', 'Size')}
value={formatBytes(database.lastDumpSizeBytes)}
/>
</>
) : (
<Row
label={t('backup.coverage.database.lastDump', 'Last dump')}
value={t('backup.coverage.database.noDump', 'No dump on file yet')}
/>
)}
{database.lastDumpStale && (
<Row
label={t('backup.coverage.database.staleLabel', 'Status')}
value={t('backup.coverage.database.stale', 'Stale — older than 26h')}
icon={<Clock className="w-3.5 h-3.5" />}
/>
)}
</dl>
</div>
);
};
const SummaryCard: React.FC<{
summary: BackupCoverageReport['summary'];
}> = ({ summary }) => {
const { t } = useTranslation();
const tone: Tone = summary.overallOk
? 'green'
: summary.driftCount > 0 || !summary.databaseOk
? 'amber'
: 'neutral';
return (
<div className={`rounded-lg p-4 ${TONE_BG[tone]}`}>
<div className="flex items-center gap-2 mb-2">
<FolderTree className="w-4 h-4" />
<h4 className="font-semibold text-sm uppercase tracking-wide">
{t('backup.coverage.summary.title', 'Summary')}
</h4>
</div>
<dl className="space-y-1 text-sm">
<Row
label={t('backup.coverage.summary.willScan', 'Will scan')}
value={`${summary.willScanCount} / ${summary.configuredCount}`}
/>
{summary.skippedByToggleCount > 0 && (
<Row
label={t('backup.coverage.summary.skippedByToggle', 'Skipped (toggle off)')}
value={String(summary.skippedByToggleCount)}
/>
)}
{summary.skippedByFeatureFlagCount > 0 && (
<Row
label={t('backup.coverage.summary.skippedByFlag', 'Skipped (feature flag)')}
value={String(summary.skippedByFeatureFlagCount)}
/>
)}
{summary.missingOnDiskCount > 0 && (
<Row
label={t('backup.coverage.summary.missingOnDisk', 'Missing on disk')}
value={String(summary.missingOnDiskCount)}
/>
)}
<Row
label={t('backup.coverage.summary.drift', 'Unconfigured on disk (drift)')}
value={String(summary.driftCount)}
/>
</dl>
</div>
);
};
const PathsTable: React.FC<{ paths: BackupCoverageReport['paths'] }> = ({ paths }) => {
const { t } = useTranslation();
return (
<div className="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-neutral-50 dark:bg-neutral-800/50 border-b border-neutral-200 dark:border-neutral-700">
<h4 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.coverage.paths.heading', 'Configured paths')}
</h4>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800/30">
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
<th className="px-3 py-2">{t('backup.coverage.paths.path', 'Path')}</th>
<th className="px-3 py-2">{t('backup.coverage.paths.coverage', 'Coverage')}</th>
<th className="px-3 py-2">{t('backup.coverage.paths.featureFlag', 'Feature flag')}</th>
<th className="px-3 py-2">{t('backup.coverage.paths.description', 'Description')}</th>
</tr>
</thead>
<tbody>
{paths.map((p) => (
<tr
key={p.path}
className="border-t border-neutral-200 dark:border-neutral-700"
>
<td className="px-3 py-2 font-mono text-xs text-neutral-700 dark:text-neutral-300">
{p.path}
</td>
<td className="px-3 py-2">
<CoverageBadge coverage={p.coverage} />
</td>
<td className="px-3 py-2 text-xs text-neutral-600 dark:text-neutral-400">
{p.featureFlag
? `${p.featureFlag} = ${p.featureFlagValue === null ? '∅' : String(p.featureFlagValue)}`
: '—'}
</td>
<td className="px-3 py-2 text-xs text-neutral-600 dark:text-neutral-400">
{p.description ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
const DriftSection: React.FC<{ drift: BackupCoverageReport['drift'] }> = ({ drift }) => {
const { t } = useTranslation();
if (drift.unconfiguredOnDisk.length === 0) {
return (
<div className="mt-4 p-3 rounded-lg bg-green-50 dark:bg-green-900/30 text-sm text-green-700 dark:text-green-300 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
{t(
'backup.coverage.drift.none',
'No drift detected — every top-level subdirectory under STORAGE_PATH is either in backup_paths or in the expected non-backup allow-list.',
)}
</div>
);
}
return (
<div className="mt-4 border border-amber-300 dark:border-amber-700 rounded-lg overflow-hidden">
<div className="px-3 py-2 bg-amber-50 dark:bg-amber-900/30 border-b border-amber-300 dark:border-amber-700">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-amber-700 dark:text-amber-300" />
<h4 className="text-sm font-semibold text-amber-800 dark:text-amber-200">
{t('backup.coverage.drift.heading', 'Drift detected: subdirectories not covered by any backup_paths row')}
</h4>
</div>
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
{t(
'backup.coverage.drift.caption',
'These directories exist on disk but the walker will skip them. Either add a backup_paths row, move the files into a covered location, or — if they are runtime caches — confirm they are safe to exclude.',
)}
</p>
</div>
<ul className="divide-y divide-amber-200 dark:divide-amber-800">
{drift.unconfiguredOnDisk.map((d) => (
<li
key={d}
className="px-3 py-2 font-mono text-xs text-amber-900 dark:text-amber-100 flex items-center gap-2"
>
<EyeOff className="w-3.5 h-3.5" />
{d}
</li>
))}
</ul>
</div>
);
};
const CoverageBadge: React.FC<{ coverage: BackupPathCoverage }> = ({ coverage }) => {
const { t } = useTranslation();
const map: Record<BackupPathCoverage, { tone: Tone; label: string }> = {
'will-scan': {
tone: 'green',
label: t('backup.coverage.coverage.willScan', 'Will scan'),
},
'skipped-by-toggle': {
tone: 'neutral',
label: t('backup.coverage.coverage.skippedByToggle', 'Off'),
},
'skipped-by-feature-flag': {
tone: 'neutral',
label: t('backup.coverage.coverage.skippedByFlag', 'Gated off'),
},
'missing-on-disk': {
tone: 'amber',
label: t('backup.coverage.coverage.missingOnDisk', 'Missing on disk'),
},
};
const { tone, label } = map[coverage];
return (
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${TONE_BG[tone]}`}>
{label}
</span>
);
};
const Row: React.FC<{ label: string; value: string; icon?: React.ReactNode }> = ({
label, value, icon,
}) => (
<div className="flex justify-between items-center gap-3">
<dt className="text-xs uppercase tracking-wide opacity-80 flex items-center gap-1">
{icon}
{label}
</dt>
<dd className="text-sm font-medium text-right">{value}</dd>
</div>
);
type Tone = 'neutral' | 'green' | 'amber' | 'red';
const TONE_BG: Record<Tone, string> = {
neutral: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-200',
green: 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300',
amber: 'bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',
red: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300',
};
function formatBytes(bytes: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function formatAge(ms: number): string {
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 48) return `${hr}h ago`;
const day = Math.floor(hr / 24);
return `${day}d ago`;
}
@@ -0,0 +1,441 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
HardDrive,
Database,
FileArchive,
Image,
Clock,
CheckCircle,
AlertCircle,
Shield,
Server,
Cloud,
Play,
Loader2,
AlertTriangle,
Info
} from 'lucide-react';
// Per [[feedback_respect_general_format_settings]] — route every
// displayed date/time through useLocalizedDate so general_date_format
// and general_time_format settings apply uniformly.
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Card, Button } from '../common';
export type HealthStatus = 'excellent' | 'good' | 'warning' | 'critical';
export type BackupDestinationType = 's3' | 'rsync' | 'local';
interface BackupStatistics {
total_size?: number;
files_processed?: number;
database_backed_up?: boolean;
photos_backed_up?: number;
total_photos?: number;
archives_backed_up?: number;
photo_count?: number;
}
interface BackupRecord {
id: number;
status: 'completed' | 'failed' | 'running';
backup_type: string;
created_at: string;
duration_seconds: number;
started_at?: string;
error_message?: string;
statistics?: BackupStatistics;
}
interface BackupStatus {
lastBackup?: BackupRecord; // most recent attempt, any status
lastSuccessfulBackup?: BackupRecord; // most recent completed run
zombieRuns?: BackupRecord[]; // running > 30min, likely crashed
totalBackups?: number;
recentBackups?: BackupRecord[];
}
interface BackupConfig {
backup_destination_type?: BackupDestinationType;
backup_enabled?: boolean;
backup_s3_bucket?: string;
backup_destination_path?: string;
backup_rsync_host?: string;
backup_retention_days?: number;
}
interface StatCardProps {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: string | number;
color?: string;
subtext?: string;
}
interface BackupDashboardProps {
status?: BackupStatus;
config?: BackupConfig;
onRunBackup: () => void;
isBackupRunning: boolean;
}
const StatCard: React.FC<StatCardProps> = ({ icon: Icon, label, value, color = 'blue', subtext }) => (
<Card className="p-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">{label}</p>
<p className="mt-2 text-3xl font-semibold text-neutral-900 dark:text-neutral-100">{value}</p>
{subtext && (
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">{subtext}</p>
)}
</div>
<div className={`p-3 bg-${color}-100 dark:bg-${color}-900/40 rounded-lg`}>
<Icon className={`h-6 w-6 text-${color}-600 dark:text-${color}-400`} />
</div>
</div>
</Card>
);
const formatBytes = (bytes: number): string => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
const healthColors: Record<HealthStatus, string> = {
excellent: 'green',
good: 'blue',
warning: 'amber',
critical: 'red',
};
export const BackupDashboard: React.FC<BackupDashboardProps> = ({ status, config, onRunBackup, isBackupRunning }) => {
const { t } = useTranslation();
const { format, formatTime, formatDateTime, formatDistanceToNow } = useLocalizedDate();
const lastBackup = status?.lastBackup; // any status
const lastSuccessfulBackup = status?.lastSuccessfulBackup; // status='completed' only
const zombieRuns = status?.zombieRuns ?? [];
const statistics = lastSuccessfulBackup?.statistics ?? lastBackup?.statistics ?? {};
const isConfigured = config && config.backup_destination_type;
const isEnabled = config?.backup_enabled;
// Use the most recent SUCCESSFUL backup as the "age" reference for
// health, so a transient failure doesn't immediately drop the score
// — but call out failed/running/zombie attempts explicitly so the
// admin sees them at a glance.
const getHealthScore = (): { score: number; status: HealthStatus; message: string } => {
if (!lastSuccessfulBackup) {
// No successful backup ever recorded.
if (lastBackup?.status === 'failed') {
return { score: 0, status: 'critical', message: t('backup.dashboard.healthMessages.lastBackupFailed') };
}
return { score: 0, status: 'critical', message: t('backup.dashboard.healthMessages.noBackups') };
}
const hoursSinceBackup = (Date.now() - new Date(lastSuccessfulBackup.created_at).getTime()) / (1000 * 60 * 60);
// A successful backup exists. Bias the score on its age, but if
// the MOST RECENT attempt failed, downgrade the message so the
// admin sees the regression even though older backups are fine.
const latestAttemptFailed = lastBackup && lastBackup.id !== lastSuccessfulBackup.id
&& lastBackup.status === 'failed';
if (latestAttemptFailed) {
return {
score: 50,
status: 'warning',
message: t('backup.dashboard.healthMessages.lastBackupFailed'),
};
}
if (hoursSinceBackup < 24) {
return { score: 100, status: 'excellent', message: t('backup.dashboard.healthMessages.upToDate') };
} else if (hoursSinceBackup < 48) {
return { score: 75, status: 'good', message: t('backup.dashboard.healthMessages.recent') };
} else if (hoursSinceBackup < 168) {
return { score: 50, status: 'warning', message: t('backup.dashboard.healthMessages.gettingOld') };
} else {
return { score: 25, status: 'critical', message: t('backup.dashboard.healthMessages.outdated') };
}
};
const health = getHealthScore();
return (
<div className="space-y-6">
{/* Configuration Alert */}
{!isConfigured && (
<div className="bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-amber-400 mt-0.5" />
<div className="ml-3">
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">
{t('backup.dashboard.notConfigured.title')}
</h3>
<p className="mt-1 text-sm text-amber-700 dark:text-amber-300">
{t('backup.dashboard.notConfigured.message')}
</p>
</div>
</div>
</div>
)}
{/* Health Score Card */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('backup.dashboard.health.title')}</h3>
<span className={`px-3 py-1 rounded-full text-sm font-medium bg-${healthColors[health.status]}-100 dark:bg-${healthColors[health.status]}-900/40 text-${healthColors[health.status]}-700 dark:text-${healthColors[health.status]}-300`}>
{t(`backup.dashboard.healthStatus.${health.status}`)}
</span>
</div>
<div className="flex items-center space-x-4">
<div className="relative w-24 h-24">
<svg className="w-24 h-24 transform -rotate-90">
<circle
cx="48"
cy="48"
r="36"
stroke="currentColor"
strokeWidth="8"
fill="none"
className="text-neutral-200 dark:text-neutral-700"
/>
<circle
cx="48"
cy="48"
r="36"
stroke="currentColor"
strokeWidth="8"
fill="none"
strokeDasharray={`${(health.score / 100) * 226} 226`}
className={`text-${healthColors[health.status]}-500`}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{health.score}%</span>
</div>
</div>
<div className="flex-1">
<p className="text-neutral-700 dark:text-neutral-300 font-medium">{health.message}</p>
{/* Show the last successful backup explicitly — previously
this read `lastBackup.created_at` which silently rendered
a failed/running row as if it were the last success. */}
{lastSuccessfulBackup && (
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">
{t('backup.dashboard.lastSuccessful', 'Last successful backup')}: {formatDistanceToNow(new Date(lastSuccessfulBackup.created_at), { addSuffix: true })}
</p>
)}
{/* If the most recent attempt is NOT the last successful
run, surface it separately so the admin sees the
divergence (latest attempt failed or running). */}
{lastBackup && lastBackup.id !== lastSuccessfulBackup?.id && (
<p className={`text-sm mt-1 ${
lastBackup.status === 'failed'
? 'text-red-600 dark:text-red-400 font-medium'
: lastBackup.status === 'running'
? 'text-blue-600 dark:text-blue-400'
: 'text-neutral-500 dark:text-neutral-400'
}`}>
{t('backup.dashboard.lastAttempt', 'Last attempt')}: {formatDistanceToNow(new Date(lastBackup.created_at), { addSuffix: true })}
{' · '}
{t(`backup.dashboard.status.${lastBackup.status}`, lastBackup.status)}
{lastBackup.status === 'failed' && lastBackup.error_message && (
<span className="block text-xs text-red-600 dark:text-red-400 mt-0.5">
{lastBackup.error_message.split('\n')[0].slice(0, 200)}
</span>
)}
</p>
)}
{/* Zombie warning — running >30min, almost certainly crashed.
Admin needs to know they may be looking at a hung row
that won't ever flip to completed. */}
{zombieRuns.length > 0 && (
<p className="text-sm mt-1 text-amber-700 dark:text-amber-300 font-medium">
{t('backup.dashboard.zombieRuns',
'{{count}} backup(s) running >30min — may have crashed without completing',
{ count: zombieRuns.length })}
</p>
)}
<Button
onClick={onRunBackup}
disabled={!isConfigured || !isEnabled || isBackupRunning}
className="mt-3"
size="sm"
>
{isBackupRunning ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('backup.dashboard.actions.running')}
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
{t('backup.dashboard.actions.runBackupNow')}
</>
)}
</Button>
</div>
</div>
</Card>
{/* Statistics Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={FileArchive}
label={t('backup.dashboard.stats.totalBackups')}
value={status?.totalBackups || 0}
color="blue"
subtext={lastBackup ? `${t('backup.dashboard.stats.last')}: ${format(new Date(lastBackup.created_at))}` : t('backup.dashboard.stats.noBackupsYet')}
/>
<StatCard
icon={HardDrive}
label={t('backup.dashboard.stats.backupSize')}
value={formatBytes(statistics.total_size || 0)}
color="green"
subtext={`${statistics.files_processed || 0} ${t('backup.dashboard.stats.files')}`}
/>
<StatCard
icon={Clock}
label={t('backup.dashboard.stats.lastDuration')}
value={lastBackup ? `${Math.round(lastBackup.duration_seconds / 60)}m` : 'N/A'}
color="purple"
subtext={lastBackup ? formatTime(new Date(lastBackup.created_at)) : ''}
/>
<StatCard
icon={Shield}
label={t('backup.dashboard.stats.backupStatus')}
value={isEnabled ? t('backup.dashboard.stats.active') : t('backup.dashboard.stats.inactive')}
color={isEnabled ? 'green' : 'gray'}
subtext={config?.backup_destination_type || t('backup.dashboard.notConfigured.title')}
/>
</div>
{/* Recent Activity */}
{status?.recentBackups && status.recentBackups.length > 0 && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.dashboard.recentActivity.title')}</h3>
<div className="space-y-3">
{status.recentBackups.slice(0, 5).map((backup) => (
<div key={backup.id} className="flex items-center justify-between py-3 border-b border-neutral-100 dark:border-neutral-700 last:border-0">
<div className="flex items-center space-x-3">
{backup.status === 'completed' ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : backup.status === 'failed' ? (
<AlertCircle className="h-5 w-5 text-red-500" />
) : (
<Loader2 className="h-5 w-5 text-blue-500 animate-spin" />
)}
<div>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{t('backup.dashboard.backupType', { type: backup.backup_type })}
</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{formatDateTime(new Date(backup.created_at))}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{formatBytes(backup.statistics?.total_size || 0)}
</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{backup.statistics?.files_processed || 0} files
</p>
</div>
</div>
))}
</div>
</Card>
)}
{/* Storage Status */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.dashboard.coverage.title')}</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Database className="h-5 w-5 text-neutral-400" />
<span className="text-neutral-700 dark:text-neutral-300">Database</span>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${
statistics.database_backed_up ? 'bg-green-100 dark:bg-green-900/40 text-green-700 dark:text-green-300' : 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300'
}`}>
{statistics.database_backed_up ? t('backup.dashboard.coverage.included') : t('backup.dashboard.coverage.excluded')}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Image className="h-5 w-5 text-neutral-400" />
<span className="text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.photos')}</span>
</div>
<span className="text-sm text-neutral-500 dark:text-neutral-400">
{statistics.photos_backed_up || 0} {t('common.of')} {statistics.total_photos || 0}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<FileArchive className="h-5 w-5 text-neutral-400" />
<span className="text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.archives')}</span>
</div>
<span className="text-sm text-neutral-500 dark:text-neutral-400">
{statistics.archives_backed_up || 0} {t('backup.dashboard.stats.files')}
</span>
</div>
</div>
</Card>
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.dashboard.storageDestination')}</h3>
<div className="space-y-3">
<div className="flex items-center space-x-3">
{config?.backup_destination_type === 's3' ? (
<Cloud className="h-5 w-5 text-blue-500" />
) : config?.backup_destination_type === 'rsync' ? (
<Server className="h-5 w-5 text-purple-500" />
) : (
<HardDrive className="h-5 w-5 text-neutral-500" />
)}
<div>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{config?.backup_destination_type
? t(`backup.configuration.destinationTypes.${config.backup_destination_type}.name`)
: t('backup.dashboard.notConfigured.title')}
</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{config?.backup_destination_type === 's3' && config?.backup_s3_bucket
? `Bucket: ${config.backup_s3_bucket}`
: config?.backup_destination_type === 'local' && config?.backup_destination_path
? `Path: ${config.backup_destination_path}`
: config?.backup_destination_type === 'rsync' && config?.backup_rsync_host
? `Host: ${config.backup_rsync_host}`
: t('backup.dashboard.noDestinationSet')}
</p>
</div>
</div>
{config?.backup_retention_days && (
<div className="mt-4 p-3 bg-neutral-50 dark:bg-neutral-700 rounded-lg">
<div className="flex items-center space-x-2">
<Info className="h-4 w-4 text-neutral-400" />
<span className="text-sm text-neutral-600 dark:text-neutral-300">
{t('backup.configuration.schedule.retentionDays')} {config.backup_retention_days} {t('backup.configuration.schedule.retentionHelp').replace('days (older backups will be automatically deleted)', '')}
</span>
</div>
</div>
)}
</div>
</Card>
</div>
</div>
);
};
+3
View File
@@ -0,0 +1,3 @@
import type { ComponentType } from 'react';
export const BackupHistory: ComponentType<any>;
@@ -0,0 +1,489 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Download,
Eye,
Trash2,
Search,
Filter,
CheckCircle,
XCircle,
Clock,
AlertCircle,
FileArchive,
Database,
Image,
HardDrive,
ChevronDown,
ChevronUp,
Calendar,
RefreshCw,
Loader2
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Button, Card, Input, Loading } from '../common';
import { api } from '../../config/api';
import { useMutationWithToast } from '../../hooks';
// Per [[feedback_respect_general_format_settings]]: route every displayed
// date/time through useLocalizedDate so the admin's general_date_format +
// general_time_format settings apply uniformly. Previously the backup
// History pane used raw date-fns format() with hard-coded 'p' (12-hour
// AM/PM) and 'PPP' (US-locale long date), which ignored the settings —
// Ralf 2026-05-31 flagged "11:25 PM" on a 24h-configured install.
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
const statusIcons = {
completed: { icon: CheckCircle, color: 'text-green-500' },
failed: { icon: XCircle, color: 'text-red-500' },
running: { icon: Loader2, color: 'text-blue-500 animate-spin' },
partial: { icon: AlertCircle, color: 'text-amber-500' }
};
const formatBytes = (bytes) => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
export const BackupHistory = () => {
const { t } = useTranslation();
const [expandedRows, setExpandedRows] = useState(new Set());
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [currentPage, setCurrentPage] = useState(1);
// Locale-aware formatters that respect admin's general_date_format +
// general_time_format settings. See useLocalizedDate.ts for the full
// contract; formatTime gives "HH:mm" (24h) or "h:mm a" (12h) based on
// the setting, format(date) honors general_date_format, and
// formatDistanceToNow returns "2 minutes ago" in the admin's i18n locale.
const { format, formatTime, formatDistanceToNow } = useLocalizedDate();
// Fetch backup history
const { data, isLoading, refetch } = useQuery({
queryKey: ['backup-history', currentPage, searchTerm, filterStatus],
queryFn: async () => {
const params = new URLSearchParams({
page: currentPage,
limit: 20,
...(searchTerm && { search: searchTerm }),
...(filterStatus !== 'all' && { status: filterStatus })
});
const response = await api.get(`/admin/backup/status?${params}`);
return response.data;
}
});
// Delete backup mutation
const deleteMutation = useMutationWithToast({
mutationFn: async (backupId) => {
const response = await api.delete(`/admin/backup/runs/${backupId}`);
return response.data;
},
successMessage: 'Backup deleted successfully',
invalidateKeys: [['backup-history']],
errorMessage: 'Failed to delete backup'
});
const toggleRowExpansion = (id) => {
const newExpanded = new Set(expandedRows);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
setExpandedRows(newExpanded);
};
const handleDelete = (backup) => {
if (window.confirm(`Are you sure you want to delete this backup from ${format(new Date(backup.created_at))}?`)) {
deleteMutation.mutate(backup.id);
}
};
if (isLoading) {
return <Loading />;
}
const backups = data?.recentBackups || [];
const pagination = data?.pagination || {};
return (
<div className="space-y-6">
{/* Search and Filters */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
<Input
type="text"
placeholder={t('backup.history.searchPlaceholder')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="flex gap-2">
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md focus:outline-none focus:ring-primary focus:border-primary"
>
<option value="all">All Status</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="running">Running</option>
<option value="partial">Partial</option>
</select>
<Button
onClick={() => refetch()}
variant="secondary"
size="sm"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
{/* Backup History Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('backup.history.columns.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('backup.history.columns.dateTime')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('backup.history.columns.type')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('backup.history.columns.size')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('backup.history.columns.duration')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{t('backup.history.columns.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
{backups.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-neutral-500 dark:text-neutral-400">
<FileArchive className="h-12 w-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" />
<p className="text-lg font-medium text-neutral-900 dark:text-neutral-100">No backups found</p>
<p className="text-sm mt-1">Backups will appear here once created</p>
</td>
</tr>
) : (
backups.map((backup) => {
const StatusIcon = statusIcons[backup.status]?.icon || AlertCircle;
const statusColor = statusIcons[backup.status]?.color || 'text-gray-500';
const isExpanded = expandedRows.has(backup.id);
const stats = backup.statistics || {};
return (
<React.Fragment key={backup.id}>
<tr className="hover:bg-neutral-50 dark:hover:bg-neutral-700/50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<StatusIcon className={`h-5 w-5 ${statusColor}`} />
<span className="ml-2 text-sm font-medium text-neutral-900 dark:text-neutral-100 capitalize">
{backup.status}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div>
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{format(new Date(backup.created_at))}
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{formatTime(new Date(backup.created_at))} {formatDistanceToNow(new Date(backup.created_at), { addSuffix: true })}
</p>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300 capitalize">
{backup.backup_type || 'Manual'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<p className="text-sm text-neutral-900 dark:text-neutral-100">
{formatBytes(stats.total_size || 0)}
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{stats.files_processed || 0} {t('backup.dashboard.stats.files')}
</p>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-900 dark:text-neutral-100">
{backup.duration_seconds
? `${Math.round(backup.duration_seconds / 60)}m ${backup.duration_seconds % 60}s`
: '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end space-x-2">
<button
onClick={() => toggleRowExpansion(backup.id)}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
title={t('backup.actions.view')}
>
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</button>
{backup.manifest_path && (
<button
onClick={() => window.open(`/api/admin/backup/download/${backup.id}`, '_blank')}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
title={t('backup.actions.download')}
>
<Download size={20} />
</button>
)}
<button
onClick={() => handleDelete(backup)}
className="text-neutral-400 hover:text-red-600"
title={t('backup.actions.delete')}
disabled={deleteMutation.isLoading}
>
<Trash2 size={20} />
</button>
</div>
</td>
</tr>
{/* Expanded Details Row */}
{isExpanded && (
<tr>
<td colSpan={6} className="px-6 py-4 bg-neutral-50 dark:bg-neutral-700/50">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Backup Details */}
<div className="space-y-2">
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.history.details.backupDetails')}</h4>
<div className="text-sm space-y-1">
<div className="flex justify-between">
<span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.destination')}:</span>
<span className="text-neutral-900 dark:text-neutral-100">{backup.destination_type || 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.started')}:</span>
<span className="text-neutral-900 dark:text-neutral-100">{formatTime(new Date(backup.created_at))}</span>
</div>
{backup.completed_at && (
<div className="flex justify-between">
<span className="text-neutral-500 dark:text-neutral-400">{t('backup.history.details.completed')}:</span>
<span className="text-neutral-900 dark:text-neutral-100">{formatTime(new Date(backup.completed_at))}</span>
</div>
)}
</div>
</div>
{/* Content Backed Up
Two render paths depending on what the backend
provided:
- NEW: per_path map { "events/active": {count, size}, ... }
from Stage B's walker. One row per path,
ordered by display_order.
- LEGACY: fall back to Photos + Archives +
"Other" bucket so the arithmetic still adds
up when restoring a backup taken before this
change shipped. */}
<div className="space-y-2">
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.history.details.contentBackedUp')}</h4>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Database className={`h-4 w-4 ${stats.database_backed_up ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.database')}</span>
</div>
{(() => {
// Per-path breakdown when present
const perPath = stats.per_path || stats.perPath;
if (perPath && Object.keys(perPath).length > 0) {
const formatSize = (bytes) => {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
// Sort by path string so the order is stable across renders;
// backend uses backup_paths.display_order to drive the walker
// but doesn't carry order into per_path map — alphabetic is
// fine for the display.
const entries = Object.entries(perPath).sort(([a], [b]) => a.localeCompare(b));
return (
<>
{entries.map(([pathKey, info]) => (
<div key={pathKey} className="flex items-center space-x-2">
<FileArchive className={`h-4 w-4 ${info.count > 0 ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
<span className="text-sm text-neutral-700 dark:text-neutral-300 font-mono">
{pathKey}
</span>
<span className="text-sm text-neutral-500 dark:text-neutral-400 ml-auto">
{info.count} {info.size ? `(${formatSize(info.size)})` : ''}
</span>
</div>
))}
<div className="flex items-center space-x-2 pt-1 border-t border-neutral-200 dark:border-neutral-700">
<span className="text-xs uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
{t('backup.history.details.totalFiles', 'Total files')}: {stats.files_processed || 0}
</span>
</div>
</>
);
}
// LEGACY rendering for backups taken before
// per_path was emitted.
const total = Number(stats.files_processed) || 0;
const accounted =
(Number(stats.photos_backed_up) || 0)
+ (Number(stats.archives_backed_up) || 0);
const other = Math.max(total - accounted, 0);
return (
<>
<div className="flex items-center space-x-2">
<Image className={`h-4 w-4 ${stats.photos_backed_up > 0 ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
Photos ({stats.photos_backed_up || 0} of {stats.total_photos || 0})
</span>
</div>
<div className="flex items-center space-x-2">
<FileArchive className={`h-4 w-4 ${stats.archives_backed_up > 0 ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
Archives ({stats.archives_backed_up || 0})
</span>
</div>
<div className="flex items-center space-x-2">
<FileArchive className={`h-4 w-4 ${other > 0 ? 'text-green-500' : 'text-neutral-300 dark:text-neutral-600'}`} />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{t('backup.history.details.otherFiles', 'Business documents & other')} ({other})
</span>
</div>
<div className="flex items-center space-x-2 pt-1 border-t border-neutral-200 dark:border-neutral-700">
<span className="text-xs uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
{t('backup.history.details.totalFiles', 'Total files')}: {total}
</span>
</div>
</>
);
})()}
</div>
</div>
{/* Error Information */}
{backup.error_message && (
<div className="space-y-2">
<h4 className="font-medium text-red-900 dark:text-red-200">{t('backup.history.details.errorDetails')}</h4>
<p className="text-sm text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/30 p-2 rounded">
{backup.error_message}
</p>
</div>
)}
{/* Manifest Path */}
{backup.manifest_path && (
<div className="space-y-2">
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.history.details.manifest')}</h4>
<p className="text-sm text-neutral-600 dark:text-neutral-400 font-mono break-all">
{backup.manifest_path}
</p>
</div>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
{pagination.pages > 1 && (
<div className="bg-white dark:bg-neutral-800 px-4 py-3 border-t border-neutral-200 dark:border-neutral-700 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex-1 flex justify-between sm:hidden">
<Button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
variant="secondary"
size="sm"
>
{t('backup.history.pagination.previous')}
</Button>
<Button
onClick={() => setCurrentPage(p => Math.min(pagination.pages, p + 1))}
disabled={currentPage === pagination.pages}
variant="secondary"
size="sm"
>
{t('backup.history.pagination.next')}
</Button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-neutral-700 dark:text-neutral-300">
{t('backup.history.pagination.showing', {
from: (currentPage - 1) * pagination.limit + 1,
to: Math.min(currentPage * pagination.limit, pagination.total),
total: pagination.total
})}
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm font-medium text-neutral-500 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('backup.history.pagination.previous')}
</button>
{[...Array(Math.min(5, pagination.pages))].map((_, i) => {
const pageNum = i + 1;
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
currentPage === pageNum
? 'z-10 bg-accent-dark/15 border-primary text-primary'
: 'bg-white dark:bg-neutral-800 border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700'
}`}
>
{pageNum}
</button>
);
})}
<button
onClick={() => setCurrentPage(p => Math.min(pagination.pages, p + 1))}
disabled={currentPage === pagination.pages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm font-medium text-neutral-500 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t('backup.history.pagination.next')}
</button>
</nav>
</div>
</div>
</div>
</div>
)}
</Card>
</div>
);
};
@@ -0,0 +1,287 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ShieldCheck,
ShieldAlert,
FileX,
Hash,
HelpCircle,
Play,
Loader2,
} from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
// Locale-aware formatters per [[feedback_respect_general_format_settings]].
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Card, Button } from '../common';
import { adminService, BackupIntegrityReport } from '../../services/admin.service';
/**
* BackupIntegrityCard — on-demand verifier for CRM document artefacts.
*
* Walks every `*_path` column on quotes / contracts / invoices and
* confirms (a) the referenced file exists on disk, (b) where a SHA-256
* is stored, the file's bytes hash to the expected value. Surfaces
* three failure buckets:
*
* - missing — `*_path` set, file not on disk (broken FK)
* - hashMismatches — file exists but bytes don't match the stored hash
* - existsButNoHash — verified by existence only; weaker evidence
*
* Designed to be portable. Currently embedded as a tab on
* `BackupManagement.tsx`; when the System Health page (backlog item)
* lands, this same component can be lifted there without changes.
*/
export const BackupIntegrityCard: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime } = useLocalizedDate();
const [report, setReport] = useState<BackupIntegrityReport | null>(null);
const [expanded, setExpanded] = useState<'missing' | 'hashMismatches' | null>(null);
const runCheck = useMutation({
mutationFn: () => adminService.getBackupIntegrity(),
onSuccess: (data) => {
setReport(data);
// Auto-expand whichever failure bucket has entries, prioritising
// the more severe one (missing > hashMismatches).
if (data.summary.missingFiles > 0) setExpanded('missing');
else if (data.summary.hashMismatches > 0) setExpanded('hashMismatches');
else setExpanded(null);
},
});
const summary = report?.summary;
const isHealthy = report
&& summary
&& summary.missingFiles === 0
&& summary.hashMismatches === 0;
return (
<Card className="p-6">
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
{isHealthy ? (
<ShieldCheck className="w-5 h-5 text-green-600 dark:text-green-400" />
) : report ? (
<ShieldAlert className="w-5 h-5 text-red-600 dark:text-red-400" />
) : (
<ShieldCheck className="w-5 h-5 text-neutral-400" />
)}
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.integrity.title', 'Document integrity')}
</h3>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 max-w-2xl">
{t(
'backup.integrity.description',
'Verifies every CRM document (quote / contract / invoice / signature) referenced from the database actually exists on disk and — where a hash is stored — its bytes still match. Read-only, on-demand.',
)}
</p>
</div>
<Button
variant="primary"
onClick={() => runCheck.mutate()}
disabled={runCheck.isPending}
leftIcon={
runCheck.isPending
? <Loader2 className="w-4 h-4 animate-spin" />
: <Play className="w-4 h-4" />
}
>
{runCheck.isPending
? t('backup.integrity.running', 'Checking…')
: t('backup.integrity.runNow', 'Run check now')}
</Button>
</div>
{runCheck.isError && (
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 text-sm text-red-700 dark:text-red-300">
{t('backup.integrity.error', 'Check failed: {{message}}', {
message: (runCheck.error as Error)?.message ?? 'unknown error',
})}
</div>
)}
{report && summary && (
<>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 mb-4">
<Counter
label={t('backup.integrity.summary.total', 'Total')}
value={summary.totalRows}
tone="neutral"
/>
<Counter
label={t('backup.integrity.summary.verifiedOk', 'Hash-verified')}
value={summary.verifiedOk}
tone="green"
icon={<Hash className="w-4 h-4" />}
/>
<Counter
label={t('backup.integrity.summary.existsButNoHash', 'Exists only')}
value={summary.existsButNoHash}
tone="amber"
icon={<HelpCircle className="w-4 h-4" />}
tooltip={t(
'backup.integrity.summary.existsButNoHashHint',
'File found, but no SHA-256 is stored for it (quote/invoice PDFs, signature drawings). Existence-only is weaker evidence in a dispute.',
)}
/>
<Counter
label={t('backup.integrity.summary.missingFiles', 'Missing')}
value={summary.missingFiles}
tone={summary.missingFiles > 0 ? 'red' : 'neutral'}
icon={<FileX className="w-4 h-4" />}
onClick={summary.missingFiles > 0
? () => setExpanded(expanded === 'missing' ? null : 'missing')
: undefined}
/>
<Counter
label={t('backup.integrity.summary.hashMismatches', 'Hash mismatches')}
value={summary.hashMismatches}
tone={summary.hashMismatches > 0 ? 'red' : 'neutral'}
icon={<ShieldAlert className="w-4 h-4" />}
onClick={summary.hashMismatches > 0
? () => setExpanded(expanded === 'hashMismatches' ? null : 'hashMismatches')
: undefined}
/>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
{t('backup.integrity.scannedAt', 'Last checked: {{when}}', {
when: formatDateTime(new Date(report.scannedAt)),
})}
</p>
{expanded === 'missing' && summary.missingFiles > 0 && (
<ResultTable
title={t('backup.integrity.missing.heading', 'Missing files')}
caption={t(
'backup.integrity.missing.caption',
'These rows reference a path that does not exist on disk. After a restore, this means the artefact was lost from the backup chain; for fresh installs, it usually means the file was deleted manually.',
)}
rows={report.missing.map((m) => ({
table: m.table,
rowId: m.rowId,
column: m.column,
detail: m.expectedPath,
}))}
/>
)}
{expanded === 'hashMismatches' && summary.hashMismatches > 0 && (
<ResultTable
title={t('backup.integrity.hashMismatches.heading', 'Hash mismatches')}
caption={t(
'backup.integrity.hashMismatches.caption',
'The file exists but its current bytes do not match the SHA-256 captured at issue / sign time. Indicates tampering, bit-rot, or a restore that pulled in a different copy than the original.',
)}
rows={report.hashMismatches.map((m) => ({
table: m.table,
rowId: m.rowId,
column: m.column,
detail: `${m.expectedPath} (expected ${m.expectedSha.slice(0, 12)}…, got ${m.actualSha.slice(0, 12)}…)`,
}))}
/>
)}
</>
)}
{!report && !runCheck.isPending && (
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
{t(
'backup.integrity.emptyState',
'No check has been run yet in this session. Click "Run check now" to scan the document estate.',
)}
</p>
)}
</Card>
);
};
type Tone = 'neutral' | 'green' | 'amber' | 'red';
const TONE_CLASSES: Record<Tone, string> = {
neutral: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-200',
green: 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300',
amber: 'bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300',
red: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300',
};
const Counter: React.FC<{
label: string;
value: number;
tone: Tone;
icon?: React.ReactNode;
tooltip?: string;
onClick?: () => void;
}> = ({ label, value, tone, icon, tooltip, onClick }) => {
const interactive = Boolean(onClick);
const classes = `rounded-lg p-3 ${TONE_CLASSES[tone]} ${
interactive ? 'cursor-pointer hover:ring-2 hover:ring-offset-1 hover:ring-current/30 transition' : ''
}`;
return (
<div
className={classes}
onClick={onClick}
title={tooltip}
role={interactive ? 'button' : undefined}
tabIndex={interactive ? 0 : undefined}
>
<div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide opacity-80">
{icon}
<span>{label}</span>
</div>
<div className="text-2xl font-semibold mt-1 tabular-nums">{value}</div>
</div>
);
};
const ResultTable: React.FC<{
title: string;
caption: string;
rows: Array<{ table: string; rowId: number; column: string; detail: string }>;
}> = ({ title, caption, rows }) => {
const { t } = useTranslation();
return (
<div className="mt-4 border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden">
<div className="p-3 bg-neutral-50 dark:bg-neutral-800/50 border-b border-neutral-200 dark:border-neutral-700">
<h4 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{title}</h4>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">{caption}</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800/30">
<tr className="text-left text-xs uppercase tracking-wide text-neutral-500 dark:text-neutral-400">
<th className="px-3 py-2">{t('backup.integrity.results.table', 'Table')}</th>
<th className="px-3 py-2">{t('backup.integrity.results.rowId', 'Row id')}</th>
<th className="px-3 py-2">{t('backup.integrity.results.column', 'Column')}</th>
<th className="px-3 py-2">{t('backup.integrity.results.detail', 'Detail')}</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => (
<tr
key={`${r.table}-${r.rowId}-${r.column}-${i}`}
className="border-t border-neutral-200 dark:border-neutral-700"
>
<td className="px-3 py-2 font-mono text-xs text-neutral-700 dark:text-neutral-300">
{r.table}
</td>
<td className="px-3 py-2 tabular-nums text-neutral-700 dark:text-neutral-300">
{r.rowId}
</td>
<td className="px-3 py-2 font-mono text-xs text-neutral-700 dark:text-neutral-300">
{r.column}
</td>
<td className="px-3 py-2 font-mono text-xs text-neutral-700 dark:text-neutral-300 break-all">
{r.detail}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
@@ -0,0 +1,92 @@
import React from 'react';
import { Archive, AlertTriangle, X } from 'lucide-react';
import { Button, Card } from '../common';
import type { Event } from '../../types';
interface BulkArchiveModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
selectedEvents: Event[];
isLoading?: boolean;
}
export const BulkArchiveModal: React.FC<BulkArchiveModalProps> = ({
isOpen,
onClose,
onConfirm,
selectedEvents,
isLoading = false,
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">Confirm Bulk Archive</h2>
<button
onClick={onClose}
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
disabled={isLoading}
>
<X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
</button>
</div>
<div className="mb-6">
<div className="flex items-start gap-3 mb-4">
<AlertTriangle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-neutral-700">
<p className="mb-2">
You are about to archive <strong>{selectedEvents.length} event{selectedEvents.length > 1 ? 's' : ''}</strong>.
This action will:
</p>
<ul className="list-disc list-inside space-y-1 text-neutral-600">
<li>Create a ZIP archive of all photos for each event</li>
<li>Make the galleries inaccessible to guests</li>
<li>Remove the events from active listings</li>
<li>Free up storage space by compressing photos</li>
</ul>
</div>
</div>
<div className="border border-neutral-200 rounded-lg max-h-48 overflow-y-auto">
<div className="p-3">
<h3 className="text-sm font-medium text-neutral-700 mb-2">Events to be archived:</h3>
<ul className="space-y-1">
{selectedEvents.map((event) => (
<li key={event.id} className="text-sm text-neutral-600">
{event.event_name} ({event.event_type})
</li>
))}
</ul>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="primary"
onClick={onConfirm}
isLoading={isLoading}
leftIcon={<Archive className="w-4 h-4" />}
>
Archive {selectedEvents.length} Event{selectedEvents.length > 1 ? 's' : ''}
</Button>
</div>
</div>
</Card>
</div>
);
};
BulkArchiveModal.displayName = 'BulkArchiveModal';
@@ -0,0 +1,102 @@
import React, { useState } from 'react';
import { FolderOpen, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../common';
interface CategoryOption {
id: number;
name: string;
}
interface BulkCategoryModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (categoryId: number | null) => Promise<void>;
photoCount: number;
categories: CategoryOption[];
isLoading: boolean;
}
export const BulkCategoryModal: React.FC<BulkCategoryModalProps> = ({
isOpen,
onClose,
onConfirm,
photoCount,
categories,
isLoading,
}) => {
const { t } = useTranslation();
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
if (!isOpen) return null;
const handleConfirm = async () => {
await onConfirm(selectedCategoryId);
};
const handleClose = () => {
setSelectedCategoryId(null);
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{t('photos.moveToCategory', 'Move {{count}} photos to category', { count: photoCount })}
</h2>
<button
onClick={handleClose}
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
disabled={isLoading}
>
<X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
</button>
</div>
<div className="mb-6">
<label htmlFor="category-select" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('photos.selectCategory', 'Select category')}
</label>
<select
id="category-select"
value={selectedCategoryId ?? ''}
onChange={(e) => setSelectedCategoryId(e.target.value === '' ? null : Number(e.target.value))}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
disabled={isLoading}
>
<option value="">{t('photos.uncategorized', 'Uncategorized')}</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={handleClose}
disabled={isLoading}
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={handleConfirm}
isLoading={isLoading}
leftIcon={<FolderOpen className="w-4 h-4" />}
>
{t('photos.movePhotos', 'Move Photos')}
</Button>
</div>
</div>
</Card>
</div>
);
};
BulkCategoryModal.displayName = 'BulkCategoryModal';
@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import { Trash2, AlertTriangle, X, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card, Input } from '../common';
import type { Event } from '../../types';
// The exact literal a user must type to confirm bulk deletion. Kept English
// across locales (matching GitHub's repo-deletion pattern) so it can never
// be interpreted as autofillable text or be triggered by passkey/Windows
// Hello flows on a password field — see issue #417.
const CONFIRM_LITERAL = 'DELETE';
interface BulkDeleteModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => Promise<void>;
selectedEvents: Event[];
isLoading?: boolean;
}
export const BulkDeleteModal: React.FC<BulkDeleteModalProps> = ({
isOpen,
onClose,
onConfirm,
selectedEvents,
isLoading = false,
}) => {
const { t } = useTranslation();
const [confirmText, setConfirmText] = useState('');
if (!isOpen) return null;
const count = selectedEvents.length;
const confirmed = confirmText === CONFIRM_LITERAL;
const handleSubmit = async () => {
if (!confirmed || isLoading) return;
await onConfirm();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-red-700 dark:text-red-400">
{t('events.bulkDelete.title', 'Permanently delete {{count}} events?', { count })}
</h2>
<button
onClick={onClose}
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
disabled={isLoading}
aria-label={t('common.close', 'Close')}
>
<X className="w-5 h-5 text-neutral-500 dark:text-neutral-400" />
</button>
</div>
{isLoading ? (
<div className="py-8 text-center">
<Loader2 className="w-8 h-8 mx-auto mb-3 animate-spin text-red-600 dark:text-red-400" />
<p className="text-sm text-neutral-700 dark:text-neutral-300">
{t('events.bulkDelete.processing', 'Deleting {{count}} events. This may take a few minutes — please don\'t close this window.', { count })}
</p>
</div>
) : (
<>
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-800 dark:text-red-200">
{t('events.bulkDelete.warning', 'This will permanently delete the selected events, all their photos, archives, and audit logs. This action cannot be undone.')}
</p>
</div>
<div className="border border-neutral-200 dark:border-neutral-700 rounded-lg max-h-40 overflow-y-auto mb-4">
<ul className="p-3 space-y-1">
{selectedEvents.map((event) => (
<li key={event.id} className="text-sm text-neutral-700 dark:text-neutral-300">
{event.event_name} ({event.event_type})
</li>
))}
</ul>
</div>
<div className="mb-6">
<Input
type="text"
label={t(
'events.bulkDelete.confirmLabel',
'Type {{literal}} to confirm',
{ literal: CONFIRM_LITERAL }
)}
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={CONFIRM_LITERAL}
helperText={t(
'events.bulkDelete.confirmHelp',
'A typed confirmation prevents accidental deletions and isn\'t affected by browser autofill or passkey shortcuts.'
)}
autoFocus
autoComplete="off"
spellCheck={false}
/>
</div>
<div className="flex justify-end gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={isLoading}
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={!confirmed || isLoading}
leftIcon={<Trash2 className="w-4 h-4" />}
className="bg-red-600 hover:bg-red-700 focus:ring-red-500 text-white"
>
{t('events.bulkDelete.submit', 'Delete {{count}} events', { count })}
</Button>
</div>
</>
)}
</div>
</Card>
</div>
);
};
BulkDeleteModal.displayName = 'BulkDeleteModal';
+563
View File
@@ -0,0 +1,563 @@
import React, { useState, useCallback } from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import HardBreak from '@tiptap/extension-hard-break';
import Placeholder from '@tiptap/extension-placeholder';
import CharacterCount from '@tiptap/extension-character-count';
import TextAlign from '@tiptap/extension-text-align';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { lowlight } from 'lowlight';
import {
Bold,
Italic,
List,
ListOrdered,
Link as LinkIcon,
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
Quote,
Code,
Code2,
Minus,
Undo,
Redo,
RemoveFormatting,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
Eye,
Edit3,
Columns,
Maximize2,
HelpCircle,
Save
} from 'lucide-react';
import { Button } from '../common';
import DOMPurify from 'dompurify';
import '../../styles/prose-overrides.css';
interface CMSEditorProps {
content: string;
onChange: (content: string) => void;
onSave?: () => void;
isSaving?: boolean;
}
type ViewMode = 'edit' | 'preview' | 'split';
export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave, isSaving }) => {
const [linkUrl, setLinkUrl] = useState('');
const [showLinkDialog, setShowLinkDialog] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('edit');
const [isFullscreen, setIsFullscreen] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [wordCount, setWordCount] = useState(0);
const [charCount, setCharCount] = useState(0);
const editor = useEditor({
extensions: [
StarterKit.configure({
hardBreak: false, // We'll use the separate HardBreak extension
codeBlock: false, // We'll use CodeBlockLowlight instead
}),
HardBreak.configure({
keepMarks: true,
HTMLAttributes: {
class: 'hard-break',
},
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer',
},
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
alignments: ['left', 'center', 'right', 'justify'],
defaultAlignment: 'left',
}),
CodeBlockLowlight.configure({
lowlight,
HTMLAttributes: {
class: 'hljs',
},
}),
Placeholder.configure({
placeholder: 'Start typing your content here...',
}),
CharacterCount.configure({
limit: null,
}),
],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
updateCounts(editor);
},
onCreate: ({ editor }) => {
updateCounts(editor);
},
});
const updateCounts = useCallback((editorInstance: Editor) => {
const textContent = editorInstance.state.doc.textContent;
setCharCount(editorInstance.storage.characterCount.characters());
const words = textContent.trim().split(/\s+/).filter((word: string) => word.length > 0);
setWordCount(words.length);
}, []);
// Update editor content when prop changes
React.useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content);
}
}, [content, editor]);
if (!editor) {
return null;
}
const addLink = () => {
if (linkUrl) {
editor.chain().focus().setLink({ href: linkUrl }).run();
setLinkUrl('');
setShowLinkDialog(false);
}
};
const MenuButton: React.FC<{
onClick: () => void;
active?: boolean;
children: React.ReactNode;
title: string;
disabled?: boolean;
}> = ({ onClick, active, children, title, disabled }) => (
<button
onClick={onClick}
disabled={disabled}
className={`p-2 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors ${
active
? 'bg-accent-dark/15 text-accent-dark'
: 'text-neutral-700 dark:text-neutral-200'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
title={title}
type="button"
>
{children}
</button>
);
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
const getPreviewContent = () => {
return DOMPurify.sanitize(editor?.getHTML() || '', {
ALLOWED_TAGS: [
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'blockquote', 'a', 'em', 'strong',
'code', 'pre', 'hr', 'div', 'span'
],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'],
ALLOW_DATA_ATTR: false,
KEEP_CONTENT: true,
ADD_TAGS: ['br'], // Explicitly allow br tags
ADD_ATTR: ['style'], // Allow style for text alignment
});
};
// Reusable view-mode chip — three states (edit/preview/split). Shared
// styling block extracted as a const so the dark variants stay in sync.
const viewModeChipClass = (mode: typeof viewMode) =>
`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === mode
? 'bg-accent-dark/15 text-accent-dark'
: 'text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700'
}`;
return (
<div className={`relative ${isFullscreen ? 'fixed inset-0 z-50 bg-white dark:bg-neutral-900' : ''}`}>
<div className="border border-neutral-300 dark:border-neutral-700 rounded-lg overflow-hidden h-full flex flex-col bg-white dark:bg-neutral-900">
{/* Top Toolbar */}
<div className="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800">
{/* View Mode Controls */}
<div className="flex items-center justify-between p-2 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex items-center gap-2">
<button onClick={() => setViewMode('edit')} className={viewModeChipClass('edit')}>
<Edit3 className="w-4 h-4 inline-block mr-1" />
Edit
</button>
<button onClick={() => setViewMode('preview')} className={viewModeChipClass('preview')}>
<Eye className="w-4 h-4 inline-block mr-1" />
Preview
</button>
<button onClick={() => setViewMode('split')} className={viewModeChipClass('split')}>
<Columns className="w-4 h-4 inline-block mr-1" />
Split
</button>
</div>
<div className="flex items-center gap-2">
{onSave && (
<Button
size="sm"
onClick={onSave}
isLoading={isSaving}
leftIcon={<Save className="w-4 h-4" />}
>
Save
</Button>
)}
<MenuButton
onClick={() => setShowHelp(true)}
title="Help & Keyboard Shortcuts"
>
<HelpCircle className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={toggleFullscreen}
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
active={isFullscreen}
>
<Maximize2 className="w-4 h-4" />
</MenuButton>
</div>
</div>
{/* Formatting Toolbar */}
{viewMode !== 'preview' && (
<div className="flex items-center gap-1 p-2 flex-wrap">
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
active={editor.isActive('heading', { level: 1 })}
title="Heading 1 (Ctrl+Alt+1)"
>
<Heading1 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
title="Heading 2 (Ctrl+Alt+2)"
>
<Heading2 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor.isActive('heading', { level: 3 })}
title="Heading 3 (Ctrl+Alt+3)"
>
<Heading3 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
active={editor.isActive('heading', { level: 4 })}
title="Heading 4 (Ctrl+Alt+4)"
>
<Heading4 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 5 }).run()}
active={editor.isActive('heading', { level: 5 })}
title="Heading 5 (Ctrl+Alt+5)"
>
<Heading5 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 6 }).run()}
active={editor.isActive('heading', { level: 6 })}
title="Heading 6 (Ctrl+Alt+6)"
>
<Heading6 className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
title="Bold (Ctrl+B)"
>
<Bold className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
title="Italic (Ctrl+I)"
>
<Italic className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleCode().run()}
active={editor.isActive('code')}
title="Inline Code (Ctrl+E)"
>
<Code className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
active={editor.isActive('codeBlock')}
title="Code Block (Ctrl+Alt+C)"
>
<Code2 className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
title="Bullet List (Ctrl+Shift+8)"
>
<List className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
title="Numbered List (Ctrl+Shift+9)"
>
<ListOrdered className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive('blockquote')}
title="Blockquote (Ctrl+Shift+B)"
>
<Quote className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => setShowLinkDialog(true)}
active={editor.isActive('link')}
title="Add Link (Ctrl+K)"
>
<LinkIcon className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal Rule"
>
<Minus className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('left').run()}
active={editor.isActive({ textAlign: 'left' })}
title="Align Left"
>
<AlignLeft className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('center').run()}
active={editor.isActive({ textAlign: 'center' })}
title="Align Center"
>
<AlignCenter className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('right').run()}
active={editor.isActive({ textAlign: 'right' })}
title="Align Right"
>
<AlignRight className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
active={editor.isActive({ textAlign: 'justify' })}
title="Justify"
>
<AlignJustify className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title="Clear Formatting"
>
<RemoveFormatting className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo (Ctrl+Z)"
>
<Undo className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo (Ctrl+Y)"
>
<Redo className="w-4 h-4" />
</MenuButton>
</div>
)}
</div>
{/* Link Dialog */}
{showLinkDialog && (
<div className="p-3 bg-accent-dark/15 border-b border-accent-dark/30 flex items-center gap-2">
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addLink()}
placeholder="Enter URL..."
className="flex-1 px-3 py-1 border border-accent-dark/30 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button size="sm" onClick={addLink}>Add Link</Button>
<Button size="sm" variant="outline" onClick={() => {
setShowLinkDialog(false);
setLinkUrl('');
}}>
Cancel
</Button>
</div>
)}
{/* Editor Content Area */}
<div className="flex-1 flex overflow-hidden">
{/* Editor — prose-invert in dark mode flips the prose typography
palette without us having to override every prose-* class. */}
{viewMode !== 'preview' && (
<div className={`${viewMode === 'split' ? 'w-1/2 border-r border-neutral-200 dark:border-neutral-700' : 'w-full'} overflow-auto bg-white dark:bg-neutral-900`}>
<EditorContent
editor={editor}
className="min-h-[400px] p-4 prose prose-neutral dark:prose-invert max-w-none focus:outline-none [&_.ProseMirror]:min-h-[400px] [&_.ProseMirror]:outline-none [&_.ProseMirror]:text-neutral-900 dark:[&_.ProseMirror]:text-neutral-100 [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-400 dark:[&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-500 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_br.hard-break]:display-block [&_.ProseMirror_br.hard-break]:content-[''] [&_.ProseMirror_br.hard-break]:margin-[0.5em_0] [&_.ProseMirror_pre]:bg-neutral-100 dark:[&_.ProseMirror_pre]:bg-neutral-800 [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_code]:bg-neutral-100 dark:[&_.ProseMirror_code]:bg-neutral-800 [&_.ProseMirror_code]:rounded [&_.ProseMirror_code]:px-1 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:text-sm [&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0"
/>
</div>
)}
{/* Preview */}
{viewMode !== 'edit' && (
<div className={`${viewMode === 'split' ? 'w-1/2' : 'w-full'} overflow-auto bg-neutral-50 dark:bg-neutral-800 p-4`}>
<div
className="prose prose-neutral dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: getPreviewContent() }}
/>
</div>
)}
</div>
{/* Status Bar */}
<div className="flex items-center justify-between px-4 py-2 bg-neutral-50 dark:bg-neutral-800 border-t border-neutral-200 dark:border-neutral-700 text-sm text-neutral-600 dark:text-neutral-300">
<div className="flex items-center gap-4">
<span>{wordCount} words</span>
<span>{charCount} characters</span>
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
Press Shift+Enter for line break, Enter for new paragraph
</div>
</div>
</div>
{/* Help Modal */}
{showHelp && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 rounded-lg max-w-2xl w-full max-h-[80vh] overflow-auto">
<div className="p-6">
<h2 className="text-xl font-semibold mb-4">Editor Help & Keyboard Shortcuts</h2>
<div className="space-y-4">
<div>
<h3 className="font-semibold mb-2">Text Formatting</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div><kbd>Ctrl+B</kbd> - Bold</div>
<div><kbd>Ctrl+I</kbd> - Italic</div>
<div><kbd>Ctrl+E</kbd> - Inline code</div>
<div><kbd>Ctrl+K</kbd> - Add link</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-2">Headings</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div><kbd>Ctrl+Alt+1</kbd> - Heading 1</div>
<div><kbd>Ctrl+Alt+2</kbd> - Heading 2</div>
<div><kbd>Ctrl+Alt+3</kbd> - Heading 3</div>
<div><kbd>Ctrl+Alt+4</kbd> - Heading 4</div>
<div><kbd>Ctrl+Alt+5</kbd> - Heading 5</div>
<div><kbd>Ctrl+Alt+6</kbd> - Heading 6</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-2">Lists & Blocks</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div><kbd>Ctrl+Shift+8</kbd> - Bullet list</div>
<div><kbd>Ctrl+Shift+9</kbd> - Numbered list</div>
<div><kbd>Ctrl+Shift+B</kbd> - Blockquote</div>
<div><kbd>Ctrl+Alt+C</kbd> - Code block</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-2">Text Alignment</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>Click alignment buttons in toolbar</div>
<div>Works on paragraphs and headings</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-2">Line Breaks</h3>
<div className="space-y-1 text-sm">
<div><kbd>Enter</kbd> - New paragraph</div>
<div><kbd>Shift+Enter</kbd> - Line break (preserves formatting)</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-2">Navigation</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div><kbd>Ctrl+Z</kbd> - Undo</div>
<div><kbd>Ctrl+Y</kbd> - Redo</div>
</div>
</div>
</div>
<div className="mt-6 flex justify-end">
<Button onClick={() => setShowHelp(false)}>Close</Button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
CMSEditor.displayName = 'CMSEditor';
@@ -0,0 +1,227 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { Button } from '../common';
import { useMutationWithToast, useModal } from '../../hooks';
export const CategoryManager: React.FC = () => {
const { t } = useTranslation();
const addingModal = useModal();
const [editingId, setEditingId] = useState<number | null>(null);
const [newCategoryName, setNewCategoryName] = useState('');
const [editingName, setEditingName] = useState('');
// Fetch global categories
const { data: categories = [], isLoading } = useQuery({
queryKey: ['global-categories'],
queryFn: categoriesService.getGlobalCategories,
});
// Create category mutation
const createMutation = useMutationWithToast({
mutationFn: (name: string) =>
categoriesService.createCategory({ name, is_global: true }),
invalidateKeys: [['global-categories']],
successMessage: t('categories.categoryCreatedSuccess'),
onSuccess: () => {
setNewCategoryName('');
addingModal.close();
},
errorMessage: t('categories.failedToCreateCategory'),
});
// Update category mutation
const updateMutation = useMutationWithToast({
mutationFn: ({ id, name }: { id: number; name: string }) =>
categoriesService.updateCategory(id, name),
invalidateKeys: [['global-categories']],
successMessage: t('toast.categoryUpdated'),
onSuccess: () => {
setEditingId(null);
setEditingName('');
},
errorMessage: t('toast.saveError'),
});
// Delete category mutation
const deleteMutation = useMutationWithToast({
mutationFn: categoriesService.deleteCategory,
invalidateKeys: [['global-categories']],
successMessage: t('categories.categoryDeletedSuccess'),
errorMessage: t('categories.failedToDeleteCategory'),
});
const handleCreate = () => {
if (newCategoryName.trim()) {
createMutation.mutate(newCategoryName.trim());
}
};
const handleUpdate = (id: number) => {
if (editingName.trim()) {
updateMutation.mutate({ id, name: editingName.trim() });
}
};
const handleDelete = (category: PhotoCategory) => {
if (window.confirm(t('categories.deleteConfirm', { name: category.name }))) {
deleteMutation.mutate(category.id);
}
};
const startEdit = (category: PhotoCategory) => {
setEditingId(category.id);
setEditingName(category.name);
};
const cancelEdit = () => {
setEditingId(null);
setEditingName('');
};
if (isLoading) {
return (
<div className="flex justify-center items-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-accent" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('categories.title')}</h3>
{!addingModal.isOpen && (
<Button
variant="primary"
size="sm"
onClick={addingModal.open}
leftIcon={<Plus className="w-4 h-4" />}
>
{t('categories.addCategory')}
</Button>
)}
</div>
{/* Add new category form */}
{addingModal.isOpen && (
<div className="flex gap-2 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
placeholder={t('categories.categoryName')}
className="flex-1 px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button
variant="primary"
size="sm"
onClick={handleCreate}
disabled={!newCategoryName.trim() || createMutation.isPending}
>
{createMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
t('common.save')
)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
addingModal.close();
setNewCategoryName('');
}}
>
{t('common.cancel')}
</Button>
</div>
)}
{/* Categories list */}
<div className="space-y-2">
{categories.length === 0 ? (
<p className="text-neutral-500 dark:text-neutral-400 text-center py-8">
{t('categories.noCategoriesYet')}
</p>
) : (
categories.map((category) => (
<div
key={category.id}
className="flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 transition-colors"
>
{editingId === category.id ? (
<div className="flex gap-2 flex-1">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') handleUpdate(category.id);
if (e.key === 'Escape') cancelEdit();
}}
className="flex-1 px-3 py-1 border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button
variant="primary"
size="sm"
onClick={() => handleUpdate(category.id)}
disabled={!editingName.trim() || updateMutation.isPending}
>
{updateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
t('common.save')
)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={cancelEdit}
>
{t('common.cancel')}
</Button>
</div>
) : (
<>
<div>
<p className="font-medium text-neutral-900 dark:text-neutral-100">{category.name}</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">/{category.slug}</p>
</div>
<div className="flex gap-1">
<button
onClick={() => startEdit(category)}
className="p-1.5 text-neutral-600 dark:text-neutral-400 hover:text-accent dark:hover:text-accent hover:bg-accent-dark/15 rounded transition-colors"
title={t('common.edit')}
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(category)}
className="p-1.5 text-neutral-600 dark:text-neutral-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded transition-colors"
title={t('common.delete')}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
</div>
</>
)}
</div>
))
)}
</div>
</div>
);
};
CategoryManager.displayName = 'CategoryManager';
@@ -0,0 +1,207 @@
/**
* Chart of accounts manager (Layer A) — embedded in Settings → Accounting.
*
* Full CRUD for the Swiss/LI KMU-Kontenrahmen accounts, plus the mappings the
* Treuhänder export relies on: which account each expense category books to and
* the default/system accounts. Sits alongside VatCodesManager so all accounting
* configuration lives in one place.
*
* This data drives the export only — picpeak is not a double-entry ledger.
*
* NOTE: ledgerService.updateSettings is a PARTIAL merge, so this component saves
* ONLY the account keys (SETTING_ACCOUNT_KEYS); the VAT maps are owned by
* VatCodesManager. Scoping each patch keeps the two from reverting each other.
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Plus, Pencil, Trash2, AlertCircle } from 'lucide-react';
import { Button, Card, CardContent, Input, Loading } from '../common';
import {
ledgerService, type LedgerAccount, type AccountType, type LedgerSettings,
} from '../../services/ledger.service';
import { categoryLabel } from '../../services/accounting.service';
import { useMutationWithToast } from '../../hooks';
const ACCOUNT_TYPES: AccountType[] = ['asset', 'liability', 'equity', 'revenue', 'expense'];
const labelCls = 'block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1';
const selectCls = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm';
const SETTING_ACCOUNT_KEYS: (keyof LedgerSettings)[] = [
'ledger_account_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank', 'ledger_account_cash',
'ledger_account_default_revenue', 'ledger_account_default_expense',
'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue',
];
// ── account modal ──────────────────────────────────────────────────────
const AccountModal: React.FC<{ account?: LedgerAccount; onClose: () => void; onDone: () => void }> = ({ account, onClose, onDone }) => {
const { t } = useTranslation();
const isEdit = !!account;
const [number, setNumber] = useState(account?.number ?? '');
const [name, setName] = useState(account?.name ?? '');
const [type, setType] = useState<AccountType>(account?.type ?? 'expense');
const save = useMutationWithToast({
mutationFn: () => isEdit ? ledgerService.updateAccount(account!.id, { number, name, type }) : ledgerService.createAccount({ number, name, type }),
successMessage: t('common.saved', 'Saved.'),
onSuccess: () => onDone(),
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
});
return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4">
<div className="mt-20 w-full max-w-sm rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{isEdit ? t('ledger.account.editTitle', 'Edit account') : t('ledger.account.addTitle', 'Add account')}</h2>
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
</div>
<div className="px-5 py-4 space-y-3">
<div><label className={labelCls}>{t('ledger.account.number', 'Account number')}</label><Input value={number} onChange={(e) => setNumber(e.target.value)} placeholder="6700" /></div>
<div><label className={labelCls}>{t('ledger.account.name', 'Name')}</label><Input value={name} onChange={(e) => setName(e.target.value)} /></div>
<div><label className={labelCls}>{t('ledger.account.type', 'Type')}</label>
<select value={type} onChange={(e) => setType(e.target.value as AccountType)} className={selectCls}>
{ACCOUNT_TYPES.map((tp) => <option key={tp} value={tp}>{t(`ledger.accountType.${tp}`, tp)}</option>)}
</select>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button onClick={() => save.mutate()} disabled={save.isPending || !number || !name}>{save.isPending ? t('common.saving', 'Saving…') : t('common.save', 'Save')}</Button>
</div>
</div>
</div>
);
};
export const ChartOfAccountsManager: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const [accountModal, setAccountModal] = useState<{ account?: LedgerAccount } | null>(null);
const { data: accounts, isLoading: la } = useQuery({ queryKey: ['ledger-accounts'], queryFn: () => ledgerService.listAccounts() });
const { data: mappings, isLoading: lm } = useQuery({ queryKey: ['ledger-mappings'], queryFn: () => ledgerService.getMappings() });
// Local editable copy of the settings (default/system accounts only).
const [settings, setSettings] = useState<LedgerSettings>({});
useEffect(() => { if (mappings?.settings) setSettings(mappings.settings); }, [mappings?.settings]);
const accountOptions = useMemo(() => (accounts ?? []).filter((a) => a.active), [accounts]);
const refetchAll = () => { qc.invalidateQueries({ queryKey: ['ledger-accounts'] }); qc.invalidateQueries({ queryKey: ['ledger-vat-codes'] }); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); };
const delAccount = useMutationWithToast({
mutationFn: (id: number) => ledgerService.deleteAccount(id),
successMessage: t('common.deleted', 'Deleted.'),
onSuccess: () => refetchAll(),
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
});
const setCat = useMutationWithToast({
mutationFn: ({ id, accId }: { id: number; accId: number | null }) => ledgerService.setCategoryAccount(id, accId),
invalidateKeys: [['ledger-mappings']],
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
});
// Save ONLY the account keys — the VAT maps are owned by VatCodesManager and
// updateSettings is a partial merge, so scoping the patch here prevents a
// stale full-settings save from reverting the maps.
const saveSettings = useMutationWithToast({
mutationFn: () => {
const patch: Partial<LedgerSettings> = {};
for (const k of SETTING_ACCOUNT_KEYS) patch[k] = settings[k];
return ledgerService.updateSettings(patch);
},
successMessage: t('ledger.settingsSaved', 'Mappings saved.'),
invalidateKeys: [['ledger-mappings']],
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
});
const setAcctSetting = (key: keyof LedgerSettings, value: string) => setSettings((s) => ({ ...s, [key]: value }));
if (la || lm) return <Loading />;
return (
<div className="space-y-6">
<p className="flex items-start gap-2 text-xs text-neutral-500 dark:text-neutral-400">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-0.5" />
<span>{t('ledger.intro', 'Used only to produce the Treuhänder export — picpeak does not keep double-entry books. The seeded chart + VAT codes follow the Swiss/LI KMU-Kontenrahmen; adjust them to match your Treuhänders setup.')}</span>
</p>
{/* Default + system accounts */}
<Card>
<CardContent className="p-5">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('ledger.defaults.title', 'Default & system accounts')}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{SETTING_ACCOUNT_KEYS.map((key) => (
<div key={key}>
<label className={labelCls}>{t(`ledger.defaults.${key}`, key)}</label>
<select value={settings[key] as string ?? ''} onChange={(e) => setAcctSetting(key, e.target.value)} className={selectCls}>
<option value="">{t('ledger.defaults.none', '— none —')}</option>
{accountOptions.map((a) => <option key={a.id} value={a.number}>{a.number} · {a.name}</option>)}
</select>
</div>
))}
</div>
<div className="mt-4 flex justify-end">
<Button onClick={() => saveSettings.mutate()} disabled={saveSettings.isPending}>{saveSettings.isPending ? t('common.saving', 'Saving…') : t('ledger.saveDefaults', 'Save mappings')}</Button>
</div>
</CardContent>
</Card>
{/* Category → account */}
<Card>
<CardContent className="p-5">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100 mb-3">{t('ledger.categoryMap.title', 'Expense category → account')}</h2>
<div className="space-y-2">
{(mappings?.categories ?? []).map((c) => (
<div key={c.id} className="flex items-center gap-3">
<span className="flex-1 text-sm text-neutral-800 dark:text-neutral-200">{categoryLabel(c as any, t)}</span>
<select value={c.ledger_account_id ?? ''} onChange={(e) => setCat.mutate({ id: c.id, accId: e.target.value ? Number(e.target.value) : null })} className={selectCls} style={{ maxWidth: 320 }}>
<option value="">{t('ledger.defaults.none', '— none —')}</option>
{accountOptions.filter((a) => a.type === 'expense').map((a) => <option key={a.id} value={a.id}>{a.number} · {a.name}</option>)}
</select>
</div>
))}
</div>
</CardContent>
</Card>
{/* Chart of accounts */}
<Card>
<CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{t('ledger.accounts.title', 'Chart of accounts')}</h2>
<Button size="sm" onClick={() => setAccountModal({})}><Plus className="w-4 h-4 mr-1" /> {t('ledger.account.addTitle', 'Add account')}</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-neutral-700">
<tr>
<th className="py-1.5 pr-3 font-medium">{t('ledger.account.number', 'No.')}</th>
<th className="py-1.5 pr-3 font-medium">{t('ledger.account.name', 'Name')}</th>
<th className="py-1.5 pr-3 font-medium">{t('ledger.account.type', 'Type')}</th>
<th className="py-1.5 pr-3 font-medium text-right">{t('common.actions', 'Actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
{(accounts ?? []).map((a) => (
<tr key={a.id} className={a.active ? '' : 'opacity-50'}>
<td className="py-1.5 pr-3 tabular-nums font-medium text-neutral-900 dark:text-neutral-100">{a.number}</td>
<td className="py-1.5 pr-3 text-neutral-800 dark:text-neutral-200">{a.name}</td>
<td className="py-1.5 pr-3 text-neutral-500 dark:text-neutral-400">{t(`ledger.accountType.${a.type}`, a.type)}</td>
<td className="py-1.5 pr-3">
<div className="flex items-center justify-end gap-1">
<button onClick={() => setAccountModal({ account: a })} className="p-1 text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"><Pencil className="w-4 h-4" /></button>
<button onClick={() => { if (window.confirm(t('ledger.account.confirmDelete', 'Delete this account?') as string)) delAccount.mutate(a.id); }} className="p-1 text-neutral-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
{accountModal && <AccountModal account={accountModal.account} onClose={() => setAccountModal(null)} onDone={() => { setAccountModal(null); refetchAll(); }} />}
</div>
);
};
export default ChartOfAccountsManager;
@@ -0,0 +1,211 @@
/**
* 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, FileText, Receipt, Wrench, Calculator, Clock, ScrollText, Calendar, FolderKanban } 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: 'overview',
to: '/admin/clients/projects',
label: t('clients.subnav.overview', 'Overview'),
icon: FolderKanban,
featureFlag: 'projects',
},
{
key: 'accounts',
to: '/admin/clients/accounts',
label: t('clients.subnav.accounts', 'Accounts'),
icon: UserCog,
featureFlag: 'customerPortal',
},
{
key: 'calendar',
to: '/admin/clients/calendar',
label: t('clients.subnav.calendar', 'Calendar'),
icon: Calendar,
featureFlag: 'calendar',
},
{
key: 'quotes',
to: '/admin/clients/quotes',
label: t('clients.subnav.quotes', 'Quotes'),
icon: FileText,
featureFlag: 'quotes',
},
{
key: 'contracts',
to: '/admin/clients/contracts',
label: t('clients.subnav.contracts', 'Contracts'),
icon: ScrollText,
featureFlag: 'contracts',
},
{
key: 'hours',
to: '/admin/clients/hours',
label: t('clients.subnav.hours', 'Hours'),
icon: Clock,
featureFlag: 'hoursLogging',
},
{
key: 'bills',
to: '/admin/clients/bills',
label: t('clients.subnav.bills', 'Invoices'),
icon: Receipt,
featureFlag: 'bills',
},
// Tax export moved permanently to the Accounting section (it is no
// longer a CRM sub-feature). See AccountingLayout.
// Future sub-features:
// { key: 'messaging', ... featureFlag: 'messaging' }
{
key: 'development',
to: '/admin/clients/development',
label: t('clients.subnav.development', 'Development'),
icon: Wrench,
featureFlag: 'crmDevelopment',
},
];
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', 'CRM')}
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing — everything for recurring clients in one place.')}
</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 CRM features enabled')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t(
'clients.empty.body',
'Enable Accounts (or another CRM 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', 'CRM')}
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
{t('clients.subtitle', 'Customer accounts, scheduling, quotes and billing — everything for recurring clients in one place.')}
</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', 'CRM 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', 'CRM 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>
);
};
@@ -0,0 +1,293 @@
/**
* CrmOverviewSection — headline metrics embedded into the main
* AdminDashboard for admins who use the CRM features.
*
* Feature-flag gating (three layers):
* - `clients` parent flag OFF → renders nothing
* - only `quotes` enabled → only the quote cards render
* - only `bills` enabled → only the invoice + revenue
* + outstanding cards render
* - both enabled → full section
*
* Numbers come from /api/admin/dashboard/crm-stats. Each card deep-
* links into the matching filtered list so the admin can drill in
* with one click.
*/
import React from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import {
FileText, Send, CheckCircle2, XCircle, Clock,
Receipt, AlertTriangle, TrendingUp, Wallet,
} from 'lucide-react';
import { Card } from '../common';
import { fetchCrmOverview, type CrmOverviewStats } from '../../services/bills.service';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { formatMoneyMinor } from '../../utils/money';
// Local alias preserved so call-sites in this file keep their
// minor-units semantics. The unified helper handles the /100 conversion.
const formatMoney = formatMoneyMinor;
export const CrmOverviewSection: React.FC = () => {
const { t } = useTranslation();
const { flags } = useFeatureFlags();
const { data: publicSettings } = usePublicSettings();
// Outer gate: hide the entire CRM block when the parent feature
// flag is off. The query is also skipped so we don't hit the
// endpoint at all on non-CRM installs.
const clientsOn = !!flags.clients;
const quotesOn = clientsOn && !!flags.quotes;
const billsOn = clientsOn && !!flags.bills;
const anyCrm = quotesOn || billsOn;
// Per-tile visibility (admin pref in Settings → CRM behaviour). All
// default true; only explicit false hides the matching tile. We
// resolve via `!== false` so the very first render — before
// publicSettings finishes loading — shows everything, then settles.
const showRevenue = publicSettings?.crm_overview_show_revenue !== false;
const showOutstanding = publicSettings?.crm_overview_show_outstanding !== false;
// Revenue "year" tile toggles in place between the trailing-365-day
// window and calendar year-to-date — keeps the dashboard to four
// tiles instead of adding a fifth.
const [revYearMode, setRevYearMode] = React.useState<'rolling' | 'calendar'>('rolling');
const showQuotes = publicSettings?.crm_overview_show_quotes !== false;
const showInvoices = publicSettings?.crm_overview_show_invoices !== false;
// Compute which sub-sections actually render so we can skip the
// outer block entirely when the admin hid everything.
const billsRevenueRow = billsOn && (showRevenue || showOutstanding);
const quotesBlock = quotesOn && showQuotes;
const invoicesBlock = billsOn && showInvoices;
const { data, isLoading, isError } = useQuery({
queryKey: ['crm-overview'],
queryFn: () => fetchCrmOverview(),
enabled: anyCrm,
// Dashboard tile aggregates — admin opens this on every dashboard
// load; refetching the full aggregate on every mount adds DB load
// without observable benefit. 60s window covers the typical
// "click into a contract / click back" pattern.
staleTime: 60_000,
});
if (!anyCrm) return null;
// Admin hid every CRM tile — render nothing, including the heading.
if (!billsRevenueRow && !quotesBlock && !invoicesBlock) return null;
if (isLoading) return null;
if (isError || !data) {
// Surface a tiny inline notice when the section is enabled by
// flags but the API failed — silent renders make this hard to
// debug (the user reported "everything turned on but nothing
// shows" which traced back to a permission check on the
// backend). Keep it small so it doesn't disrupt the page.
return (
<section className="mt-8">
<h2 className="text-xl font-bold text-theme mb-2">
{t('crmOverview.title', 'CRM overview')}
</h2>
<p className="text-sm text-red-600">
{t('crmOverview.loadError',
'Could not load CRM stats. Check that you have bills.view or quotes.view permission and that the backend is on the latest build.')}
</p>
</section>
);
}
const d: CrmOverviewStats = data;
const cur = d.currency || 'CHF';
return (
<section className="mt-8 space-y-5">
<h2 className="text-xl font-bold text-theme">
{t('crmOverview.title', 'CRM overview')}
</h2>
{/* Revenue + outstanding (bills feature only). Revenue trio and
outstanding tile are gated independently — admins who only
want the outstanding figure (or vice versa) can hide either
via Settings → CRM behaviour. */}
{billsRevenueRow && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{showRevenue && (
<>
<StatCard
icon={<TrendingUp className="w-5 h-5" />}
label={t('crmOverview.revenue.month', 'Revenue · last 30 days')}
value={formatMoney(d.revenue.monthMinor, cur)}
/>
<StatCard
icon={<TrendingUp className="w-5 h-5" />}
label={t('crmOverview.revenue.quarter', 'Revenue · last 90 days')}
value={formatMoney(d.revenue.quarterMinor, cur)}
/>
<StatCard
icon={<TrendingUp className="w-5 h-5" />}
label={revYearMode === 'calendar'
? t('crmOverview.revenue.yearCalendar', 'Revenue · this year')
: t('crmOverview.revenue.year', 'Revenue · last 365 days')}
value={formatMoney(
revYearMode === 'calendar' ? d.revenue.calendarYearMinor : d.revenue.yearMinor,
cur,
)}
sub={t('crmOverview.revenue.toggleHint', 'Tap to switch window')}
onClick={() => setRevYearMode((m) => (m === 'rolling' ? 'calendar' : 'rolling'))}
/>
</>
)}
{showOutstanding && (
<StatCard
icon={<Wallet className="w-5 h-5 text-red-600" />}
label={t('crmOverview.outstanding', 'Outstanding payments')}
value={formatMoney(d.outstanding.totalMinor, cur)}
sub={t('crmOverview.outstandingSub', '{{count}} invoice(s) unpaid', {
count: d.outstanding.invoiceCount,
})}
to="/admin/clients/bills?unpaidOnly=true"
/>
)}
</div>
)}
{/* Quotes pipeline (quotes feature only) */}
{quotesBlock && (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold flex items-center gap-2">
<FileText className="w-5 h-5" />
{t('crmOverview.quotes.title', 'Quotes')}
</h3>
<Link to="/admin/clients/quotes" className="text-sm text-primary-600 dark:text-primary-400 hover:underline">
{t('crmOverview.viewAll', 'View all')}
</Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
<StatCard
icon={<Clock className="w-5 h-5 text-amber-600" />}
label={t('quotes.status.draft', 'Drafts')}
value={d.quotes.draft}
to="/admin/clients/quotes?status=draft"
/>
<StatCard
icon={<Send className="w-5 h-5 text-blue-600" />}
label={t('quotes.status.sent', 'Sent / open')}
value={d.quotes.sent}
to="/admin/clients/quotes?status=sent"
/>
<StatCard
icon={<CheckCircle2 className="w-5 h-5 text-green-600" />}
label={t('quotes.status.accepted', 'Accepted')}
value={d.quotes.accepted}
to="/admin/clients/quotes?status=accepted"
/>
<StatCard
icon={<XCircle className="w-5 h-5 text-red-600" />}
label={t('quotes.status.declined', 'Declined')}
value={d.quotes.declined}
to="/admin/clients/quotes?status=declined"
/>
<StatCard
icon={<Clock className="w-5 h-5 text-neutral-500" />}
label={t('quotes.status.expired', 'Expired')}
value={d.quotes.expired}
to="/admin/clients/quotes?status=expired"
/>
<StatCard
icon={<CheckCircle2 className="w-5 h-5 text-emerald-700" />}
label={t('quotes.status.converted', 'Converted')}
value={d.quotes.converted}
to="/admin/clients/quotes?status=converted"
/>
</div>
</div>
)}
{/* Invoices pipeline (bills feature only) */}
{invoicesBlock && (
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold flex items-center gap-2">
<Receipt className="w-5 h-5" />
{t('crmOverview.invoices.title', 'Invoices')}
</h3>
<Link to="/admin/clients/bills" className="text-sm text-primary-600 dark:text-primary-400 hover:underline">
{t('crmOverview.viewAll', 'View all')}
</Link>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
<StatCard
icon={<Clock className="w-5 h-5 text-amber-600" />}
label={t('bills.status.scheduled', 'Scheduled')}
value={d.invoices.scheduled}
to="/admin/clients/bills?status=scheduled"
/>
<StatCard
icon={<Send className="w-5 h-5 text-blue-600" />}
label={t('bills.status.sent', 'Sent / open')}
value={d.invoices.sent}
to="/admin/clients/bills?status=sent"
/>
<StatCard
icon={<CheckCircle2 className="w-5 h-5 text-green-600" />}
label={t('bills.status.paid', 'Paid')}
value={d.invoices.paid}
to="/admin/clients/bills?status=paid"
/>
<StatCard
icon={<AlertTriangle className="w-5 h-5 text-red-600" />}
label={t('bills.status.overdue', 'Overdue')}
value={d.invoices.overdue}
to="/admin/clients/bills?status=overdue"
/>
<StatCard
icon={<XCircle className="w-5 h-5 text-neutral-500" />}
label={t('bills.status.cancelled', 'Cancelled')}
value={d.invoices.cancelled}
to="/admin/clients/bills?status=cancelled"
/>
</div>
</div>
)}
</section>
);
};
interface StatCardProps {
icon: React.ReactNode;
label: string;
value: string | number;
sub?: string;
to?: string;
/** Makes the whole tile a button (mutually exclusive with `to`).
* Used by the revenue tile to toggle its window in place. */
onClick?: () => void;
}
const StatCard: React.FC<StatCardProps> = ({ icon, label, value, sub, to, onClick }) => {
const inner = (
<Card padding="md" className="h-full">
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5">{icon}</div>
<div className="min-w-0">
<div className="text-xs uppercase tracking-wider text-muted-theme">{label}</div>
<div className="text-2xl font-bold tabular-nums mt-1">{value}</div>
{sub && <div className="text-xs text-muted-theme mt-1">{sub}</div>}
</div>
</div>
</Card>
);
if (to) {
return <Link to={to} className="block hover:opacity-90 transition-opacity">{inner}</Link>;
}
if (onClick) {
return (
<button type="button" onClick={onClick} className="block w-full text-left hover:opacity-90 transition-opacity">
{inner}
</button>
);
}
return inner;
};
export default CrmOverviewSection;
@@ -0,0 +1,240 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Save, RotateCcw, Code, AlertTriangle, Check } from 'lucide-react';
import { Button, Card, Loading } from '../common';
import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../hooks';
export const CssTemplateEditor: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const queryClient = useQueryClient();
const [activeSlot, setActiveSlot] = useState(1);
const [localTemplates, setLocalTemplates] = useState<CssTemplate[]>([]);
const [hasChanges, setHasChanges] = useState(false);
// Fetch templates
const { data: templates, isLoading } = useQuery({
queryKey: ['css-templates'],
queryFn: () => cssTemplatesService.getTemplates()
});
// Update local state when templates load
useEffect(() => {
if (templates) {
setLocalTemplates(templates);
setHasChanges(false);
}
}, [templates]);
// Save mutation
const saveMutation = useMutation({
mutationFn: async () => {
const template = localTemplates.find(t => t.slot_number === activeSlot);
if (!template) throw new Error('Template not found');
return cssTemplatesService.updateTemplate(activeSlot, {
name: template.name,
css_content: template.css_content,
is_enabled: template.is_enabled
});
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['css-templates'] });
setHasChanges(false);
if (result.warnings.length > 0) {
toast.warning(t('cssTemplates.sanitizationWarning', 'Some CSS patterns were blocked for security'));
} else {
toast.success(t('cssTemplates.saved', 'Template saved successfully'));
}
},
onError: (error: Error) => {
toast.error(error.message || t('cssTemplates.saveFailed', 'Failed to save template'));
}
});
// Reset mutation
const resetMutation = useMutationWithToast({
mutationFn: () => cssTemplatesService.resetToDefault(),
invalidateKeys: [['css-templates']],
successMessage: t('cssTemplates.reset', 'Template reset to default'),
errorMessage: (error: Error) => error.message || t('cssTemplates.resetFailed', 'Failed to reset template')
});
const activeTemplate = localTemplates.find(t => t.slot_number === activeSlot);
const updateLocalTemplate = (updates: Partial<CssTemplate>) => {
setLocalTemplates(prev =>
prev.map(t =>
t.slot_number === activeSlot ? { ...t, ...updates } : t
)
);
setHasChanges(true);
};
const handleReset = () => {
if (!confirm(t('cssTemplates.resetConfirm', 'Reset this template to the default? Your changes will be lost.'))) {
return;
}
resetMutation.mutate();
};
if (isLoading) {
return <Loading size="lg" text={t('common.loading', 'Loading...')} />;
}
return (
<Card>
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<Code className="w-5 h-5" />
{t('cssTemplates.title', 'Custom CSS Templates')}
</h2>
</div>
{/* Tab Navigation */}
<div className="flex border-b border-neutral-200 dark:border-neutral-700 mb-6">
{[1, 2, 3].map(slot => {
const template = localTemplates.find(t => t.slot_number === slot);
return (
<button
key={slot}
onClick={() => setActiveSlot(slot)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeSlot === slot
? 'border-accent text-accent'
: 'border-transparent text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:border-neutral-300 dark:hover:border-neutral-600'
}`}
>
{t('cssTemplates.template', 'Template')} {slot}
{template && (
<span className="ml-2 text-neutral-400">
({template.name})
</span>
)}
{template?.is_enabled && (
<Check className="w-3 h-3 inline ml-1 text-green-500" />
)}
</button>
);
})}
</div>
{activeTemplate && (
<div className="space-y-6">
{/* Template Name */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 dark:text-neutral-300 mb-2">
{t('cssTemplates.templateName', 'Template Name')}
</label>
<input
type="text"
value={activeTemplate.name}
onChange={(e) => updateLocalTemplate({ name: e.target.value })}
maxLength={50}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
/>
</div>
{/* Enable Toggle */}
<div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={activeTemplate.is_enabled}
onChange={(e) => updateLocalTemplate({ is_enabled: e.target.checked })}
className="rounded border-neutral-300 text-accent focus:ring-primary-500"
/>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('cssTemplates.enableTemplate', 'Enable this template')}
</span>
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1 ml-6">
{t('cssTemplates.enableHint', 'Enabled templates can be selected when creating events')}
</p>
</div>
{/* CSS Editor */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 dark:text-neutral-300 mb-2">
{t('cssTemplates.cssContent', 'CSS Content')}
</label>
<div className="relative">
<textarea
value={activeTemplate.css_content}
onChange={(e) => updateLocalTemplate({ css_content: e.target.value })}
className="w-full h-96 px-4 py-3 font-mono text-sm border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark bg-neutral-900 text-green-400"
spellCheck={false}
placeholder="/* Enter your custom CSS here */"
/>
<div className="absolute bottom-3 right-3 text-xs text-neutral-400">
{(activeTemplate.css_content?.length || 0).toLocaleString()} / 102,400 {t('common.characters', 'characters')}
</div>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-2">
{t('cssTemplates.cssHint', 'Use .gallery-page to scope styles to the gallery. Available variables: --gallery-bg, --gallery-text, --gallery-accent')}
</p>
</div>
{/* Security Notice */}
<div className="flex items-start gap-2 p-3 bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg">
<AlertTriangle className="w-4 h-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
<div className="text-xs text-amber-800 dark:text-amber-200">
<strong>{t('cssTemplates.securityNotice', 'Security Notice')}:</strong>{' '}
{t('cssTemplates.securityText', 'CSS is sanitized to prevent malicious code. External URLs, @import, and JavaScript expressions are blocked.')}
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-between pt-4 border-t border-neutral-100 dark:border-neutral-700">
<div className="flex items-center gap-3">
{activeSlot === 1 && activeTemplate.is_default && (
<Button
variant="outline"
size="sm"
onClick={handleReset}
disabled={resetMutation.isPending}
leftIcon={<RotateCcw className="w-4 h-4" />}
>
{t('cssTemplates.resetToDefault', 'Reset to Default')}
</Button>
)}
</div>
<div className="flex items-center gap-3">
{hasChanges && (
<span className="text-sm text-amber-600">
{t('cssTemplates.unsavedChanges', 'Unsaved changes')}
</span>
)}
<Button
variant="primary"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !hasChanges}
isLoading={saveMutation.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('cssTemplates.saveTemplate', 'Save Template')}
</Button>
</div>
</div>
{/* Last Updated */}
{activeTemplate.updated_at && (
<p className="text-xs text-neutral-400 dark:text-neutral-500 text-right">
{t('cssTemplates.lastUpdated', 'Last updated')}: {fmtDateTime(activeTemplate.updated_at)}
</p>
)}
</div>
)}
</div>
</Card>
);
};
export default CssTemplateEditor;
@@ -0,0 +1,204 @@
/**
* CustomerAccountPicker (#354).
*
* Multi-select autocomplete used on the event create / edit forms to
* assign customer accounts to an event. Anyone selected here gets
* dashboard access + can bypass the per-event password.
*
* Backed by GET /api/admin/customers/search (debounced 200ms).
* Selected values render as removable chips so the form can stay compact.
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Search, X, UserPlus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { customerAdminService, type CustomerAccountSummary } from '../../services/customerAdmin.service';
import { useFeatureEnabled } from '../../contexts/FeatureFlagsContext';
export interface SelectedCustomer {
id: number;
email: string;
displayName: string | null;
}
interface Props {
value: SelectedCustomer[];
onChange: (next: SelectedCustomer[]) => void;
disabled?: boolean;
}
const labelFor = (c: { email: string; displayName?: string | null; companyName?: string | null }) => {
const display = c.displayName?.trim() || c.companyName?.trim();
return display ? `${display} · ${c.email}` : c.email;
};
export const CustomerAccountPicker: React.FC<Props> = ({ value, onChange, disabled }) => {
const { t } = useTranslation();
// Rules of Hooks: the feature-flag gate (early-return) is moved to
// the very end of this hook list (see end of function). The previous
// shape did `if (!customerPortalEnabled) return null` BEFORE the
// useState/useRef/useEffect calls below, which caused the hook count
// to differ between renders the moment the React Query for
// /admin/feature-flags resolved (first render: enabled=false from
// DEFAULT_FLAGS → return null; second render: enabled=true → hooks
// run → "Rendered more hooks than during the previous render"
// crash). That tanked the entire /admin/events/new page through
// the global error boundary. PR #458 reviewer flag.
const customerPortalEnabled = useFeatureEnabled('customerPortal');
const [query, setQuery] = useState('');
const [results, setResults] = useState<CustomerAccountSummary[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Debounced search. Aborts in-flight requests so a fast typer doesn't
// see an old result win the race over a newer one.
useEffect(() => {
const term = query.trim();
if (!term) {
setResults([]);
setIsSearching(false);
return;
}
setIsSearching(true);
let cancelled = false;
const handle = window.setTimeout(async () => {
try {
const rows = await customerAdminService.search(term);
if (!cancelled) {
// Filter out already-selected ids on the client. Cheaper than
// round-tripping the selection state to the server.
const selectedIds = new Set(value.map((v) => v.id));
setResults(rows.filter((r) => !selectedIds.has(r.id)));
}
} catch {
if (!cancelled) setResults([]);
} finally {
if (!cancelled) setIsSearching(false);
}
}, 200);
return () => { cancelled = true; window.clearTimeout(handle); };
}, [query, value]);
// Click-outside to close. Listening on mousedown matches what the
// existing AdminHeader notification dropdown uses.
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, []);
const select = (c: CustomerAccountSummary) => {
onChange([...value, { id: c.id, email: c.email, displayName: c.displayName }]);
setQuery('');
setResults([]);
setIsOpen(false);
};
const remove = (id: number) => {
onChange(value.filter((v) => v.id !== id));
};
const helpText = useMemo(
() => t(
'events.customerPicker.help',
'Customers added here can log in at /customer/login and view this gallery without entering the per-event password.'
),
[t]
);
// Feature-flag gate (deliberately placed AFTER all hooks — see the
// long comment at the top of this component for why). When the
// customerPortal flag is off the backend returns 410 on
// /admin/customers/search anyway, but hiding the UI here keeps the
// event form clean and removes the dangling "Customer accounts"
// label that would otherwise appear above an empty placeholder.
if (!customerPortalEnabled) return null;
return (
<div ref={containerRef} className="relative">
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">
{t('events.customerPicker.label', 'Customer accounts')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{helpText}</p>
{/* Selected chips */}
{value.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{value.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700"
>
<span className="font-medium">{c.displayName?.trim() || c.email}</span>
{c.displayName?.trim() && c.email !== c.displayName && (
<span className="text-neutral-500 dark:text-neutral-400">· {c.email}</span>
)}
{!disabled && (
<button
type="button"
onClick={() => remove(c.id)}
className="ml-1 -mr-1 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 p-0.5"
aria-label={t('events.customerPicker.removeAria', 'Remove {{name}}', { name: c.email })}
>
<X className="w-3 h-3" />
</button>
)}
</span>
))}
</div>
)}
{/* Search input */}
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 pointer-events-none" />
<input
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setIsOpen(true); }}
onFocus={() => setIsOpen(true)}
disabled={disabled}
placeholder={t('events.customerPicker.placeholder', 'Search by email, name, or company')}
className="input pl-9"
/>
</div>
{/* Dropdown */}
{isOpen && query.trim() !== '' && (
<div
className="absolute left-0 right-0 mt-1 z-20 rounded-lg shadow-lg border max-h-72 overflow-y-auto bg-white dark:bg-neutral-900 border-neutral-200 dark:border-neutral-700"
>
{isSearching ? (
<div className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
{t('events.customerPicker.searching', 'Searching…')}
</div>
) : results.length === 0 ? (
<div className="px-3 py-3 text-sm text-neutral-500 dark:text-neutral-400">
{t('events.customerPicker.noResults', 'No matches. Invite this customer from Clients → Accounts first.')}
</div>
) : (
<ul role="listbox">
{results.map((r) => (
<li key={r.id}>
<button
type="button"
onClick={() => select(r)}
className="w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
>
<UserPlus className="w-4 h-4 text-neutral-500 dark:text-neutral-400 flex-shrink-0" />
<span className="flex-1 truncate">{labelFor(r)}</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
};
export default CustomerAccountPicker;
@@ -0,0 +1,227 @@
/**
* Customer CRM panels — quotes + invoices history shown on the customer
* detail page. Each panel:
* - is gated by its global feature flag (`quotes` / `bills`); when the
* flag is off the panel doesn't render at all (no empty space)
* - shows the 10 most recent rows for that customer, with status
* badge, total and a click-through to the full document
* - exposes a "New …" button + a "Show all" link to the global list
* pre-filtered by this customer
*
* Lives as a separate component so CustomerDetailPage doesn't need to
* know about CRM types; the panels handle their own data fetching.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { FileText, Plus, Receipt, ScrollText } from 'lucide-react';
import { Card, Button, Loading } from '../common';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { quotesService } from '../../services/quotes.service';
import { billsService, isDraftInvoice } from '../../services/bills.service';
import { contractsService } from '../../services/contracts.service';
import { formatMoney } from './LineItemsTable';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
interface Props {
customerAccountId: number;
}
export const CustomerCrmPanels: React.FC<Props> = ({ customerAccountId }) => {
const { flags } = useFeatureFlags();
return (
<>
{flags.quotes && <QuotesPanel customerAccountId={customerAccountId} />}
{flags.contracts && <ContractsPanel customerAccountId={customerAccountId} />}
{flags.bills && <InvoicesPanel customerAccountId={customerAccountId} />}
</>
);
};
const QuotesPanel: React.FC<Props> = ({ customerAccountId }) => {
const { t } = useTranslation();
const { format: fmtDate } = useLocalizedDate();
const { data, isLoading } = useQuery({
queryKey: ['customer-quotes', customerAccountId],
queryFn: () => quotesService.list({ customerAccountId, page: 1, pageSize: 10, sort: 'newest' }),
// Customer detail page mounts these three panels together. Without
// staleTime they all refetch on every visit + every queryClient
// touch elsewhere. 30s lets a quick tab-out/tab-in not re-hit the
// API; admin mutations invalidate the cache explicitly when they
// need fresh data.
staleTime: 30_000,
});
return (
<Card padding="lg">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<FileText className="w-5 h-5" /> {t('customers.detail.quotesSection', 'Quotes')}
</h2>
<div className="flex gap-2">
<Link to={`/admin/clients/quotes?customerAccountId=${customerAccountId}`}>
<Button variant="outline" size="sm">{t('common.showAll', 'Show all')}</Button>
</Link>
{/* "New quote" pre-fills via state on QuoteEditorPage when a
customerAccountId search-param is present (cheap follow-up
if you want it). For now the editor's customer picker
starts empty. */}
{/* Pre-fill this customer on the editor via search-param
so the admin doesn't have to retype it. The editor picks
it up on mount. */}
<Link to={`/admin/clients/quotes/new?customerAccountId=${customerAccountId}`}>
<Button size="sm"><Plus className="w-4 h-4 mr-1" />{t('quotes.new', 'New quote')}</Button>
</Link>
</div>
</div>
{isLoading ? <Loading /> : !data || data.quotes.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('customers.detail.noQuotes', 'No quotes for this customer yet.')}
</p>
) : (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{data.quotes.map((q) => (
<li key={q.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<Link to={`/admin/clients/quotes/${q.id}`} className="text-neutral-900 dark:text-neutral-100 hover:underline font-mono text-sm">
{q.quoteNumber}
</Link>
<span className="text-xs text-neutral-500 dark:text-neutral-400 ml-2">{q.eventName || fmtDate(q.issueDate)}</span>
</div>
<span className="text-sm tabular-nums">{formatMoney(Number(q.totalAmountMinor) / 100, q.currency)}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
q.status === 'accepted' || q.status === 'converted' ? 'bg-green-100 text-green-800'
: q.status === 'declined' ? 'bg-red-100 text-red-800'
: q.status === 'sent' ? 'bg-blue-100 text-blue-800'
: 'bg-neutral-100 text-neutral-700'
}`}>{t(`quotes.status.${q.status}`, q.status)}</span>
</li>
))}
</ul>
)}
</Card>
);
};
const ContractsPanel: React.FC<Props> = ({ customerAccountId }) => {
const { t } = useTranslation();
const { format: fmtDate } = useLocalizedDate();
const { data, isLoading } = useQuery({
queryKey: ['customer-contracts', customerAccountId],
queryFn: () => contractsService.list({ customerAccountId, page: 1, pageSize: 10, sort: 'newest' }),
staleTime: 30_000,
});
return (
<Card padding="lg">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<ScrollText className="w-5 h-5" /> {t('customers.detail.contractsSection', 'Contracts')}
</h2>
<div className="flex gap-2">
<Link to={`/admin/clients/contracts?customerAccountId=${customerAccountId}`}>
<Button variant="outline" size="sm">{t('common.showAll', 'Show all')}</Button>
</Link>
<Link to={`/admin/clients/contracts/new?customerAccountId=${customerAccountId}`}>
<Button size="sm"><Plus className="w-4 h-4 mr-1" />{t('contracts.list.new', 'New contract')}</Button>
</Link>
</div>
</div>
{isLoading ? <Loading /> : !data || data.contracts.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('customers.detail.noContracts', 'No contracts for this customer yet.')}
</p>
) : (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{data.contracts.map((c) => (
<li key={c.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<Link to={`/admin/clients/contracts/${c.id}`} className="text-neutral-900 dark:text-neutral-100 hover:underline font-mono text-sm">
{c.contractNumber}
</Link>
<span className="text-xs text-neutral-500 dark:text-neutral-400 ml-2 truncate">{c.title || fmtDate(c.issueDate)}</span>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
c.status === 'fully_signed' ? 'bg-green-100 text-green-800'
: c.status === 'signed_by_customer' || c.status === 'signed_by_admin' ? 'bg-blue-100 text-blue-800'
: c.status === 'sent' ? 'bg-amber-100 text-amber-800'
: c.status === 'cancelled' ? 'bg-neutral-200 text-neutral-600'
: 'bg-neutral-100 text-neutral-700'
}`}>{t(`contracts.status.${c.status}`, c.status)}</span>
</li>
))}
</ul>
)}
</Card>
);
};
const InvoicesPanel: React.FC<Props> = ({ customerAccountId }) => {
const { t } = useTranslation();
const { format: fmtDate } = useLocalizedDate();
const { data, isLoading } = useQuery({
queryKey: ['customer-invoices', customerAccountId],
queryFn: () => billsService.list({ customerAccountId, page: 1, pageSize: 10, sort: 'newest' }),
staleTime: 30_000,
});
return (
<Card padding="lg">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<Receipt className="w-5 h-5" /> {t('customers.detail.billsSection', 'Invoices')}
</h2>
<div className="flex gap-2">
<Link to={`/admin/clients/bills?customerAccountId=${customerAccountId}`}>
<Button variant="outline" size="sm">{t('common.showAll', 'Show all')}</Button>
</Link>
{/* Same prefill trick as quotes — see comment in QuotesPanel. */}
<Link to={`/admin/clients/bills/new?customerAccountId=${customerAccountId}`}>
<Button size="sm"><Plus className="w-4 h-4 mr-1" />{t('bills.new', 'New invoice')}</Button>
</Link>
</div>
</div>
{isLoading ? <Loading /> : !data || data.invoices.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('customers.detail.noBills', 'No invoices for this customer yet.')}
</p>
) : (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{data.invoices.map((inv) => (
<li key={inv.id} className="py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<Link to={`/admin/clients/bills/${inv.id}`} className="text-neutral-900 dark:text-neutral-100 hover:underline font-mono text-sm">
{inv.invoiceNumber}
</Link>
<span className="text-xs text-neutral-500 dark:text-neutral-400 ml-2">
{fmtDate(inv.dueDate)}
{inv.installmentTotal > 1 ? ` · ${inv.installmentIndex + 1}/${inv.installmentTotal}` : ''}
</span>
</div>
<span className="text-sm tabular-nums">{formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)}</span>
{isDraftInvoice(inv) ? (
<span className="px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200">
{t('bills.status.draft', 'Draft')}
</span>
) : (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
inv.status === 'paid' ? 'bg-green-100 text-green-800'
: inv.status === 'overdue' ? 'bg-red-100 text-red-800'
: inv.status === 'sent' ? 'bg-blue-100 text-blue-800'
: inv.status === 'cancelled' ? 'bg-neutral-200 text-neutral-600'
: inv.status === 'skipped' ? 'bg-neutral-100 text-neutral-500 italic'
: 'bg-amber-100 text-amber-800'
}`}>{t(`bills.status.${inv.status}`, inv.status)}</span>
)}
</li>
))}
</ul>
)}
</Card>
);
};
@@ -0,0 +1,165 @@
/**
* Customer dashboard branding card (#354 follow-up).
*
* Two toggles that govern what shows in the /customer/dashboard
* header — the logo and the company-name text. Persisted under
* setting_type='customer_surface' in app_settings via the dedicated
* /admin/settings/customer-surface endpoint, kept separate from the
* main BrandingPage save flow so toggling these doesn't drag the
* full branding payload through a save cycle.
*
* Mounted from BrandingPage and only rendered when the customerPortal
* feature flag is on — see CustomerDashboardBrandingSection in
* BrandingPage.tsx.
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Save, Image as ImageIcon, Type, UserCog } from 'lucide-react';
import { Button, Card, Loading } from '../common';
import { api } from '../../config/api';
import { useMutationWithToast } from '../../hooks';
interface CustomerSurfaceSettings {
customer_show_logo: boolean;
customer_show_company_name: boolean;
}
const DEFAULTS: CustomerSurfaceSettings = {
customer_show_logo: true,
customer_show_company_name: true,
};
// Migration 092 seeds these as JSON-encoded booleans. Treat anything
// other than literal false as on, matching the backend defaults so a
// brand-new install (no row yet) shows the same UI as an existing one.
function withDefaults(raw: Partial<CustomerSurfaceSettings> | null | undefined): CustomerSurfaceSettings {
return {
customer_show_logo: raw?.customer_show_logo !== false,
customer_show_company_name: raw?.customer_show_company_name !== false,
};
}
interface ToggleProps {
enabled: boolean;
onChange: () => void;
label: string;
hint?: string;
icon: React.ComponentType<{ className?: string }>;
}
const Toggle: React.FC<ToggleProps> = ({ enabled, onChange, label, hint, icon: Icon }) => (
<label className="flex items-start justify-between gap-4 py-3 cursor-pointer">
<div className="flex items-start gap-3 min-w-0">
<Icon className="w-5 h-5 mt-0.5 text-neutral-500 dark:text-neutral-400 flex-shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{label}</div>
{hint && <p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{hint}</p>}
</div>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
onClick={onChange}
className={`relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 ${enabled ? '' : 'bg-neutral-300 dark:bg-neutral-600'}`}
style={enabled ? { backgroundColor: 'var(--color-accent, #5C8762)' } : undefined}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${enabled ? 'translate-x-6' : 'translate-x-1'}`}
/>
</button>
</label>
);
export const CustomerDashboardBrandingCard: React.FC = () => {
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ['admin-settings-customer-surface'],
queryFn: async () => {
const res = await api.get<Partial<CustomerSurfaceSettings>>('/admin/settings/customer-surface');
return withDefaults(res.data);
},
});
const [form, setForm] = useState<CustomerSurfaceSettings>(DEFAULTS);
useEffect(() => { if (data) setForm(data); }, [data]);
const saveMutation = useMutationWithToast({
mutationFn: () => api.put('/admin/settings/customer-surface', form),
// The customer-side session response (/api/customer/auth/session)
// also bundles these as branding flags — invalidate public-settings so
// a customer tab refresh picks up the new visibility on the next focus.
invalidateKeys: [['admin-settings-customer-surface'], ['public-settings']],
successMessage: t('settings.customerSurface.saved', 'Customer dashboard branding saved'),
errorMessage: () => t('settings.customerSurface.error', 'Could not save settings'),
});
const toggle = (key: keyof CustomerSurfaceSettings) => {
setForm((p) => ({ ...p, [key]: !p[key] }));
};
const isDirty = data
? form.customer_show_logo !== data.customer_show_logo
|| form.customer_show_company_name !== data.customer_show_company_name
: false;
if (isLoading) {
return (
<Card padding="md">
<div className="py-6 flex justify-center"><Loading size="md" /></div>
</Card>
);
}
return (
<Card padding="md">
<div className="flex items-start gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-accent-soft text-on-accent-soft flex items-center justify-center flex-shrink-0">
<UserCog className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('settings.customerSurface.brandingTitle', 'Customer dashboard header')}
</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
{t(
'settings.customerSurface.brandingHint',
'Controls what shows in the header of /customer/dashboard. Public galleries and admin surfaces are not affected.',
)}
</p>
</div>
</div>
<div className="divide-y divide-neutral-200 dark:divide-neutral-700">
<Toggle
enabled={form.customer_show_logo}
onChange={() => toggle('customer_show_logo')}
label={t('settings.customerSurface.showLogo', 'Show logo in customer header')}
hint={t('settings.customerSurface.showLogoHint', 'Uses the same branding logo configured above.')}
icon={ImageIcon}
/>
<Toggle
enabled={form.customer_show_company_name}
onChange={() => toggle('customer_show_company_name')}
label={t('settings.customerSurface.showCompanyName', 'Show company name in customer header')}
hint={t('settings.customerSurface.showCompanyNameHint', 'Hide if your logo already includes the company name.')}
icon={Type}
/>
</div>
<div className="flex justify-end mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<Button
variant="primary"
leftIcon={<Save className="w-4 h-4" />}
isLoading={saveMutation.isPending}
disabled={!isDirty || saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
{t('settings.customerSurface.save', 'Save changes')}
</Button>
</div>
</Card>
);
};
@@ -0,0 +1,214 @@
/**
* CustomerPicker — shared search-or-create surface for the three CRM
* editors (Quote, Bill, Contract).
*
* **Why this exists**
*
* The audit flagged that QuoteEditorPage, BillEditorPage, and
* ContractEditorPage each carried near-identical ~80-line customer
* picker blocks: a "currently selected" row when an id is present,
* a search-debounced lookup with passive-badge chips, and an
* "InlineCustomerCreate" expansion when the admin clicks "+ Create
* new customer". The three copies had drifted: passive badge text
* positioning differed, the contract variant used a bare `<input>`
* instead of the shared `<Input>` component, and the contract change
* link used `text-accent-dark hover:underline` instead of the
* Button-variant "outline" style the other two used.
*
* Behavior unified here matches the Quote and Bill variants (which
* were already in sync with each other); the contract variant's
* styling differences are folded in.
*
* **API**
*
* <CustomerPicker
* value={customerAccountId} // number | null
* label={customerLabel} // pre-formatted display label
* isPassive={customerIsPassive} // boolean
* onSelect={(c) => { ... }} // CustomerSummary from search
* onCreate={(c) => { ... }} // CustomerAccountDetail from inline create
* onClear={() => { ... }} // user clicked "Change"
* readOnly={false} // contract editor uses true on edit
* />
*
* The component owns the `customerSearch` state + the debounced
* useQuery against customerAdminService.search and the "+ Create new
* customer" expansion toggle. Parents own the canonical
* `customerAccountId / label / isPassive` triple because each editor
* stores them differently (Quote nests them inside a form object,
* Bill + Contract use separate useStates). Keeping the triple owned
* by the parent avoids a forced shape migration.
*
* **Selection vs creation callbacks**
*
* `onSelect` receives the CustomerSummary shape from the search
* endpoint (id, email, displayName, companyName, firstName, lastName,
* isPassive). `onCreate` receives the full CustomerAccountDetail
* because some editors want to inherit additional fields from a
* freshly-created customer (e.g. Quote inherits the new customer's
* preferredLanguage so the doc renders in their locale by default).
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Button, Input } from '../common';
import { InlineCustomerCreate } from './InlineCustomerCreate';
import {
customerAdminService,
type CustomerAccountDetail,
type CustomerAccountSummary,
} from '../../services/customerAdmin.service';
// Alias for clarity at the call-site: search returns the Summary shape.
export type CustomerSummary = CustomerAccountSummary;
export interface CustomerPickerProps {
value: number | null;
label: string;
isPassive: boolean;
onSelect: (customer: CustomerSummary) => void;
onCreate: (customer: CustomerAccountDetail) => void;
onClear: () => void;
/**
* Read-only mode: render only the selected-row chip, hide search +
* create + change. Used by the contract editor in edit mode where
* the customer is locked to whatever the contract was created with.
*/
readOnly?: boolean;
/**
* Placeholder override for the search box. Defaults to the i18n
* `crm.customerPicker.search` key with an EN fallback. Specific
* editors can pass a doc-type-flavoured label.
*/
searchPlaceholder?: string;
/**
* F.6 — surface a feature-gate badge so the admin sees up front that
* selecting a particular customer won't work for this surface (e.g.
* the calendar's hour-entry drag-create modal would 409 the backend
* on a customer whose `feature_hours_logging` is OFF).
* Currently only 'hoursLogging' is supported; pass undefined to
* skip the badge entirely (default for quote / bill / contract
* editors which don't care about hour-logging eligibility).
*/
requireFeature?: 'hoursLogging';
}
export const CustomerPicker: React.FC<CustomerPickerProps> = ({
value,
label,
isPassive,
onSelect,
onCreate,
onClear,
readOnly = false,
searchPlaceholder,
requireFeature,
}) => {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const [creating, setCreating] = useState(false);
// Debounce the search term before it hits the API. The previous
// shape fired one search request per keystroke; on a passive-
// customer list of 500+ rows, that's hundreds of /api/admin/customers
// calls during a single look-up. 250ms is below the perceptual
// threshold for typing.
const [debouncedSearch, setDebouncedSearch] = useState('');
useEffect(() => {
const handle = window.setTimeout(() => setDebouncedSearch(search), 250);
return () => window.clearTimeout(handle);
}, [search]);
const { data: options = [] } = useQuery({
queryKey: ['crm-customer-picker', debouncedSearch],
queryFn: () => customerAdminService.search(debouncedSearch),
enabled: !readOnly && !value && !creating && debouncedSearch.trim().length >= 2,
});
if (value) {
return (
<div className="flex items-center justify-between bg-neutral-50 dark:bg-neutral-800 rounded-md px-3 py-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm">{label || `#${value}`}</span>
{isPassive && (
<span className="inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300">
{t('customers.passive.badge', 'Passive — admin only')}
</span>
)}
</div>
{!readOnly && (
<Button variant="outline" size="sm" onClick={onClear}>
{t('common.change', 'Change')}
</Button>
)}
</div>
);
}
if (creating) {
return (
<InlineCustomerCreate
onCancel={() => setCreating(false)}
onCreated={(c) => {
onCreate(c);
setCreating(false);
}}
/>
);
}
return (
<>
<Input
placeholder={searchPlaceholder
|| (t('crm.customerPicker.search', 'Search customer by email or company…') as string)}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{options.length > 0 && (
<ul className="mt-2 rounded-md border border-neutral-200 dark:border-neutral-700 divide-y divide-neutral-200 dark:divide-neutral-700">
{options.map((c) => {
// F.6 — gate badge for the calendar's hour-entry create
// modal. Selecting a customer with feature_hours_logging
// OFF would 409 the backend; warn up front. We still allow
// the click so the admin can open the customer's detail
// page to flip the flag from a separate tab.
const hourLoggingOff =
requireFeature === 'hoursLogging' && c.featureHoursLogging === false;
return (
<li key={c.id}>
<button
type="button"
onClick={() => onSelect(c)}
className="w-full text-left px-3 py-2 hover:bg-neutral-50 dark:hover:bg-neutral-800 text-sm"
>
<span className="font-medium">
{c.companyName || c.displayName || c.email}
</span>
<span className="text-neutral-500 ml-2">{c.email}</span>
{c.isPassive && (
<span className="ml-2 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300">
{t('customers.passive.badge', 'Passive — admin only')}
</span>
)}
{hourLoggingOff && (
<span className="ml-2 inline-flex items-center gap-1 text-[11px] px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/40 text-amber-800 dark:text-amber-300">
{t('customers.hoursLoggingDisabled.badge', 'Hour logging disabled')}
</span>
)}
</button>
</li>
);
})}
</ul>
)}
<button
type="button"
onClick={() => setCreating(true)}
className="mt-3 inline-flex items-center gap-1 text-sm text-primary-600 dark:text-primary-400 hover:underline"
>
{t('customers.create.openLink', '+ Create new customer')}
</button>
</>
);
};
@@ -0,0 +1,313 @@
/**
* <DocumentLineageCard> — flat list of every document sharing one
* `deal_uuid` (migration 140), grouped by type. Renders on the three
* detail pages (Quote / Contract / Bill) so the admin sees the
* complete chain — quotes + contracts + invoices (incl. Storno /
* reissue / installment siblings) — without walking individual FKs.
*
* Data comes from `GET /api/admin/deals/:uuid/documents` (commit #3).
* The card is purely presentational; the parent passes the dealUuid
* and a `currentId` so the row representing "the document you're
* looking at" can be highlighted instead of linked.
*
* When no other documents share the deal (newly-created standalone
* doc), the card renders a compact "no related documents" line
* instead of three empty groups — keeps the detail page from looking
* busy on the common case.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { FileText, ScrollText, Receipt, AlertTriangle, Pencil } from 'lucide-react';
import { Button, Card } from '../common';
import { formatMoneyMinor } from '../../utils/money';
import { api } from '../../config/api';
import { EditInstallmentPlanModal } from './EditInstallmentPlanModal';
export interface DocumentLineageCardProps {
dealUuid: string | null | undefined;
/** Which document you're currently looking at. The matching row
* renders muted (not a link) so the admin doesn't navigate to the
* page they're already on. */
current: { kind: 'quote' | 'contract' | 'invoice'; id: number };
className?: string;
}
interface DealItemBase {
id: number;
number: string;
status: string;
currency?: string;
totalAmountMinor?: number;
issueDate?: string;
eventName?: string | null;
createdAt?: string;
}
interface DealQuoteItem extends DealItemBase { kind: 'quote'; validUntil?: string; }
interface DealContractItem extends DealItemBase { kind: 'contract'; title?: string | null; validUntil?: string; }
interface DealInvoiceItem extends DealItemBase {
kind: 'invoice';
invoiceKind: 'invoice' | 'storno';
paidAmountMinor?: number;
dueDate?: string;
eventDate?: string | null;
installmentIndex?: number;
installmentTotal?: number;
installmentLabel?: string | null;
installmentTrigger?: string | null;
installmentOffsetDays?: number;
isMonthlyDraft?: boolean;
}
interface DealLineageResponse {
dealUuid: string;
quotes: DealQuoteItem[];
contracts: DealContractItem[];
invoices: DealInvoiceItem[];
}
const EDITABLE_PLAN_STATUSES = new Set(['scheduled', 'pending_delivery']);
export const DocumentLineageCard: React.FC<DocumentLineageCardProps> = ({
dealUuid, current, className = '',
}) => {
const { t } = useTranslation();
const [showEditPlan, setShowEditPlan] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ['deal-lineage', dealUuid],
queryFn: async () => {
const res = await api.get(`/admin/deals/${dealUuid}/documents`);
return (res.data.data || res.data) as DealLineageResponse;
},
enabled: !!dealUuid,
staleTime: 30_000,
});
if (!dealUuid) return null;
if (isLoading) {
return (
<Card padding="md" className={className}>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('dealLineage.loading', 'Loading related documents…')}
</p>
</Card>
);
}
if (error) {
return (
<Card padding="md" className={className}>
<p className="text-sm text-red-700 dark:text-red-300 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
{t('dealLineage.error', 'Could not load related documents.')}
</p>
</Card>
);
}
if (!data) return null;
const { quotes, contracts, invoices } = data;
const totalCount = quotes.length + contracts.length + invoices.length;
// Reshape gesture is offered when this deal holds a multi-installment
// plan AND every invoice is still pre-customer. Server re-checks on
// save; if a sibling shipped between render and click, the 409 path
// in the modal handles the race.
const hasMultiInstallment = invoices.some((i) => (i.installmentTotal || 0) > 1);
const allInvoicesEditable = invoices.length > 0
&& invoices.every((i) => i.invoiceKind !== 'storno'
&& EDITABLE_PLAN_STATUSES.has(i.status));
const canEditPlan = hasMultiInstallment && allInvoicesEditable;
// Only ONE doc total = the current one. No siblings to surface.
if (totalCount <= 1) {
return (
<Card padding="md" className={className}>
<h2 className="font-semibold mb-1 flex items-center gap-2">
{t('dealLineage.title', 'Related documents')}
</h2>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('dealLineage.empty', 'No other documents share this deal yet. New invoices, contracts, or installments will show up here once created.')}
</p>
</Card>
);
}
return (
<Card padding="md" className={className}>
<h2 className="font-semibold mb-3">
{t('dealLineage.title', 'Related documents')}
</h2>
{quotes.length > 0 && (
<Group
icon={<FileText className="w-4 h-4" />}
label={t('dealLineage.quotes', 'Quotes')}
count={quotes.length}
>
{quotes.map((q) => (
<Row
key={`q-${q.id}`}
isCurrent={current.kind === 'quote' && current.id === q.id}
href={`/admin/clients/quotes/${q.id}`}
number={q.number}
statusKey={`quotes.status.${q.status}`}
statusFallback={q.status}
right={q.totalAmountMinor != null && q.currency
? formatMoneyMinor(q.totalAmountMinor, q.currency)
: null}
meta={q.eventName || undefined}
/>
))}
</Group>
)}
{contracts.length > 0 && (
<Group
icon={<ScrollText className="w-4 h-4" />}
label={t('dealLineage.contracts', 'Contracts')}
count={contracts.length}
>
{contracts.map((c) => (
<Row
key={`c-${c.id}`}
isCurrent={current.kind === 'contract' && current.id === c.id}
href={`/admin/clients/contracts/${c.id}`}
number={c.number}
statusKey={`contracts.status.${c.status}`}
statusFallback={c.status}
meta={c.title || c.eventName || undefined}
/>
))}
</Group>
)}
{invoices.length > 0 && (
<Group
icon={<Receipt className="w-4 h-4" />}
label={t('dealLineage.invoices', 'Invoices')}
count={invoices.length}
action={canEditPlan ? (
<Button
variant="outline"
size="sm"
onClick={() => setShowEditPlan(true)}
leftIcon={<Pencil className="w-3.5 h-3.5" />}
className="ml-auto"
>
{t('dealLineage.editPlan', 'Edit plan')}
</Button>
) : undefined}
>
{invoices.map((i) => {
const isStorno = i.invoiceKind === 'storno';
const installmentTag = i.installmentTotal && i.installmentTotal > 1
? ` · ${i.installmentLabel || `${i.installmentIndex! + 1}/${i.installmentTotal}`}`
: '';
return (
<Row
key={`i-${i.id}`}
isCurrent={current.kind === 'invoice' && current.id === i.id}
href={`/admin/clients/bills/${i.id}`}
number={i.number}
statusKey={`bills.status.${i.status}`}
statusFallback={i.status}
right={i.totalAmountMinor != null && i.currency
? formatMoneyMinor(i.totalAmountMinor, i.currency)
: null}
badge={isStorno ? t('bills.kind.storno', 'Storno') as string : undefined}
meta={installmentTag.replace(/^ · /, '') || undefined}
/>
);
})}
</Group>
)}
{canEditPlan && dealUuid && (
<EditInstallmentPlanModal
isOpen={showEditPlan}
onClose={() => setShowEditPlan(false)}
dealUuid={dealUuid}
siblings={invoices.map((i) => ({
id: i.id,
number: i.number,
status: i.status,
totalAmountMinor: i.totalAmountMinor,
installmentIndex: i.installmentIndex,
installmentTotal: i.installmentTotal,
installmentLabel: i.installmentLabel,
installmentTrigger: i.installmentTrigger,
installmentOffsetDays: i.installmentOffsetDays,
}))}
eventDate={invoices.find((i) => i.eventDate)?.eventDate || null}
/>
)}
</Card>
);
};
const Group: React.FC<{
icon: React.ReactNode;
label: string;
count: number;
children: React.ReactNode;
action?: React.ReactNode;
}> = ({ icon, label, count, children, action }) => (
<div className="mb-3 last:mb-0">
<div className="flex items-center gap-2 text-xs uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-1">
{icon}
<span>{label}</span>
<span>({count})</span>
{action}
</div>
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{children}
</ul>
</div>
);
const Row: React.FC<{
isCurrent: boolean;
href: string;
number: string;
statusKey: string;
statusFallback: string;
right?: string | null;
meta?: string;
badge?: string;
}> = ({ isCurrent, href, number, statusKey, statusFallback, right, meta, badge }) => {
const { t } = useTranslation();
const inner = (
<div className="flex items-center justify-between gap-3 py-1.5">
<div className="flex items-center gap-2 min-w-0">
<span className={`font-mono text-sm ${isCurrent ? 'text-neutral-500 dark:text-neutral-400' : ''}`}>{number}</span>
{badge && (
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300">
{badge}
</span>
)}
{meta && (
<span className="text-xs text-neutral-500 dark:text-neutral-400 truncate">{meta}</span>
)}
</div>
<div className="flex items-center gap-2 shrink-0 text-xs">
{right && <span className="tabular-nums">{right}</span>}
<span className="text-neutral-500 dark:text-neutral-400">{t(statusKey, statusFallback)}</span>
</div>
</div>
);
if (isCurrent) {
return <li className="opacity-60">{inner}</li>;
}
return (
<li>
<Link to={href} className="block hover:bg-neutral-50 dark:hover:bg-neutral-800/40 -mx-2 px-2 rounded">
{inner}
</Link>
</li>
);
};
export default DocumentLineageCard;
@@ -0,0 +1,141 @@
import React, { useState } from 'react';
import { X, Copy } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card, Input, LocalizedDateInput } from '../common';
interface DuplicateEventDialogProps {
sourceEventName: string;
isDuplicating: boolean;
onConfirm: (data: {
event_name: string;
event_date?: string;
customer_name?: string;
customer_email?: string;
}) => void;
onClose: () => void;
}
/**
* "Duplicate gallery" dialog (#626) — admin types a fresh event name + date
* (and optionally a new customer) and the backend clones the source event's
* branding / behaviour / feedback / categories into a new draft. Photos,
* the password, the share token and client-access secrets do NOT carry over —
* those are set fresh on the duplicate. The new event opens in draft mode so
* the admin can finish customising before publishing via the publish dialog
* (#627).
*/
export const DuplicateEventDialog: React.FC<DuplicateEventDialogProps> = ({
sourceEventName,
isDuplicating,
onConfirm,
onClose,
}) => {
const { t } = useTranslation();
const [eventName, setEventName] = useState('');
const [eventDate, setEventDate] = useState('');
const [customerName, setCustomerName] = useState('');
const [customerEmail, setCustomerEmail] = useState('');
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = () => {
if (!eventName.trim()) {
setError(t('events.duplicateDialog.errorNameRequired', 'Event name is required.'));
return;
}
setError(undefined);
onConfirm({
event_name: eventName.trim(),
event_date: eventDate || undefined,
customer_name: customerName.trim() || undefined,
customer_email: customerEmail.trim() || undefined,
});
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-md w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{t('events.duplicateDialog.title', 'Duplicate gallery')}
</h2>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
aria-label={t('common.close', 'Close')}
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
{t('events.duplicateDialog.description', {
sourceEventName,
defaultValue:
'Creates a new draft gallery that inherits the branding, behaviour, feedback, and category configuration from "{{sourceEventName}}". Photos, password, and share tokens are NOT carried over.',
})}
</p>
<div className="space-y-3 mb-4">
<Input
type="text"
label={t('events.duplicateDialog.eventNameLabel', 'New event name *')}
placeholder={t('events.duplicateDialog.eventNamePlaceholder', 'e.g. Müller Wedding 2026')}
value={eventName}
onChange={(e) => {
setEventName(e.target.value);
if (error) setError(undefined);
}}
error={error}
/>
<LocalizedDateInput
label={t('events.duplicateDialog.eventDateLabel', 'Event date')}
value={eventDate}
onChange={setEventDate}
helperText={t(
'events.duplicateDialog.eventDateHelp',
'Leave blank to use a random suffix in the gallery URL. Expiration is recomputed from this date plus the source gallerys expiration window.',
)}
/>
<Input
type="text"
label={t('events.duplicateDialog.customerNameLabel', 'Customer name')}
placeholder={t('events.duplicateDialog.customerNamePlaceholder', 'Optional — fill in later if unknown')}
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
/>
<Input
type="email"
label={t('events.duplicateDialog.customerEmailLabel', 'Customer email')}
placeholder={t('events.duplicateDialog.customerEmailPlaceholder', 'Optional')}
value={customerEmail}
onChange={(e) => setCustomerEmail(e.target.value)}
/>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={isDuplicating}
className="flex-1"
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={isDuplicating}
isLoading={isDuplicating}
leftIcon={<Copy className="w-4 h-4" />}
className="flex-1"
>
{t('events.duplicateDialog.confirm', 'Create duplicate')}
</Button>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,209 @@
/**
* <EditInstallmentPlanModal> — atomic reshape of an installment plan
* after siblings have spawned. Wraps the same `<InstallmentsPanel>`
* used by Quote/Bill editors, pre-filled from the existing siblings
* (via the lineage payload — no extra fetch needed).
*
* Triggered from `<DocumentLineageCard>`, which only renders the
* "Edit plan" button when every invoice on the deal is still
* scheduled / pending_delivery (the same gate enforced server-side).
* The server still re-checks on save; a 409 INVOICE_LOCKED races back
* if a sibling shipped between modal open and save click.
*
* Plan total preservation: the backend uses sum(existing sibling totals)
* as the plan total, so the panel doesn't need to surface money — only
* the structure (percents / labels / triggers / offsets).
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { X, AlertTriangle } from 'lucide-react';
import { Button, Card } from '../common';
import { InstallmentsPanel } from './InstallmentsPanel';
import type { PaymentTermInstallment } from '../../services/quotes.service';
import { dealsService } from '../../services/deals.service';
export interface EditInstallmentPlanModalProps {
isOpen: boolean;
onClose: () => void;
dealUuid: string;
/** Existing sibling invoices on the deal — used to seed the panel
* and to derive the current plan total for percent computation. */
siblings: Array<{
id: number;
number: string;
status: string;
totalAmountMinor?: number;
installmentIndex?: number;
installmentTotal?: number;
installmentLabel?: string | null;
installmentTrigger?: string | null;
installmentOffsetDays?: number;
}>;
/** Event date — passed to <InstallmentsPanel> so its date preview
* works for before_event / after_event rows. */
eventDate?: string | null;
onSaved?: () => void;
}
const VALID_TRIGGERS: PaymentTermInstallment['trigger'][] = [
'quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date',
];
function isValidTrigger(t: unknown): t is PaymentTermInstallment['trigger'] {
return typeof t === 'string' && (VALID_TRIGGERS as string[]).includes(t);
}
export const EditInstallmentPlanModal: React.FC<EditInstallmentPlanModalProps> = ({
isOpen, onClose, dealUuid, siblings, eventDate, onSaved,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// Derive the initial panel rows from the existing siblings, sorted
// by installment_index. Percent = total_amount_minor / sum * 100.
const initialPlan = useMemo<PaymentTermInstallment[]>(() => {
const sorted = [...siblings].sort(
(a, b) => (a.installmentIndex ?? 0) - (b.installmentIndex ?? 0),
);
const sum = sorted.reduce((s, x) => s + (x.totalAmountMinor || 0), 0);
if (sum === 0) {
// Degenerate: every sibling totals zero. Fall back to equal split.
return sorted.map((s, i) => ({
label: s.installmentLabel || `${i + 1}/${sorted.length}`,
percent: Math.round(10000 / sorted.length) / 100,
trigger: isValidTrigger(s.installmentTrigger) ? s.installmentTrigger : 'fixed_date',
offset_days: s.installmentOffsetDays ?? 0,
}));
}
// Compute percents, last slice absorbs rounding so the panel reports
// sum=100 on open.
const rows: PaymentTermInstallment[] = [];
let accPct = 0;
sorted.forEach((s, i) => {
let pct = i === sorted.length - 1
? Math.max(0, 100 - accPct)
: Math.round(((s.totalAmountMinor || 0) / sum) * 10000) / 100;
pct = Math.round(pct * 100) / 100;
accPct += pct;
rows.push({
label: s.installmentLabel || `${i + 1}/${sorted.length}`,
percent: pct,
trigger: isValidTrigger(s.installmentTrigger) ? s.installmentTrigger : 'fixed_date',
offset_days: s.installmentOffsetDays ?? 0,
});
});
return rows;
}, [siblings]);
const [plan, setPlan] = useState<PaymentTermInstallment[] | null>(initialPlan);
const [valid, setValid] = useState(true);
// Reseed when the modal opens against fresh siblings.
useEffect(() => {
if (isOpen) setPlan(initialPlan);
}, [isOpen, initialPlan]);
const save = useMutation({
mutationFn: async () => {
if (!plan || plan.length === 0) {
throw new Error(t('dealLineage.editPlanEmpty', 'Plan cannot be empty.') as string);
}
return dealsService.updateInstallmentPlan(dealUuid, plan);
},
onSuccess: () => {
toast.success(t('dealLineage.editPlanSuccess', 'Installment plan updated.'));
queryClient.invalidateQueries({ queryKey: ['deal-lineage', dealUuid] });
queryClient.invalidateQueries({ queryKey: ['adminBills'] });
queryClient.invalidateQueries({ queryKey: ['admin-invoices'] });
onSaved?.();
onClose();
},
onError: (err: unknown) => {
const e = err as { response?: { data?: { error?: string; code?: string } }; message?: string };
const code = e?.response?.data?.code;
if (code === 'INVOICE_LOCKED' || code === 'PLAN_HAS_STORNO') {
toast.error(t('dealLineage.editPlanLocked',
'Plan can no longer be edited — at least one invoice has shipped or been cancelled.'));
queryClient.invalidateQueries({ queryKey: ['deal-lineage', dealUuid] });
onClose();
return;
}
if (code === 'PERCENT_SUM_INVALID') {
toast.error(t('dealLineage.editPlanPercentSumError', 'Percents must sum to 100.'));
return;
}
toast.error(
e?.response?.data?.error
|| e?.message
|| t('dealLineage.editPlanGeneralError', 'Could not update plan.'),
);
},
});
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex items-start justify-between gap-3 mb-2 flex-wrap">
<div>
<h2 className="text-xl font-semibold">
{t('dealLineage.editPlanModalTitle', 'Edit installment plan')}
</h2>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('dealLineage.editPlanHelp',
'Atomically reshape this plan: change percents, labels, triggers, add or remove rows. The plan total stays fixed; existing invoice numbers are kept where possible. Refused once any invoice has shipped.')}
</p>
</div>
<button
type="button"
onClick={onClose}
disabled={save.isPending}
className="text-neutral-400 hover:text-neutral-600 disabled:opacity-50"
aria-label={t('common.close', 'Close') as string}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="rounded-md border border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700 p-3 mb-3 flex items-start gap-2">
<AlertTriangle className="w-4 h-4 text-amber-700 dark:text-amber-300 mt-0.5 shrink-0" />
<p className="text-sm text-amber-800 dark:text-amber-200">
{t('dealLineage.editPlanWarning',
'Trimming rows deletes their invoice numbers (the sequence cannot release them — a §14 UStG continuity rule). Adding rows claims fresh numbers.')}
</p>
</div>
<InstallmentsPanel
value={plan}
onChange={(next) => setPlan(next || [])}
onValidityChange={setValid}
eventDate={eventDate || null}
/>
<div className="flex justify-end gap-2 mt-4">
<Button
variant="outline"
onClick={onClose}
disabled={save.isPending}
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={() => save.mutate()}
isLoading={save.isPending}
disabled={save.isPending || !valid || !plan || plan.length === 0}
>
{t('dealLineage.editPlanSave', 'Save plan')}
</Button>
</div>
</Card>
</div>
);
};
export default EditInstallmentPlanModal;
@@ -0,0 +1,100 @@
import React from 'react';
import { X, Mail, FileText } from 'lucide-react';
import { Button, Card } from '../common';
interface EmailPreviewModalProps {
isOpen: boolean;
onClose: () => void;
subject: string;
htmlContent: string;
textContent?: string;
}
export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
isOpen,
onClose,
subject,
htmlContent,
textContent
}) => {
const [viewMode, setViewMode] = React.useState<'html' | 'text'>('html');
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-4xl max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex items-center gap-3">
<Mail className="w-6 h-6 text-accent" />
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">Email Preview</h2>
</div>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Subject */}
<div className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800">
<p className="text-sm font-medium text-neutral-600 dark:text-neutral-400">Subject:</p>
<p className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mt-1">{subject}</p>
</div>
{/* View mode toggle */}
<div className="px-6 py-3 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex gap-2">
<Button
variant={viewMode === 'html' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('html')}
leftIcon={<Mail className="w-4 h-4" />}
>
HTML View
</Button>
{textContent && (
<Button
variant={viewMode === 'text' ? 'primary' : 'outline'}
size="sm"
onClick={() => setViewMode('text')}
leftIcon={<FileText className="w-4 h-4" />}
>
Text View
</Button>
)}
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-auto p-6">
{viewMode === 'html' ? (
<div className="bg-white border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-sm">
<iframe
srcDoc={htmlContent}
className="w-full h-[600px] border-0"
title="Email Preview"
sandbox="allow-same-origin"
/>
</div>
) : (
<div className="bg-neutral-50 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg p-6">
<pre className="whitespace-pre-wrap font-mono text-sm text-neutral-700 dark:text-neutral-300">
{textContent}
</pre>
</div>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 p-6 border-t border-neutral-200 dark:border-neutral-700">
<Button variant="outline" onClick={onClose}>
Close
</Button>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,398 @@
import React, { useState, useCallback } from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import HardBreak from '@tiptap/extension-hard-break';
import TextAlign from '@tiptap/extension-text-align';
import {
Bold,
Italic,
List,
ListOrdered,
Link as LinkIcon,
Heading2,
Heading3,
Quote,
Minus,
Undo,
Redo,
RemoveFormatting,
AlignLeft,
AlignCenter,
AlignRight,
Code2,
Variable,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface EmailTemplateEditorProps {
content: string;
onChange: (content: string) => void;
variables?: string[];
}
export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
content,
onChange,
variables = [],
}) => {
const { t } = useTranslation();
const [isSourceMode, setIsSourceMode] = useState(false);
const [sourceContent, setSourceContent] = useState(content);
const [linkUrl, setLinkUrl] = useState('');
const [showLinkDialog, setShowLinkDialog] = useState(false);
const [showVariables, setShowVariables] = useState(false);
const editor = useEditor({
extensions: [
StarterKit.configure({
hardBreak: false,
}),
HardBreak.configure({
keepMarks: true,
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer',
},
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
alignments: ['left', 'center', 'right'],
defaultAlignment: 'left',
}),
],
content,
onUpdate: ({ editor }) => {
const html = editor.getHTML();
onChange(html);
setSourceContent(html);
},
});
// Sync editor when content prop changes externally
React.useEffect(() => {
if (editor && !isSourceMode && content !== editor.getHTML()) {
editor.commands.setContent(content);
setSourceContent(content);
}
}, [content, editor, isSourceMode]);
const handleSourceChange = useCallback((value: string) => {
setSourceContent(value);
onChange(value);
}, [onChange]);
const switchToVisual = useCallback(() => {
if (editor) {
editor.commands.setContent(sourceContent);
}
setIsSourceMode(false);
}, [editor, sourceContent]);
const switchToSource = useCallback(() => {
if (editor) {
setSourceContent(editor.getHTML());
}
setIsSourceMode(true);
}, [editor]);
const insertVariable = useCallback((variable: string) => {
const tag = `{{${variable}}}`;
if (isSourceMode) {
// Insert at cursor in textarea
const textarea = document.querySelector('[data-email-source]') as HTMLTextAreaElement;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newContent = sourceContent.substring(0, start) + tag + sourceContent.substring(end);
setSourceContent(newContent);
onChange(newContent);
// Restore cursor position after React re-render
requestAnimationFrame(() => {
textarea.selectionStart = textarea.selectionEnd = start + tag.length;
textarea.focus();
});
}
} else if (editor) {
editor.chain().focus().insertContent(tag).run();
}
setShowVariables(false);
}, [editor, isSourceMode, sourceContent, onChange]);
const addLink = useCallback(() => {
if (linkUrl && editor) {
editor.chain().focus().setLink({ href: linkUrl }).run();
setLinkUrl('');
setShowLinkDialog(false);
}
}, [editor, linkUrl]);
if (!editor) {
return null;
}
const MenuButton: React.FC<{
onClick: () => void;
active?: boolean;
children: React.ReactNode;
title: string;
disabled?: boolean;
}> = ({ onClick, active, children, title, disabled }) => (
<button
onClick={onClick}
disabled={disabled}
className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-600 transition-colors ${
active
? 'bg-accent-dark/15 text-accent-dark'
: 'text-neutral-700 dark:text-neutral-300'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
title={title}
type="button"
>
{children}
</button>
);
return (
<div className="border border-neutral-300 dark:border-neutral-600 rounded-lg overflow-hidden">
{/* Toolbar */}
<div className="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800">
<div className="flex items-center justify-between p-2">
{/* Formatting buttons */}
<div className="flex items-center gap-0.5 flex-wrap">
{!isSourceMode && (
<>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<Heading2 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor.isActive('heading', { level: 3 })}
title="Heading 3"
>
<Heading3 className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
title={`${t('email.editor.bold')} (Ctrl+B)`}
>
<Bold className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
title={`${t('email.editor.italic')} (Ctrl+I)`}
>
<Italic className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
title={t('email.editor.bulletList')}
>
<List className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
title={t('email.editor.numberedList')}
>
<ListOrdered className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive('blockquote')}
title={t('email.editor.blockquote')}
>
<Quote className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => setShowLinkDialog(true)}
active={editor.isActive('link')}
title={t('email.editor.link')}
>
<LinkIcon className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title={t('email.editor.horizontalRule')}
>
<Minus className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('left').run()}
active={editor.isActive({ textAlign: 'left' })}
title={t('email.editor.alignLeft')}
>
<AlignLeft className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('center').run()}
active={editor.isActive({ textAlign: 'center' })}
title={t('email.editor.alignCenter')}
>
<AlignCenter className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('right').run()}
active={editor.isActive({ textAlign: 'right' })}
title={t('email.editor.alignRight')}
>
<AlignRight className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title={t('email.editor.clearFormatting')}
>
<RemoveFormatting className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title={`${t('email.editor.undo')} (Ctrl+Z)`}
>
<Undo className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title={`${t('email.editor.redo')} (Ctrl+Y)`}
>
<Redo className="w-4 h-4" />
</MenuButton>
</>
)}
</div>
{/* Right side: Variables + Source toggle */}
<div className="flex items-center gap-2">
{variables.length > 0 && (
<div className="relative">
<button
onClick={() => setShowVariables(!showVariables)}
className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-colors ${
showVariables
? 'bg-accent-dark/15 text-accent-dark'
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
}`}
type="button"
>
<Variable className="w-3.5 h-3.5" />
{t('email.editor.insertVariable')}
</button>
{showVariables && (
<div className="absolute right-0 top-full mt-1 z-10 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-600 rounded-lg shadow-lg py-1 min-w-[200px] max-h-[240px] overflow-auto">
{variables.map(variable => (
<button
key={variable}
onClick={() => insertVariable(variable)}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
type="button"
>
<code className="text-accent">{`{{${variable}}}`}</code>
</button>
))}
</div>
)}
</div>
)}
<button
onClick={isSourceMode ? switchToVisual : switchToSource}
className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-colors ${
isSourceMode
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
}`}
type="button"
>
<Code2 className="w-3.5 h-3.5" />
{isSourceMode ? t('email.editor.visualMode') : t('email.editor.sourceMode')}
</button>
</div>
</div>
</div>
{/* Link Dialog */}
{showLinkDialog && (
<div className="p-3 bg-accent-dark/15 border-b border-accent-dark/30 flex items-center gap-2">
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addLink()}
placeholder={t('email.editor.enterUrl')}
className="flex-1 px-3 py-1 text-sm border border-accent-dark/30 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<button
onClick={addLink}
className="px-3 py-1 text-sm bg-accent-dark text-white rounded-md hover:opacity-90"
type="button"
>
{t('email.editor.addLink')}
</button>
<button
onClick={() => { setShowLinkDialog(false); setLinkUrl(''); }}
className="px-3 py-1 text-sm bg-neutral-200 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-md hover:bg-neutral-300 dark:hover:bg-neutral-600"
type="button"
>
{t('email.editor.cancel')}
</button>
</div>
)}
{/* Editor / Source Content Area */}
{isSourceMode ? (
<textarea
data-email-source=""
value={sourceContent}
onChange={(e) => handleSourceChange(e.target.value)}
rows={15}
className="w-full px-3 py-2 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 font-mono text-sm focus:outline-none resize-y"
spellCheck={false}
/>
) : (
<EditorContent
editor={editor}
className="min-h-[300px] p-4 prose prose-neutral dark:prose-invert max-w-none bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 focus:outline-none [&_.ProseMirror]:min-h-[300px] [&_.ProseMirror]:outline-none [&_.ProseMirror]:text-neutral-900 [&_.ProseMirror]:dark:text-neutral-100 [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-400 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0"
/>
)}
</div>
);
};
EmailTemplateEditor.displayName = 'EmailTemplateEditor';
@@ -0,0 +1,39 @@
/**
* Booking-target dropdown for accounting (expenses + incoming invoices).
* "Company" (value null) or a specific event. Projects remain a separate
* aggregation of events and are intentionally NOT a booking target here.
*/
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { eventsService } from '../../services/events.service';
interface Props {
value: number | null;
onChange: (eventId: number | null) => void;
className?: string;
}
export const EventBookingSelect: React.FC<Props> = ({ value, onChange, className }) => {
const { t } = useTranslation();
const { data } = useQuery({
queryKey: ['events-for-booking'],
queryFn: () => eventsService.getEvents(1, 200),
staleTime: 60_000,
});
const events = data?.events ?? [];
const cls = 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';
return (
<select className={cls} value={value == null ? '' : String(value)}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}>
<option value="">{t('accounting.booking.company', 'Company')}</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.event_name}</option>
))}
</select>
);
};
export default EventBookingSelect;
@@ -0,0 +1,379 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud } from 'lucide-react';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { photosService } from '../../services/photos.service';
import { Button, Card, AuthenticatedImage } from '../common';
import { useTranslation } from 'react-i18next';
import { useMutationWithToast, useModal } from '../../hooks';
interface EventCategoryManagerProps {
eventId: number;
}
export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ eventId }) => {
const { t } = useTranslation();
const addingModal = useModal();
const [newCategoryName, setNewCategoryName] = useState('');
const [heroPickerCategoryId, setHeroPickerCategoryId] = useState<number | null>(null);
// Fetch categories for this event
const { data: categories = [], isLoading } = useQuery({
queryKey: ['event-categories', eventId],
queryFn: () => categoriesService.getEventCategories(eventId),
});
// Fetch photos for hero selection
const { data: photos = [] } = useQuery({
queryKey: ['admin-event-photos', eventId, {}],
queryFn: () => photosService.getEventPhotos(eventId, {}),
enabled: heroPickerCategoryId !== null,
});
// Filter to show only event-specific categories
const eventCategories = categories.filter(cat => !cat.is_global);
// Create category mutation
const createMutation = useMutationWithToast({
mutationFn: (name: string) =>
categoriesService.createCategory({
name,
is_global: false,
event_id: eventId
}),
invalidateKeys: [['event-categories', eventId]],
successMessage: t('categories.categoryCreatedSuccess'),
onSuccess: () => {
setNewCategoryName('');
addingModal.close();
},
errorMessage: t('categories.failedToCreateCategory'),
});
// Delete category mutation
const deleteMutation = useMutationWithToast({
mutationFn: categoriesService.deleteCategory,
invalidateKeys: [['event-categories', eventId]],
successMessage: t('categories.categoryDeletedSuccess'),
errorMessage: t('categories.failedToDeleteCategory'),
});
// Set hero photo mutation
const heroMutation = useMutationWithToast({
mutationFn: ({ categoryId, photoId }: { categoryId: number; photoId: number | null }) =>
categoriesService.setCategoryHeroPhoto(categoryId, photoId),
invalidateKeys: [['event-categories', eventId]],
successMessage: (_data, variables) =>
variables.photoId ? t('categories.coverPhotoSet') : t('categories.coverPhotoRemoved'),
onSuccess: () => {
setHeroPickerCategoryId(null);
},
errorMessage: t('categories.failedToSetCoverPhoto'),
});
// Toggle per-category download permission (#640). The backend AND's this
// with the event-level `allow_downloads`, so disabling at either level
// blocks downloads for this category's photos.
const downloadToggleMutation = useMutationWithToast({
mutationFn: ({ category, allow }: { category: PhotoCategory; allow: boolean }) =>
categoriesService.updateCategory(category.id, category.name, { allow_downloads: allow }),
invalidateKeys: [['event-categories', eventId]],
successMessage: (_data, variables) =>
variables.allow
? t('categories.downloadsEnabled', 'Downloads enabled for this category')
: t('categories.downloadsDisabled', 'Downloads disabled for this category'),
errorMessage: t('categories.failedToToggleDownloads', 'Failed to update download permission'),
});
const handleCreate = () => {
if (newCategoryName.trim()) {
createMutation.mutate(newCategoryName.trim());
}
};
const handleDelete = (category: PhotoCategory) => {
if (window.confirm(t('categories.deleteConfirm', { name: category.name }))) {
deleteMutation.mutate(category.id);
}
};
const handleSelectHeroPhoto = (categoryId: number, photoId: number) => {
heroMutation.mutate({ categoryId, photoId });
};
const handleRemoveHeroPhoto = (categoryId: number) => {
heroMutation.mutate({ categoryId, photoId: null });
};
if (isLoading) {
return (
<div className="flex justify-center items-center py-4">
<Loader2 className="w-5 h-5 animate-spin text-accent" />
</div>
);
}
return (
<div className="space-y-3">
<div className="flex justify-between items-center">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.eventSpecificCategories')}</h3>
{!addingModal.isOpen && (
<Button
variant="outline"
size="sm"
onClick={addingModal.open}
leftIcon={<Plus className="w-3 h-3" />}
>
{t('common.add')}
</Button>
)}
</div>
{/* Hint about hero photo fallback */}
<p className="text-xs text-neutral-500 dark:text-neutral-400 italic">
{t('categories.categoryHeroHint')}
</p>
{/* Add new category form */}
{addingModal.isOpen && (
<div className="flex gap-2">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
placeholder={t('categories.categoryName')}
className="flex-1 px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button
variant="primary"
size="sm"
onClick={handleCreate}
disabled={!newCategoryName.trim() || createMutation.isPending}
>
{createMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
t('common.add')
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
addingModal.close();
setNewCategoryName('');
}}
>
{t('common.cancel')}
</Button>
</div>
)}
{/* Event categories list */}
{eventCategories.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
{t('categories.noEventSpecificCategories')}
</p>
) : (
<div className="space-y-2">
{eventCategories.map((category) => {
const heroPhoto = category.hero_photo_id
? photos.find(p => p.id === category.hero_photo_id)
: null;
return (
<div
key={category.id}
className="flex items-center justify-between px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Hero photo thumbnail */}
<button
onClick={() => setHeroPickerCategoryId(category.id)}
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-accent-dark transition-colors flex items-center justify-center"
title={t('categories.setCoverPhoto')}
>
{heroPhoto ? (
<AuthenticatedImage
src={heroPhoto.thumbnail_url || heroPhoto.url}
alt={category.name}
className="w-full h-full object-cover"
/>
) : category.hero_photo_id ? (
<ImageIcon className="w-4 h-4 text-accent" />
) : (
<ImageIcon className="w-4 h-4 text-neutral-300" />
)}
</button>
<span className="text-sm text-neutral-700 dark:text-neutral-300 truncate">{category.name}</span>
</div>
<div className="flex items-center gap-1">
{/* Per-category downloads toggle (#640). Green DownloadCloud
icon when on, struck-through outline when off. The
event-level `allow_downloads` AND's with this — if the
whole event has downloads off, this toggle is cosmetic. */}
<button
onClick={() => downloadToggleMutation.mutate({
category,
allow: category.allow_downloads === false,
})}
className={`p-1 transition-colors ${
category.allow_downloads === false
? 'text-neutral-400 dark:text-neutral-500 hover:text-green-600 dark:hover:text-green-400'
: 'text-green-600 dark:text-green-400 hover:text-neutral-400'
}`}
title={
category.allow_downloads === false
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
}
disabled={downloadToggleMutation.isPending}
>
{downloadToggleMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : category.allow_downloads === false ? (
<Download className="w-3 h-3" />
) : (
<DownloadCloud className="w-3 h-3" />
)}
</button>
<button
onClick={() => handleDelete(category)}
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title={t('categories.deleteCategoryTitle')}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<X className="w-3 h-3" />
)}
</button>
</div>
</div>
);
})}
</div>
)}
{/* Show available global categories */}
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
<div className="space-y-2">
{categories
.filter(cat => cat.is_global)
.map(cat => {
const heroPhoto = cat.hero_photo_id
? photos.find(p => p.id === cat.hero_photo_id)
: null;
return (
<div key={cat.id} className="flex items-center gap-3 px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md">
<button
onClick={() => setHeroPickerCategoryId(cat.id)}
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-accent-dark transition-colors flex items-center justify-center"
title={t('categories.setCoverPhoto')}
>
{heroPhoto ? (
<AuthenticatedImage
src={heroPhoto.thumbnail_url || heroPhoto.url}
alt={cat.name}
className="w-full h-full object-cover"
/>
) : cat.hero_photo_id ? (
<ImageIcon className="w-4 h-4 text-accent" />
) : (
<ImageIcon className="w-4 h-4 text-neutral-300" />
)}
</button>
<span className="text-sm text-neutral-600 dark:text-neutral-400">{cat.name}</span>
</div>
);
})}
</div>
</div>
{/* Hero Photo Picker Modal */}
{heroPickerCategoryId !== null && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<Card className="max-w-4xl w-full max-h-[90vh] overflow-hidden">
<div className="p-6 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('categories.setCoverPhoto')}</h2>
<button
onClick={() => setHeroPickerCategoryId(null)}
className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
{photos.length === 0 ? (
<p className="text-center text-neutral-500 dark:text-neutral-400 py-8">
{t('events.noPhotosAvailable')}
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{photos.map((photo) => {
const currentCategory = categories.find(c => c.id === heroPickerCategoryId);
const isSelected = photo.id === currentCategory?.hero_photo_id;
return (
<div
key={photo.id}
onClick={() => handleSelectHeroPhoto(heroPickerCategoryId, photo.id)}
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
isSelected
? 'border-accent-dark ring-2 ring-primary-500 ring-offset-2'
: 'border-transparent hover:border-neutral-300'
}`}
>
<div className="aspect-square bg-neutral-100 dark:bg-neutral-700">
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover"
/>
</div>
{isSelected && (
<div className="absolute top-2 right-2 bg-accent-dark/150 text-white rounded-full p-1">
<Check className="w-4 h-4" />
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p className="text-white text-xs truncate">{photo.filename}</p>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="p-6 border-t border-neutral-200 dark:border-neutral-700 flex justify-between gap-3">
{categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
<Button
variant="outline"
onClick={() => handleRemoveHeroPhoto(heroPickerCategoryId)}
disabled={heroMutation.isPending}
>
{t('categories.removeCoverPhoto')}
</Button>
)}
<div className="flex-1" />
<Button
variant="outline"
onClick={() => setHeroPickerCategoryId(null)}
>
{t('common.cancel')}
</Button>
</div>
</Card>
</div>
)}
</div>
);
};
EventCategoryManager.displayName = 'EventCategoryManager';
@@ -0,0 +1,174 @@
/**
* <EventReminderOverrideCard>
*
* Per-event override for the pre-event customer reminder (migration
* 143). Mounted once on the EventDetailsPage; admin can:
* - Disable the reminder for THIS event only (no global flip)
* - Override the global "days before" offset (null = inherit)
* - Provide a custom body that overrides the resolved template's
* body for THIS event only (subject still comes from the template)
*
* Saves through the existing PUT /api/admin/events/:id endpoint with
* the three new whitelisted fields (added 2026-05-25):
* - event_reminder_disabled (bool)
* - event_reminder_offset_days (int | null)
* - event_reminder_body_override (string | null)
*
* Behaves correctly on pre-migration installs: if the event object
* doesn't carry the new fields, the form starts blank and "Reset to
* default" is a no-op until admin saves something.
*
* Strings: every label / hint / button goes through `t()` with a
* fallback. The maintainer flagged "no hard coded i18n" in commit #5
* — applying that convention strictly from here forward.
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Bell, BellOff, Save } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { api } from '../../config/api';
import { useMutationWithToast } from '../../hooks';
export interface EventReminderOverrideCardProps {
eventId: number;
/** Initial values from the event row; null when unset. */
initial: {
event_reminder_disabled?: boolean;
event_reminder_offset_days?: number | null;
event_reminder_body_override?: string | null;
};
/** Optional callback so the parent can refresh its event query
* after a save. */
onSaved?: () => void;
}
export const EventReminderOverrideCard: React.FC<EventReminderOverrideCardProps> = ({
eventId, initial, onSaved,
}) => {
const { t } = useTranslation();
const [disabled, setDisabled] = useState<boolean>(!!initial.event_reminder_disabled);
const [offsetDays, setOffsetDays] = useState<string>(
initial.event_reminder_offset_days == null ? '' : String(initial.event_reminder_offset_days),
);
const [bodyOverride, setBodyOverride] = useState<string>(initial.event_reminder_body_override || '');
// Reset local state if the parent's `initial` changes (e.g. after
// the event refetches following an unrelated save).
useEffect(() => {
setDisabled(!!initial.event_reminder_disabled);
setOffsetDays(initial.event_reminder_offset_days == null ? '' : String(initial.event_reminder_offset_days));
setBodyOverride(initial.event_reminder_body_override || '');
}, [initial.event_reminder_disabled, initial.event_reminder_offset_days, initial.event_reminder_body_override]);
const save = useMutationWithToast({
mutationFn: async () => {
const payload: Record<string, unknown> = {
event_reminder_disabled: disabled,
};
// Empty string → null clears the override and inherits global.
if (offsetDays.trim() === '') {
payload.event_reminder_offset_days = null;
} else {
const n = Number(offsetDays);
if (!Number.isFinite(n) || n < 0) {
throw new Error(t('eventReminderOverride.invalidOffset',
'Offset must be a non-negative integer or blank.') as string);
}
payload.event_reminder_offset_days = Math.floor(n);
}
payload.event_reminder_body_override = bodyOverride.trim() === '' ? null : bodyOverride;
await api.put(`/admin/events/${eventId}`, payload);
},
successMessage: t('eventReminderOverride.saved', 'Reminder override saved.'),
invalidateKeys: [['admin-event', eventId], ['adminEvent', eventId]],
onSuccess: () => {
onSaved?.();
},
errorMessage: (err: unknown) => {
const e = err as { message?: string; response?: { data?: { error?: string } } };
return (
e?.response?.data?.error
|| e?.message
|| t('eventReminderOverride.saveError', 'Could not save reminder override.')
);
},
});
return (
<Card padding="lg" className="mt-4">
<div className="flex items-start justify-between gap-3 mb-2 flex-wrap">
<div className="flex items-center gap-2">
{disabled
? <BellOff className="w-5 h-5 text-muted-theme" aria-hidden />
: <Bell className="w-5 h-5" aria-hidden />}
<h2 className="text-lg font-semibold">
{t('eventReminderOverride.title', 'Pre-event reminder')}
</h2>
</div>
<Button
variant="outline"
size="sm"
onClick={() => save.mutate()}
isLoading={save.isPending}
disabled={save.isPending}
leftIcon={<Save className="w-4 h-4" />}
>
{t('eventReminderOverride.save', 'Save override')}
</Button>
</div>
<p className="text-xs text-muted-theme mb-3">
{t('eventReminderOverride.help',
'Per-event override for the customer reminder. Global on-off + default offset live under Settings → Reminder emails. Anything left blank here inherits the global setting / resolved template.')}
</p>
<div className="space-y-3">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={disabled}
onChange={(e) => setDisabled(e.target.checked)}
/>
{t('eventReminderOverride.disabledLabel',
'Disable the reminder for this event (no email goes out)')}
</label>
<div>
<Input
type="number"
min={0}
max={365}
label={t('eventReminderOverride.offsetLabel',
'Days before the event (override) — leave blank to inherit') as string}
value={offsetDays}
onChange={(e) => setOffsetDays(e.target.value)}
placeholder={t('eventReminderOverride.offsetPlaceholder',
'Leave blank to use the global default') as string}
disabled={disabled}
className="md:w-80"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('eventReminderOverride.bodyOverrideLabel',
'Custom body for this event (overrides the resolved template body)')}
</label>
<textarea
rows={6}
className="input w-full text-sm"
placeholder={t('eventReminderOverride.bodyOverridePlaceholder',
'Leave blank to use the template body. Variables like {{customer_name}}, {{event_name}}, {{event_date}} still work here.') as string}
value={bodyOverride}
onChange={(e) => setBodyOverride(e.target.value)}
disabled={disabled}
/>
</div>
</div>
</Card>
);
};
export default EventReminderOverrideCard;
@@ -0,0 +1,315 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { X, AlertCircle, CheckCircle, Loader2, Type, Mail } from 'lucide-react';
import { Button, Input, Card } from '../common';
interface EventRenameDialogProps {
isOpen: boolean;
eventName: string;
eventId: number;
customerEmail?: string;
onClose: () => void;
onRename: (newName: string, resendEmail: boolean) => Promise<{
success: boolean;
data?: {
newSlug: string;
newShareLink: string;
filesRenamed: number;
};
error?: string;
}>;
onValidate: (newName: string) => Promise<{
valid: boolean;
newSlug?: string;
error?: string;
}>;
}
export const EventRenameDialog: React.FC<EventRenameDialogProps> = ({
isOpen,
eventName,
eventId: _eventId,
customerEmail,
onClose,
onRename,
onValidate
}) => {
const { t } = useTranslation();
const [newName, setNewName] = useState(eventName);
const [resendEmail, setResendEmail] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [validationResult, setValidationResult] = useState<{
valid: boolean;
newSlug?: string;
error?: string;
} | null>(null);
const [renameStatus, setRenameStatus] = useState<string | null>(null);
const [renameResult, setRenameResult] = useState<{
success: boolean;
newSlug?: string;
newShareLink?: string;
filesRenamed?: number;
error?: string;
} | null>(null);
// Reset state when dialog opens
useEffect(() => {
if (isOpen) {
setNewName(eventName);
setResendEmail(false);
setValidationResult(null);
setRenameStatus(null);
setRenameResult(null);
}
}, [isOpen, eventName]);
// Debounced validation
useEffect(() => {
if (!isOpen || newName.trim() === eventName.trim() || newName.trim().length < 3) {
setValidationResult(null);
return;
}
const timeoutId = setTimeout(async () => {
setIsValidating(true);
try {
const result = await onValidate(newName.trim());
setValidationResult(result);
} catch (error) {
setValidationResult({ valid: false, error: 'Validation failed' });
} finally {
setIsValidating(false);
}
}, 500);
return () => clearTimeout(timeoutId);
}, [newName, eventName, isOpen, onValidate]);
const handleRename = async () => {
if (!validationResult?.valid) return;
setIsRenaming(true);
setRenameStatus(t('events.rename.validating', 'Validating new name...'));
try {
setRenameStatus(t('events.rename.renamingFiles', 'Renaming files...'));
const result = await onRename(newName.trim(), resendEmail);
if (result.success) {
setRenameStatus(t('events.rename.complete', 'Complete!'));
setRenameResult({
success: true,
newSlug: result.data?.newSlug,
newShareLink: result.data?.newShareLink,
filesRenamed: result.data?.filesRenamed
});
} else {
setRenameResult({
success: false,
error: result.error || 'Rename failed'
});
}
} catch (error: any) {
setRenameResult({
success: false,
error: error.message || 'Rename failed'
});
} finally {
setIsRenaming(false);
setRenameStatus(null);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-lg w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">
{t('events.rename.title', 'Rename Event')}
</h2>
<button
onClick={onClose}
disabled={isRenaming}
className="text-neutral-400 hover:text-neutral-600 disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
{renameResult?.success ? (
// Success state
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-green-50 rounded-lg">
<CheckCircle className="w-6 h-6 text-green-600 flex-shrink-0" />
<div>
<p className="font-medium text-green-900">
{t('events.rename.success', 'Event renamed successfully!')}
</p>
{renameResult.filesRenamed !== undefined && renameResult.filesRenamed > 0 && (
<p className="text-sm text-green-700 mt-1">
{t('events.rename.filesRenamed', '{{count}} files updated', { count: renameResult.filesRenamed })}
</p>
)}
</div>
</div>
{renameResult.newShareLink && (
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-200 mb-1">
{t('events.rename.newLink', 'New Gallery Link')}
</p>
<p className="text-sm text-neutral-900 dark:text-neutral-100 break-all">{renameResult.newShareLink}</p>
</div>
)}
<div className="flex justify-end">
<Button variant="primary" onClick={onClose}>
{t('common.done', 'Done')}
</Button>
</div>
</div>
) : renameResult?.error ? (
// Error state
<div className="space-y-4">
<div className="flex items-center gap-3 p-4 bg-red-50 rounded-lg">
<AlertCircle className="w-6 h-6 text-red-600 flex-shrink-0" />
<div>
<p className="font-medium text-red-900">
{t('events.rename.failed', 'Rename failed')}
</p>
<p className="text-sm text-red-700 mt-1">{renameResult.error}</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setRenameResult(null)}>
{t('common.retry', 'Retry')}
</Button>
<Button variant="primary" onClick={onClose}>
{t('common.close', 'Close')}
</Button>
</div>
</div>
) : isRenaming ? (
// Renaming in progress
<div className="space-y-4 py-8">
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-10 h-10 text-accent animate-spin" />
<p className="text-neutral-700 font-medium">{renameStatus}</p>
</div>
</div>
) : (
// Input form
<div className="space-y-4">
<div>
<p className="text-sm text-neutral-600 mb-3">
{t('events.rename.currentName', 'Current name:')} <span className="font-medium">{eventName}</span>
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.rename.newName', 'New Event Name')}
</label>
<Input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('events.rename.enterNewName', 'Enter new event name')}
leftIcon={<Type className="w-5 h-5 text-neutral-400" />}
autoFocus
/>
</div>
{/* New slug preview */}
{validationResult?.valid && validationResult.newSlug && (
<div className="p-3 bg-green-50 rounded-lg">
<p className="text-sm text-green-800">
<span className="font-medium">{t('events.rename.newUrl', 'New URL:')}</span>{' '}
<span className="break-all">/gallery/{validationResult.newSlug}/...</span>
</p>
</div>
)}
{/* Validation status */}
{isValidating && (
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Loader2 className="w-4 h-4 animate-spin" />
{t('events.rename.checkingAvailability', 'Checking availability...')}
</div>
)}
{validationResult && !validationResult.valid && (
<div className="flex items-center gap-2 p-3 bg-red-50 rounded-lg">
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0" />
<p className="text-sm text-red-700">{validationResult.error}</p>
</div>
)}
{/* Resend email option */}
{customerEmail && (
<div className="pt-2 border-t border-neutral-200">
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={resendEmail}
onChange={(e) => setResendEmail(e.target.checked)}
className="mt-1 w-4 h-4 text-accent border-neutral-300 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 flex items-center gap-1">
<Mail className="w-4 h-4" />
{t('events.rename.resendEmail', 'Resend invitation email with new gallery link')}
</span>
<p className="text-xs text-neutral-500 mt-1">
{t('events.rename.emailTo', 'Send updated gallery access email to')} {customerEmail}
</p>
</div>
</label>
</div>
)}
{/* Warning */}
<div className="p-3 bg-amber-50 rounded-lg border border-amber-200">
<div className="flex gap-2">
<AlertCircle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-800">
<p className="font-medium">{t('events.rename.warningTitle', 'Please note:')}</p>
<ul className="mt-1 list-disc list-inside space-y-1">
<li>{t('events.rename.warning1', 'The gallery URL will change')}</li>
<li>{t('events.rename.warning2', 'Old URLs will automatically redirect to the new URL')}</li>
<li>{t('events.rename.warning3', 'Photo files may be renamed')}</li>
</ul>
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
onClick={handleRename}
disabled={
!validationResult?.valid ||
isValidating ||
newName.trim() === eventName.trim() ||
newName.trim().length < 3
}
>
{t('events.rename.confirm', 'Rename Event')}
</Button>
</div>
</div>
)}
</Card>
</div>
);
};
EventRenameDialog.displayName = 'EventRenameDialog';
@@ -0,0 +1,129 @@
import React, { useState } from 'react';
import { X, Copy, Check, Download } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Button, Card } from '../common';
interface ExportPreviewModalProps {
format: 'txt' | 'csv';
content: string;
filename: string;
onClose: () => void;
}
/**
* Inline preview for text-based photo exports (#631).
*
* Daniel reported in #623 that the Lightroom TXT export was effectively
* unusable as a file download — admins re-open the file, select-all, copy,
* paste into Lightroom's search field. He suggested a modal with a
* copy-to-clipboard button as a follow-up. Same workflow applies to the
* CSV export (paste straight into Sheets / Excel).
*
* The modal preserves the file-download path so admins who want the file
* (sharing with colleagues, archiving, post-processing tooling) aren't
* worse off. XMP (ZIP archive) and JSON exports stay direct downloads —
* neither makes sense as a textarea preview.
*/
export const ExportPreviewModal: React.FC<ExportPreviewModalProps> = ({
format,
content,
filename,
onClose,
}) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
toast.success(t('export.preview.copied', 'Copied to clipboard.'));
setTimeout(() => setCopied(false), 2000);
} catch {
// Some browsers (older Safari, hardened sandboxes) reject
// clipboard writes. Fall back to manual select-all so the admin
// can Cmd/Ctrl+C themselves; doesn't fail silently.
toast.error(
t('export.preview.copyFailed', 'Clipboard write blocked. Select the text and copy manually.'),
);
}
};
const handleDownload = () => {
const blob = new Blob([content], {
type: format === 'csv' ? 'text/csv;charset=utf-8' : 'text/plain;charset=utf-8',
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
};
const titleKey = format === 'csv' ? 'export.preview.titleCsv' : 'export.preview.titleTxt';
const titleDefault = format === 'csv' ? 'CSV export' : 'Lightroom filename list';
const helpKey = format === 'csv'
? 'export.preview.helpCsv'
: 'export.preview.helpTxt';
const helpDefault = format === 'csv'
? 'Paste into a spreadsheet (Google Sheets, Excel, Numbers) — the first row is the column header.'
: 'Paste into Lightroom\'s filename search. The list is comma-separated with no extension so it matches a catalog that holds RAW files.';
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-2xl w-full">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{t(titleKey, titleDefault)}
</h2>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
aria-label={t('common.close', 'Close')}
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-3">
{t(helpKey, helpDefault)}
</p>
<textarea
readOnly
value={content}
onClick={(e) => (e.target as HTMLTextAreaElement).select()}
className="w-full h-64 p-3 rounded-md border border-neutral-300 dark:border-neutral-600 bg-neutral-50 dark:bg-neutral-900 text-sm font-mono text-neutral-900 dark:text-neutral-100 mb-4"
/>
<div className="flex gap-2 justify-between items-center">
<span className="text-xs text-neutral-500 dark:text-neutral-400 font-mono truncate">
{filename}
</span>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleDownload}
leftIcon={<Download className="w-4 h-4" />}
>
{t('export.preview.download', 'Download as file')}
</Button>
<Button
variant="primary"
onClick={handleCopy}
leftIcon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
>
{copied
? t('export.preview.copiedShort', 'Copied')
: t('export.preview.copyButton', 'Copy to clipboard')}
</Button>
</div>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,211 @@
import React from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
MessageSquare,
EyeOff,
Trash2,
CheckCircle,
User
} from 'lucide-react';
import { Card, Loading, Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { feedbackService, type FeedbackResponse, type PhotoFeedback } from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useModal } from '../../hooks';
interface FeedbackModerationPanelProps {
eventId: number;
className?: string;
compact?: boolean;
maxItems?: number;
}
export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = ({
eventId,
className = '',
compact = false,
maxItems = 5
}) => {
const { t } = useTranslation();
const { formatDateTime } = useLocalizedDate();
const queryClient = useQueryClient();
const showAllModal = useModal();
// Fetch pending feedback
const { data: feedbackData, isLoading } = useQuery<FeedbackResponse>({
queryKey: ['event-feedback-moderation', eventId],
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
type: 'comment',
status: 'pending',
limit: showAllModal.isOpen ? 100 : maxItems
}),
refetchInterval: 30000 // Refresh every 30 seconds
});
// Moderation mutation
const moderateMutation = useMutation({
mutationFn: ({ feedbackId, action }: { feedbackId: string; action: 'approve' | 'hide' | 'reject' }) =>
feedbackService.moderateFeedback(feedbackId, action),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-feedback-moderation', eventId] });
toast.success(t('feedback.moderationSuccess'));
}
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: (feedbackId: string) => feedbackService.deleteFeedback(feedbackId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-feedback-moderation', eventId] });
toast.success(t('feedback.deleted'));
}
});
if (isLoading) {
return (
<Card className={className}>
<div className="p-6">
<Loading />
</div>
</Card>
);
}
const pendingComments: PhotoFeedback[] = feedbackData?.feedback || [];
const hasPending = pendingComments.length > 0;
return (
<Card className={className}>
<div className={compact ? 'p-4' : 'p-6'}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-900">
{t('feedback.pendingModeration', 'Pending Moderation')}
</h2>
{hasPending && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
{pendingComments.length} {t('feedback.pending', 'pending')}
</span>
)}
</div>
{!hasPending ? (
<div className="text-center py-8">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-3" />
<p className="text-neutral-600 dark:text-neutral-300">{t('feedback.noPendingComments', 'No comments pending moderation')}</p>
</div>
) : (
<div className="space-y-3">
{pendingComments.slice(0, showAllModal.isOpen ? undefined : maxItems).map((item) => (
<div key={item.id} className="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 hover:bg-neutral-50 dark:hover:bg-neutral-800">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
<div className="w-10 h-10 bg-neutral-100 dark:bg-neutral-800 rounded-full flex items-center justify-center">
<User className="w-5 h-5 text-neutral-600 dark:text-neutral-300" />
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{item.guest_name || t('feedback.anonymous', 'Anonymous')}
</span>
<span className="text-neutral-500 dark:text-neutral-400"></span>
<span className="text-neutral-500 dark:text-neutral-400">
{formatDateTime(item.created_at)}
</span>
</div>
<p className="mt-1 text-sm text-neutral-700">{item.comment_text || item.comment}</p>
{item.photo_id && (
<div className="mt-2 flex items-center gap-2">
<div className="w-16 h-16 overflow-hidden rounded">
<AdminAuthenticatedImage
src={`/admin/photos/${eventId}/thumbnail/${item.photo_id}`}
alt={item.filename || 'Photo'}
className="w-16 h-16 object-cover rounded"
/>
</div>
<p className="text-xs text-neutral-500">
{t('feedback.onPhoto', 'On photo')}: {item.filename || item.photo_filename || `#${item.photo_id}`}
</p>
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 mt-3">
<Button
size="sm"
variant="ghost"
leftIcon={<CheckCircle className="w-4 h-4" />}
onClick={() => moderateMutation.mutate({
feedbackId: item.id.toString(),
action: 'approve'
})}
isLoading={moderateMutation.isPending}
>
{t('feedback.approve', 'Approve')}
</Button>
<Button
size="sm"
variant="ghost"
leftIcon={<EyeOff className="w-4 h-4" />}
onClick={() => moderateMutation.mutate({
feedbackId: item.id.toString(),
action: 'hide'
})}
isLoading={moderateMutation.isPending}
>
{t('feedback.hide', 'Hide')}
</Button>
<Button
size="sm"
variant="ghost"
leftIcon={<Trash2 className="w-4 h-4" />}
onClick={() => {
if (confirm(t('feedback.confirmDelete', 'Are you sure you want to delete this comment?'))) {
deleteMutation.mutate(item.id.toString());
}
}}
isLoading={deleteMutation.isPending}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
{t('common.delete', 'Delete')}
</Button>
</div>
</div>
</div>
</div>
))}
{pendingComments.length > maxItems && !showAllModal.isOpen && (
<button
onClick={showAllModal.open}
className="w-full text-center py-2 text-sm text-accent hover:opacity-80 font-medium"
>
{t('feedback.showAll', 'Show all {{count}} pending comments', { count: pendingComments.length })}
</button>
)}
</div>
)}
{/* Quick link to full feedback page */}
<div className="mt-4 pt-4 border-t border-neutral-200">
<a
href={`/admin/events/${eventId}/feedback`}
className="text-sm text-accent hover:opacity-80 font-medium flex items-center gap-1"
>
<MessageSquare className="w-4 h-4" />
{t('feedback.viewAllFeedback', 'View all feedback & settings')}
</a>
</div>
</div>
</Card>
);
};
FeedbackModerationPanel.displayName = 'FeedbackModerationPanel';
@@ -0,0 +1,409 @@
import React from 'react';
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye, User, Users } from 'lucide-react';
import { Card } from '../common';
import { useTranslation } from 'react-i18next';
interface FeedbackSettingsProps {
settings: FeedbackSettings;
onChange: (settings: FeedbackSettings) => void;
className?: string;
}
interface FeedbackSettings {
feedback_enabled: boolean;
allow_ratings: boolean;
allow_likes: boolean;
allow_comments: boolean;
allow_favorites: boolean;
require_name_email: boolean;
moderate_comments: boolean;
show_feedback_to_guests: boolean;
enable_rate_limiting: boolean;
rate_limit_window_minutes?: number;
rate_limit_max_requests?: number;
identity_mode?: 'simple' | 'guest';
// Per-guest caps (#655). null/0 = unlimited.
max_favorites_per_guest?: number | null;
max_likes_per_guest?: number | null;
}
export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
settings,
onChange,
className = ''
}) => {
const { t } = useTranslation();
const handleToggle = (field: keyof FeedbackSettings) => {
onChange({
...settings,
[field]: !settings[field]
});
};
const handleNumberChange = (field: keyof FeedbackSettings, value: string) => {
const numValue = parseInt(value, 10);
if (!isNaN(numValue)) {
onChange({
...settings,
[field]: numValue
});
}
};
return (
<Card className={className}>
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<MessageSquare className="w-5 h-5" />
{t('feedback.settings.title', 'Guest Feedback Settings')}
</h2>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={settings.feedback_enabled}
onChange={() => handleToggle('feedback_enabled')}
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('feedback.settings.enableFeedback', 'Enable feedback')}
</span>
</label>
</div>
{settings.feedback_enabled && (
<>
{/* Identity Mode */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('feedback.settings.identityMode', 'Identity Mode')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<label
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
(settings.identity_mode || 'simple') === 'simple'
? 'border-accent-dark bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}
>
<input
type="radio"
name="identity_mode"
value="simple"
checked={(settings.identity_mode || 'simple') === 'simple'}
onChange={() => onChange({ ...settings, identity_mode: 'simple' })}
className="mt-0.5 w-4 h-4 text-accent focus:ring-primary-500"
/>
<User className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.identityModeSimple', 'Simple feedback')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t(
'feedback.settings.identityModeSimpleDesc',
'Anonymous, device-based. All visitors on the same device share state.'
)}
</div>
</div>
</label>
<label
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
settings.identity_mode === 'guest'
? 'border-accent-dark bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}
>
<input
type="radio"
name="identity_mode"
value="guest"
checked={settings.identity_mode === 'guest'}
onChange={() => onChange({ ...settings, identity_mode: 'guest' })}
className="mt-0.5 w-4 h-4 text-accent focus:ring-primary-500"
/>
<Users className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.identityModeGuest', 'Per-guest selections')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t(
'feedback.settings.identityModeGuestDesc',
'Each visitor enters their name. Enables per-guest tracking and admin insights.'
)}
</div>
</div>
</label>
</div>
</div>
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4" />
{/* Feedback Types */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('feedback.settings.feedbackTypes', 'Feedback Types')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700">
<input
type="checkbox"
checked={settings.allow_ratings}
onChange={() => handleToggle('allow_ratings')}
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<Star className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.ratings', 'Star Ratings')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('feedback.settings.ratingsDesc', 'Allow guests to rate photos (1-5 stars)')}
</div>
</div>
</label>
<label className="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700">
<input
type="checkbox"
checked={settings.allow_likes}
onChange={() => handleToggle('allow_likes')}
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<Heart className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.likes', 'Likes')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('feedback.settings.likesDesc', 'Simple like/unlike functionality')}
</div>
</div>
</label>
<label className="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700">
<input
type="checkbox"
checked={settings.allow_comments}
onChange={() => handleToggle('allow_comments')}
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<MessageSquare className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.comments', 'Comments')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('feedback.settings.commentsDesc', 'Text comments on photos')}
</div>
</div>
</label>
<label className="flex items-center gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700">
<input
type="checkbox"
checked={settings.allow_favorites}
onChange={() => handleToggle('allow_favorites')}
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<Bookmark className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.favorites', 'Favorites')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('feedback.settings.favoritesDesc', 'Mark photos as favorites')}
</div>
</div>
</label>
</div>
</div>
{/* Per-guest caps (#655). Two numeric inputs; 0 / empty = unlimited.
Only renders when the matching toggle is on — the cap is
meaningless if the type itself is disabled. */}
{(settings.allow_favorites || settings.allow_likes) && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('feedback.settings.perGuestLimits', 'Per-guest limits')}
</h3>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t(
'feedback.settings.perGuestLimitsDesc',
'Cap how many photos each guest can favorite or like — useful for "pick your top N for the album" workflows. Leave at 0 for no limit. Lowering a cap below an existing guest\'s count keeps their existing rows; only new adds are blocked until they remove some.',
)}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{settings.allow_favorites && (
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">
{t('feedback.settings.maxFavoritesPerGuest', 'Max favorites per guest')}
</label>
<input
type="number"
min={0}
max={10000}
step={1}
value={settings.max_favorites_per_guest ?? 0}
onChange={(e) => onChange({
...settings,
max_favorites_per_guest: Math.max(0, parseInt(e.target.value, 10) || 0),
})}
className="w-32 px-2 py-1 text-sm border border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('feedback.settings.maxFavoritesPerGuestHint', '0 = unlimited')}
</p>
</div>
)}
{settings.allow_likes && (
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
<label className="block text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-1">
{t('feedback.settings.maxLikesPerGuest', 'Max likes per guest')}
</label>
<input
type="number"
min={0}
max={10000}
step={1}
value={settings.max_likes_per_guest ?? 0}
onChange={(e) => onChange({
...settings,
max_likes_per_guest: Math.max(0, parseInt(e.target.value, 10) || 0),
})}
className="w-32 px-2 py-1 text-sm border border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
/>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('feedback.settings.maxLikesPerGuestHint', '0 = unlimited')}
</p>
</div>
)}
</div>
</div>
)}
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4" />
{/* Privacy & Moderation */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('feedback.settings.privacyModeration', 'Privacy & Moderation')}
</h3>
<div className="space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.require_name_email}
onChange={() => handleToggle('require_name_email')}
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.requireInfo', 'Require Name & Email')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('feedback.settings.requireInfoDesc', 'Guests must provide name and email to leave feedback')}
</div>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.moderate_comments}
onChange={() => handleToggle('moderate_comments')}
disabled={!settings.allow_comments}
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500 disabled:opacity-50"
/>
<Shield className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.moderateComments', 'Moderate Comments')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('feedback.settings.moderateCommentsDesc', 'Comments require approval before being visible')}
</div>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.show_feedback_to_guests}
onChange={() => handleToggle('show_feedback_to_guests')}
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<Eye className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.showToGuests', 'Show Feedback to Guests')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('feedback.settings.showToGuestsDesc', 'Other guests can see ratings, likes, and approved comments')}
</div>
</div>
</label>
</div>
</div>
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4" />
{/* Rate Limiting */}
<div className="space-y-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={settings.enable_rate_limiting}
onChange={() => handleToggle('enable_rate_limiting')}
className="w-4 h-4 text-accent bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
/>
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.enableRateLimiting', 'Enable Rate Limiting')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t('feedback.settings.rateLimitingDesc', 'Prevent spam by limiting feedback frequency')}
</div>
</div>
</label>
{settings.enable_rate_limiting && (
<div className="grid grid-cols-2 gap-4 ml-7">
<div>
<label className="block text-xs font-medium text-neutral-600 dark:text-neutral-400 mb-1">
{t('feedback.settings.timeWindow', 'Time Window (minutes)')}
</label>
<input
type="number"
min="1"
max="60"
value={settings.rate_limit_window_minutes || 15}
onChange={(e) => handleNumberChange('rate_limit_window_minutes', e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-primary-500 focus:border-accent-dark"
/>
</div>
<div>
<label className="block text-xs font-medium text-neutral-600 dark:text-neutral-400 mb-1">
{t('feedback.settings.maxRequests', 'Max Requests')}
</label>
<input
type="number"
min="1"
max="100"
value={settings.rate_limit_max_requests || 10}
onChange={(e) => handleNumberChange('rate_limit_max_requests', e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-neutral-300 dark:border-neutral-600 rounded-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-primary-500 focus:border-accent-dark"
/>
</div>
</div>
)}
</div>
</>
)}
</div>
</Card>
);
};
@@ -0,0 +1,117 @@
import React, { useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { AuthenticatedImage, Button } from '../common';
interface FocalPointPickerProps {
imageUrl: string;
currentValue: string;
onChange: (value: string) => void;
slug?: string;
}
/** Convert legacy keyword to percentage pair */
const keywordToPercent = (value: string): string => {
switch (value) {
case 'top': return '50% 0%';
case 'center': return '50% 50%';
case 'bottom': return '50% 100%';
default: return value || '50% 50%';
}
};
/** Parse an anchor value (keyword or "X% Y%") into [x, y] numbers 0-100 */
const parseAnchor = (value: string): [number, number] => {
const pct = keywordToPercent(value);
const match = pct.match(/^(\d{1,3})%\s+(\d{1,3})%$/);
if (match) return [parseInt(match[1]), parseInt(match[2])];
return [50, 50];
};
export const FocalPointPicker: React.FC<FocalPointPickerProps> = ({
imageUrl,
currentValue,
onChange,
slug,
}) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const [x, y] = parseAnchor(currentValue);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const px = Math.round(Math.min(100, Math.max(0, ((e.clientX - rect.left) / rect.width) * 100)));
const py = Math.round(Math.min(100, Math.max(0, ((e.clientY - rect.top) / rect.height) * 100)));
onChange(`${px}% ${py}%`);
},
[onChange],
);
const presets: { label: string; value: string }[] = [
{ label: t('events.heroImageAnchorTop', 'Top'), value: '50% 0%' },
{ label: t('events.heroImageAnchorCenter', 'Center'), value: '50% 50%' },
{ label: t('events.heroImageAnchorBottom', 'Bottom'), value: '50% 100%' },
];
return (
<div>
{/* Clickable image preview */}
<div
ref={containerRef}
onClick={handleClick}
className="relative w-full h-48 rounded-lg overflow-hidden cursor-crosshair border border-neutral-300"
>
<AuthenticatedImage
src={imageUrl}
alt={t('events.heroPreview', 'Hero preview')}
className="w-full h-full object-cover pointer-events-none"
style={{ objectPosition: `${x}% ${y}%` }}
slug={slug}
/>
{/* Crosshair marker */}
<div
className="absolute pointer-events-none"
style={{ left: `${x}%`, top: `${y}%`, transform: 'translate(-50%, -50%)' }}
>
{/* Outer ring (dark) for contrast on light areas */}
<div className="w-6 h-6 rounded-full border-2 border-black/50" />
{/* Inner ring (white) for contrast on dark areas */}
<div className="absolute inset-0 m-px w-6 h-6 rounded-full border-2 border-white" />
{/* Center dot */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-white shadow-sm" />
</div>
</div>
{/* Coordinate label */}
<span className="absolute bottom-1.5 right-1.5 px-1.5 py-0.5 text-[10px] font-mono leading-none text-white bg-black/60 rounded">
{x}% {y}%
</span>
</div>
{/* Preset buttons */}
<div className="flex gap-2 mt-2">
{presets.map((p) => (
<Button
key={p.value}
type="button"
variant="outline"
size="sm"
onClick={() => onChange(p.value)}
className={
keywordToPercent(currentValue) === p.value
? 'bg-accent-dark/15 border-accent-dark/30 text-accent-dark'
: ''
}
>
{p.label}
</Button>
))}
</div>
</div>
);
};
FocalPointPicker.displayName = 'FocalPointPicker';
@@ -0,0 +1,347 @@
import React, { useMemo } from 'react';
import { Camera, Calendar } from 'lucide-react';
import { ThemeConfig, GalleryLayoutType, HeroDividerStyle } from '../../types/theme.types';
import { buildResourceUrl } from '../../utils/url';
import { useTranslation } from 'react-i18next';
interface GalleryPreviewBranding {
company_name?: string;
company_tagline?: string;
logo_url?: string;
logo_url_dark?: string;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
logo_position?: 'left' | 'center' | 'right';
}
interface GalleryPreviewProps {
theme: ThemeConfig;
branding?: GalleryPreviewBranding;
layoutType?: GalleryLayoutType;
className?: string;
}
// Mock photo data for preview
const generateMockPhotos = (count: number) => {
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
filename: `photo-${i + 1}.jpg`,
url: '',
thumbnail_url: '',
type: i % 3 === 0 ? 'collage' : 'individual',
category_id: (i % 4) + 1,
category_name: ['Ceremony', 'Reception', 'Portraits', 'Party'][i % 4],
category_slug: ['ceremony', 'reception', 'portraits', 'party'][i % 4],
size: Math.floor(Math.random() * 5000000) + 1000000,
uploaded_at: new Date().toISOString(),
}));
};
// Preview photo component
const PreviewPhoto: React.FC<{
photo: any;
className?: string;
aspectRatio?: string;
}> = ({
photo,
className = '',
aspectRatio = 'aspect-square'
}) => (
<div className={`relative overflow-hidden rounded-lg bg-gradient-to-br from-neutral-200 to-neutral-300 ${aspectRatio} ${className}`}>
<div className="absolute inset-0 flex items-center justify-center">
<Camera className="w-8 h-8 text-neutral-400" />
</div>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p className="text-white text-xs truncate">{photo.filename}</p>
{photo.category_name && (
<p className="text-white/70 text-[10px]">{photo.category_name}</p>
)}
</div>
{photo.type === 'collage' && (
<div className="absolute top-1 right-1">
<span className="px-1.5 py-0.5 bg-black/60 text-white text-[10px] rounded">
Collage
</span>
</div>
)}
</div>
);
export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
theme,
branding,
layoutType,
className = ''
}) => {
const { t } = useTranslation();
const mockPhotos = useMemo(() => generateMockPhotos(12), []);
// Use the provided layoutType or fallback to theme's gallery layout
const activeLayout = layoutType || theme.galleryLayout || 'grid';
const displayMode = branding?.logo_display_mode || 'logo_and_text';
const showLogo = displayMode === 'logo_only' || displayMode === 'logo_and_text';
const showText = displayMode === 'text_only' || displayMode === 'logo_and_text';
const brandName = branding?.company_name?.trim() || 'Your Studio';
const brandTagline = branding?.company_tagline?.trim() || '';
// Theme-aware logo with symmetric fallback — mirror the live surfaces
// so the preview reflects what the gallery will actually show.
const previewLogo = theme.colorMode === 'dark'
? (branding?.logo_url_dark || branding?.logo_url)
: (branding?.logo_url || branding?.logo_url_dark);
const resolvedLogoUrl = showLogo && previewLogo
? (previewLogo.startsWith('http') ? previewLogo : buildResourceUrl(previewLogo))
: null;
const logoPosition = branding?.logo_position || 'left';
const brandFlexClass = logoPosition === 'center'
? 'justify-center text-center'
: logoPosition === 'right'
? 'justify-end text-right flex-row-reverse'
: 'justify-start text-left';
// Check header style
const isHeroHeader = theme.headerStyle === 'hero';
const isMinimalHeader = theme.headerStyle === 'minimal';
const isNoHeader = theme.headerStyle === 'none';
const heroDividerStyle: HeroDividerStyle = theme.heroDividerStyle || 'wave';
// Render hero divider based on style
const renderHeroDivider = () => {
const bgColor = theme.backgroundColor || '#fafafa';
switch (heroDividerStyle) {
case 'wave':
return (
<svg className="w-full h-6" viewBox="0 0 1200 120" preserveAspectRatio="none">
<path d="M0,60 C150,90 350,30 600,60 C850,90 1050,30 1200,60 L1200,120 L0,120 Z" fill={bgColor} />
</svg>
);
case 'curve':
return (
<svg className="w-full h-6" viewBox="0 0 1200 120" preserveAspectRatio="none">
<path d="M0,120 Q600,0 1200,120 L1200,120 L0,120 Z" fill={bgColor} />
</svg>
);
case 'angle':
return (
<svg className="w-full h-6" viewBox="0 0 1200 120" preserveAspectRatio="none">
<path d="M0,120 L600,40 L1200,120 L1200,120 L0,120 Z" fill={bgColor} />
</svg>
);
case 'straight':
case 'none':
default:
return null;
}
};
const renderLayout = () => {
const spacing = theme.gallerySettings?.spacing || 'normal';
const gapClass = spacing === 'tight' ? 'gap-1' : spacing === 'relaxed' ? 'gap-4' : 'gap-2';
switch (activeLayout) {
case 'grid': {
return (
<div className={`grid grid-cols-3 md:grid-cols-4 ${gapClass}`}>
{mockPhotos.slice(0, 8).map((photo) => (
<PreviewPhoto key={photo.id} photo={photo} />
))}
</div>
);
}
case 'masonry':
return (
<div className={`columns-3 md:columns-4 ${gapClass}`}>
{mockPhotos.slice(0, 10).map((photo, idx) => (
<div key={photo.id} className={`break-inside-avoid mb-${spacing === 'tight' ? '1' : spacing === 'relaxed' ? '4' : '2'}`}>
<PreviewPhoto
photo={photo}
aspectRatio={idx % 3 === 0 ? 'aspect-[4/5]' : idx % 3 === 1 ? 'aspect-[4/3]' : 'aspect-square'}
/>
</div>
))}
</div>
);
case 'carousel':
return (
<div className="relative">
<div className="flex items-center gap-2 overflow-hidden">
<PreviewPhoto photo={mockPhotos[0]} className="w-full max-w-md mx-auto" aspectRatio="aspect-[4/3]" />
</div>
<div className="flex justify-center gap-1 mt-3">
{[0, 1, 2, 3].map((idx) => (
<div key={idx} className={`w-2 h-2 rounded-full ${idx === 0 ? 'bg-accent-dark' : 'bg-neutral-300'}`} />
))}
</div>
</div>
);
case 'timeline':
return (
<div className="space-y-6">
{['Today', 'Yesterday'].map((date, dateIdx) => (
<div key={date}>
<h4 className="text-sm font-medium text-neutral-700 mb-2">{date}</h4>
<div className={`grid grid-cols-3 ${gapClass}`}>
{mockPhotos.slice(dateIdx * 3, (dateIdx * 3) + 3).map((photo) => (
<PreviewPhoto key={photo.id} photo={photo} />
))}
</div>
</div>
))}
</div>
);
case 'mosaic':
return (
<div className={`grid grid-cols-4 grid-rows-3 ${gapClass} h-64`}>
<PreviewPhoto photo={mockPhotos[0]} className="col-span-2 row-span-2" aspectRatio="aspect-auto h-full" />
<PreviewPhoto photo={mockPhotos[1]} className="col-span-1 row-span-1" aspectRatio="aspect-auto h-full" />
<PreviewPhoto photo={mockPhotos[2]} className="col-span-1 row-span-1" aspectRatio="aspect-auto h-full" />
<PreviewPhoto photo={mockPhotos[3]} className="col-span-1 row-span-1" aspectRatio="aspect-auto h-full" />
<PreviewPhoto photo={mockPhotos[4]} className="col-span-1 row-span-1" aspectRatio="aspect-auto h-full" />
<PreviewPhoto photo={mockPhotos[5]} className="col-span-2 row-span-1" aspectRatio="aspect-auto h-full" />
</div>
);
default:
return null;
}
};
return (
<div
className={`bg-white rounded-lg shadow-sm overflow-hidden ${className}`}
style={{
backgroundColor: theme.backgroundColor || '#ffffff',
color: theme.textColor || '#171717',
fontFamily: theme.fontFamily || 'Inter, sans-serif',
}}
>
{/* Hero Header */}
{isHeroHeader && (
<div
className="relative text-white overflow-hidden"
style={{
backgroundColor: theme.accentColor || theme.primaryColor || '#22c55e',
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'40\' height=\'40\' viewBox=\'0 0 40 40\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'%23ffffff\' fill-opacity=\'0.03\'%3E%3Cpath d=\'M0 40L40 0H20L0 20M40 40V20L20 40\'/%3E%3C/g%3E%3C/svg%3E")',
}}
>
<div className="py-8 px-4 relative z-10">
<div className="text-center max-w-md mx-auto">
{showLogo && (
<div className="mb-3">
{resolvedLogoUrl ? (
<img
src={resolvedLogoUrl}
alt={brandName}
className="h-10 w-auto object-contain mx-auto"
style={{ filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' }}
/>
) : (
<div className="h-10 w-10 rounded-full bg-white/20 flex items-center justify-center mx-auto">
<Camera className="w-5 h-5 text-white" />
</div>
)}
</div>
)}
<h1
className="text-xl font-bold mb-2"
style={{
fontFamily: theme.headingFontFamily || theme.fontFamily || 'Inter, sans-serif',
textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)'
}}
>
Sample Event
</h1>
<div className="flex items-center justify-center text-white/80 text-sm" style={{ textShadow: '0 1px 3px rgba(0, 0, 0, 0.3)' }}>
<Calendar className="w-4 h-4 mr-1" />
<span>January 15, 2026</span>
</div>
{/* Hero photo placeholder hint */}
<div className="mt-3 flex items-center justify-center gap-1.5 text-white/60 text-xs">
<Camera className="w-3 h-3" />
<span>{t('branding.heroPlaceholderText')}</span>
</div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0">
{renderHeroDivider()}
</div>
</div>
)}
{/* Standard Header */}
{!isHeroHeader && !isMinimalHeader && !isNoHeader && (
<div
className="px-4 py-3 border-b space-y-2"
style={{
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
}}
>
<div className={`flex items-center gap-3 ${brandFlexClass}`}>
{showLogo && (
resolvedLogoUrl ? (
<img
src={resolvedLogoUrl}
alt={brandName}
className="h-8 w-auto object-contain"
/>
) : (
<div className="h-8 w-8 rounded-full bg-neutral-200 flex items-center justify-center">
<Camera className="w-4 h-4 text-neutral-500" />
</div>
)
)}
{showText && (
<div>
<p className="text-sm font-semibold leading-tight">{brandName}</p>
{brandTagline && (
<p className="text-xs text-neutral-500 leading-tight">{brandTagline}</p>
)}
</div>
)}
{!showLogo && !showText && (
<p className="text-sm font-semibold">{brandName}</p>
)}
</div>
</div>
)}
{/* Minimal Header - thin bar with just event name */}
{isMinimalHeader && (
<div
className="px-4 py-2 border-b"
style={{
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
}}
>
<p
className="text-sm font-semibold truncate"
style={{
fontFamily: theme.headingFontFamily || theme.fontFamily || 'Inter, sans-serif',
}}
>
Sample Event
</p>
</div>
)}
{/* None Header - no header content at all */}
{/* (isNoHeader renders nothing here — goes straight to layout bar) */}
{/* Layout info bar */}
<div className="px-4 py-1 border-b text-xs text-neutral-500 flex justify-between" style={{ borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb' }}>
<span>Gallery preview</span>
<span className="capitalize">{isHeroHeader ? `Hero + ${activeLayout}` : isMinimalHeader ? `Minimal + ${activeLayout}` : isNoHeader ? `No header + ${activeLayout}` : `${activeLayout} layout`}</span>
</div>
{/* Preview Content */}
<div className="p-4" style={{ maxHeight: '400px', overflowY: 'auto' }}>
{renderLayout()}
</div>
</div>
);
};
GalleryPreview.displayName = 'GalleryPreview';
@@ -0,0 +1,190 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Copy, Check, Trash2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { Button, Input, Loading } from '../common';
import { guestsService, GuestInvite } from '../../services/guests.service';
import { useMutationWithToast } from '../../hooks';
interface GuestInviteDialogProps {
eventId: number;
eventName?: string;
onClose: () => void;
}
/**
* Admin dialog to create pre-minted invite tokens and list existing ones.
* Each invite generates a unique URL that the admin can send to a specific
* guest. Opening the URL auto-registers that guest (single use).
*/
export const GuestInviteDialog: React.FC<GuestInviteDialogProps> = ({ eventId, onClose }) => {
const { t } = useTranslation();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [copiedId, setCopiedId] = useState<number | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['admin-guest-invites', eventId],
queryFn: () => guestsService.listInvites(eventId),
});
const createMutation = useMutationWithToast({
mutationFn: () => guestsService.createInvite(eventId, { name, email: email || undefined }),
successMessage: t('admin.guests.inviteCreated', 'Invite created'),
invalidateKeys: [['admin-guest-invites', eventId], ['admin-guests', eventId]],
onSuccess: () => {
setName('');
setEmail('');
},
errorMessage: () => t('admin.guests.inviteCreateError', 'Failed to create invite'),
});
const revokeMutation = useMutationWithToast({
mutationFn: (inviteId: number) => guestsService.revokeInvite(eventId, inviteId),
successMessage: t('admin.guests.inviteRevoked', 'Invite revoked'),
invalidateKeys: [['admin-guest-invites', eventId]],
errorMessage: () => t('admin.guests.inviteRevokeError', 'Failed to revoke invite'),
});
const copy = (invite: GuestInvite) => {
navigator.clipboard.writeText(invite.url).then(() => {
setCopiedId(invite.id);
setTimeout(() => setCopiedId(null), 1500);
});
};
const invites = data?.invites || [];
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 pt-16">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('admin.guests.invitesTitle', 'Guest invites')}
</h2>
<button
type="button"
onClick={onClose}
className="p-1 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="overflow-y-auto p-4 space-y-4">
{/* Create form */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-800 rounded">
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-3">
{t('admin.guests.createInvite', 'Create invite')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<Input
label={t('admin.guests.inviteName', 'Guest name')}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Alice"
required
/>
<Input
type="email"
label={t('admin.guests.inviteEmail', 'Email (optional)')}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="[email protected]"
/>
</div>
<Button
variant="primary"
size="sm"
onClick={() => createMutation.mutate()}
disabled={!name.trim() || createMutation.isPending}
>
{createMutation.isPending
? t('common.submitting', 'Submitting...')
: t('admin.guests.generateInvite', 'Generate invite link')}
</Button>
</div>
{/* Existing invites */}
<div>
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-2">
{t('admin.guests.existingInvites', 'Existing invites')}
</h3>
{isLoading ? (
<Loading size="sm" />
) : invites.length === 0 ? (
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-4">
{t('admin.guests.noInvites', 'No invites yet')}
</div>
) : (
<div className="space-y-2">
{invites.map((invite) => (
<div
key={invite.id}
className="p-3 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-neutral-900 dark:text-neutral-100">
{invite.guest.name}
{invite.guest.email && (
<span className="text-neutral-500 dark:text-neutral-400 font-normal ml-2">
· {invite.guest.email}
</span>
)}
</div>
<div className="text-xs mt-1">
<span
className={`inline-block px-2 py-0.5 rounded-full font-medium ${
invite.status === 'redeemed'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: invite.status === 'revoked'
? 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
}`}
>
{t(`admin.guests.inviteStatus.${invite.status}`, invite.status)}
</span>
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 truncate mt-1 font-mono">
{invite.url}
</div>
</div>
<div className="flex gap-1">
{invite.status === 'pending' && (
<>
<button
type="button"
onClick={() => copy(invite)}
className="p-1.5 text-neutral-500 hover:text-accent"
title={t('admin.guests.copyLink', 'Copy link')}
>
{copiedId === invite.id ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
<button
type="button"
onClick={() => revokeMutation.mutate(invite.id)}
className="p-1.5 text-neutral-500 hover:text-red-600"
title={t('admin.guests.revokeInvite', 'Revoke')}
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,69 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Users } from 'lucide-react';
import { Card, Loading } from '../common';
import { guestsService } from '../../services/guests.service';
import { AuthenticatedImage } from '../common/AuthenticatedImage';
import { buildResourceUrl } from '../../utils/url';
interface GuestSelectionsAggregateProps {
eventId: number;
}
/**
* Shows photos sorted by the number of distinct guests who liked or
* favorited them. Photos with zero picks are filtered server-side.
*/
export const GuestSelectionsAggregate: React.FC<GuestSelectionsAggregateProps> = ({ eventId }) => {
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ['admin-guests-aggregate', eventId],
queryFn: () => guestsService.getAggregatePicks(eventId),
});
if (isLoading) {
return <Loading size="lg" text={t('admin.guests.loading', 'Loading...')} />;
}
const photos = data?.photos || [];
if (photos.length === 0) {
return (
<Card>
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">
{t('admin.guests.aggregate.empty', 'No guest picks yet.')}
</div>
</Card>
);
}
return (
<div className="space-y-3">
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t(
'admin.guests.aggregate.description',
'Photos sorted by how many distinct guests liked or favorited them.'
)}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{photos.map((p) => (
<div key={p.id} className="relative group">
<AuthenticatedImage
src={buildResourceUrl(p.thumbnail_url)}
alt={p.filename}
className="w-full aspect-square object-cover rounded"
/>
<div className="absolute top-2 right-2 bg-accent-dark text-white text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1 shadow">
<Users className="w-3 h-3" />
{p.picker_count}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity text-white text-xs p-2 rounded-b">
{p.original_filename || p.filename}
</div>
</div>
))}
</div>
</div>
);
};
@@ -0,0 +1,173 @@
import React, { useState } from 'react';
import { X, Image as ImageIcon, Check } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card, AuthenticatedImage } from '../common';
import { AdminPhoto } from '../../services/photos.service';
interface HeroPhotoSelectorProps {
photos: AdminPhoto[];
currentHeroPhotoId?: number | null;
onSelect: (photoId: number | null) => void;
isEditing: boolean;
}
export const HeroPhotoSelector: React.FC<HeroPhotoSelectorProps> = ({
photos,
currentHeroPhotoId,
onSelect,
isEditing
}) => {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [selectedPhotoId, setSelectedPhotoId] = useState<number | null>(currentHeroPhotoId || null);
const currentHeroPhoto = photos.find(p => p.id === currentHeroPhotoId);
const handleSelect = (photoId: number) => {
setSelectedPhotoId(photoId);
onSelect(photoId);
setIsOpen(false);
};
const handleRemove = () => {
setSelectedPhotoId(null);
onSelect(null);
};
if (!isEditing) {
return (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.heroPhoto')}
</label>
{currentHeroPhoto ? (
<div className="relative w-full h-48 rounded-lg overflow-hidden bg-neutral-100">
<AuthenticatedImage
src={currentHeroPhoto.thumbnail_url || currentHeroPhoto.url}
alt={currentHeroPhoto.filename}
className="w-full h-full object-cover"
/>
</div>
) : (
<p className="text-sm text-neutral-500">{t('events.noHeroPhotoSelected')}</p>
)}
</div>
);
}
return (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.heroPhoto')}
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">
{t('events.heroPhotoHelp')}
</p>
{currentHeroPhoto ? (
<div className="relative w-full h-48 rounded-lg overflow-hidden bg-neutral-100 mb-2">
<AuthenticatedImage
src={currentHeroPhoto.thumbnail_url || currentHeroPhoto.url}
alt={currentHeroPhoto.filename}
className="w-full h-full object-cover"
/>
<div className="absolute top-2 right-2 flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => setIsOpen(true)}
className="bg-white/90 hover:bg-white"
>
{t('common.change')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleRemove}
leftIcon={<X className="w-4 h-4" />}
className="bg-white/90 hover:bg-white"
>
{t('common.remove')}
</Button>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
leftIcon={<ImageIcon className="w-4 h-4" />}
onClick={() => setIsOpen(true)}
className="w-full"
>
{t('events.selectHeroPhoto')}
</Button>
)}
{/* Photo Selection Modal */}
{isOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<Card className="max-w-4xl w-full max-h-[90vh] overflow-hidden">
<div className="p-6 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('events.selectHeroPhoto')}</h2>
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(90vh-180px)]">
{photos.length === 0 ? (
<p className="text-center text-neutral-500 py-8">
{t('events.noPhotosAvailable')}
</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{photos.map((photo) => (
<div
key={photo.id}
onClick={() => handleSelect(photo.id)}
className={`relative cursor-pointer rounded-lg overflow-hidden border-2 transition-all ${
photo.id === selectedPhotoId
? 'border-accent-dark ring-2 ring-primary-500 ring-offset-2'
: 'border-transparent hover:border-neutral-300'
}`}
>
<div className="aspect-square bg-neutral-100">
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
className="w-full h-full object-cover"
/>
</div>
{photo.id === selectedPhotoId && (
<div className="absolute top-2 right-2 bg-accent-dark/150 text-white rounded-full p-1">
<Check className="w-4 h-4" />
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<p className="text-white text-xs truncate">{photo.filename}</p>
</div>
</div>
))}
</div>
)}
</div>
<div className="p-6 border-t border-neutral-200 dark:border-neutral-700 flex justify-end gap-3">
<Button
variant="outline"
onClick={() => setIsOpen(false)}
>
{t('common.cancel')}
</Button>
</div>
</Card>
</div>
)}
</div>
);
};
@@ -0,0 +1,499 @@
/**
* Hours section card (migration 129).
*
* Used in two places:
* 1. CustomerDetailPage — rendered when the per-customer
* `feature_hours_logging` flag is on AND the master `hoursLogging`
* flag is on. Sits between the features card and account actions.
* 2. The standalone /admin/clients/hours page — admin picks ANY
* customer with hours logging enabled, then sees this card.
*
* Wraps customerAdminService.{list,create,delete,billUnbilled}HourEntries.
* All writes go through react-query invalidation so the entry list
* refreshes after every action.
*/
import React, { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import { Clock, AlertTriangle } from 'lucide-react';
import { Button, Card, LocalizedDateInput, TimeField } from '../common';
import { DecimalInput } from '../common/DecimalInput';
import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
import { customerAdminService } from '../../services/customerAdmin.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../hooks';
import { ProjectSelect } from './ProjectSelect';
export interface HoursSectionProps {
customerId: number;
customerHourlyRateMinor: number | null;
billingCadence: 'per_event' | 'monthly' | 'quarterly';
onHourlyRateChange?: (next: number | null) => void;
/**
* When true, render only the entry-history table + the per-event
* "Bill these hours" action. Hides the inline log-entry form and
* the default-rate input. Used on the customer detail page now
* that logging itself lives on the standalone /admin/clients/hours
* surface — the detail page becomes a read-only history view with
* the on-demand bill action for per-event customers.
*/
compact?: boolean;
}
export const HoursSection: React.FC<HoursSectionProps> = ({
customerId, customerHourlyRateMinor, billingCadence, onHourlyRateChange, compact,
}) => {
const { t } = useTranslation();
const qc = useQueryClient();
const navigate = useNavigate();
const { format: fmtDate, formatTime: fmtTime } = useLocalizedDate();
const [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10));
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:00');
const [duration, setDuration] = useState<string>('');
const [rateOverride, setRateOverride] = useState<string>('');
const [description, setDescription] = useState('');
// Migration 118 — optional "book to project" link (gated component).
const [projectId, setProjectId] = useState<number | null>(null);
// Duration shortcut — admin types "1.5", "1,5", "1:30" or "1h" and
// the end-time jumps to start + duration. Pure convenience; the End
// input still works for explicit times. Empty / unparseable input is
// a no-op so a typo doesn't overwrite a freshly-edited End.
const applyDuration = (raw: string) => {
const minutes = parseDuration(raw);
if (minutes == null) return;
const [hh, mm] = startTime.split(':').map(Number);
if (!Number.isFinite(hh) || !Number.isFinite(mm)) return;
const totalEnd = Math.min(hh * 60 + mm + minutes, 24 * 60 - 1);
const eh = Math.floor(totalEnd / 60).toString().padStart(2, '0');
const em = (totalEnd % 60).toString().padStart(2, '0');
setEndTime(`${eh}:${em}`);
};
const { data: entries = [], isLoading } = useQuery({
queryKey: ['admin-customer-hour-entries', customerId],
queryFn: () => customerAdminService.listHourEntries(customerId),
enabled: Number.isFinite(customerId) && customerId > 0,
});
// Pull the configured default currency so the hint can show
// "{{currency}} 150" instead of the hardcoded "CHF 150". Same cache
// key as CustomerDetailPage so a single round-trip serves both
// mount points. 5-minute stale window — the value changes via
// Settings → Business profile, not during a hours-logging session.
const { data: profileSnapshot } = useQuery({
queryKey: ['business-profile-snapshot'],
queryFn: () => businessProfileService.get(),
staleTime: 5 * 60 * 1000,
});
const profileDefaultCurrency = profileSnapshot?.profile?.defaultCurrency || 'CHF';
// Install-wide fallback rate (migration 113). Last link in the rate
// chain after the per-entry override and the per-customer default.
const installDefaultRateMinor = profileSnapshot?.profile?.defaultHourlyRateMinor ?? null;
// The rate that applies to a NEW entry when no per-entry override is
// typed: customer rate, else the install default. null = neither set,
// so a save would fail unless the admin enters an override.
const effectiveDefaultRateMinor = customerHourlyRateMinor ?? installDefaultRateMinor;
// True when there's genuinely no rate to bill at — drives the inline
// CTA + disables the save button. An override typed in the form lifts
// this (handled below where the button is rendered).
const noRateConfigured = effectiveDefaultRateMinor == null;
const overrideTyped = (() => {
if (!rateOverride.trim()) return false;
const n = parseLocaleDecimal(rateOverride);
return Number.isFinite(n) && n >= 0;
})();
const createMutation = useMutation({
mutationFn: () => customerAdminService.createHourEntry(customerId, {
entryDate, startTime, endTime,
// Locale-tolerant: "12,50" and "12.50" both yield 1250.
hourlyRateMinorOverride: (() => {
if (!rateOverride) return null;
const n = parseLocaleDecimal(rateOverride);
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null;
})(),
description: description || null,
projectId: projectId ?? null,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
setStartTime('09:00');
setEndTime('10:00');
setDuration('');
setRateOverride('');
setDescription('');
setProjectId(null);
toast.success(t('customers.hours.toast.created', 'Entry logged'));
},
onError: (err: any) => {
// The save-time "no rate" failure is translated here off the
// backend error code (the raw message is English-only). The inline
// guard below normally prevents this, but a race (rate cleared in
// another tab) can still surface it.
if (err?.response?.data?.code === 'HOURLY_RATE_REQUIRED') {
toast.error(t('customers.hours.error.noRate',
'No hourly rate set for this customer. Enter a rate override, set a rate on the customer, or configure an install-wide default in Settings.'));
return;
}
if (err?.response?.data?.code === 'PROJECT_CUSTOMER_MISMATCH') {
toast.error(t('projects.error.customerMismatch',
"That project belongs to a different customer than this entry."));
return;
}
const msg = err?.response?.data?.error || err?.message
|| t('customers.hours.error.createFailed', 'Failed to log entry');
toast.error(msg);
},
});
const deleteMutation = useMutationWithToast({
mutationFn: (entryId: number) => customerAdminService.deleteHourEntry(customerId, entryId),
invalidateKeys: [['admin-customer-hour-entries', customerId], ['admin-customer', customerId]],
successMessage: t('customers.hours.toast.deleted', 'Entry deleted'),
errorMessage: 'Failed to delete entry',
});
const billMutation = useMutation({
mutationFn: () => customerAdminService.billUnbilledHourEntries(customerId),
onSuccess: ({ invoiceId }) => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
toast.success(t('customers.hours.toast.billed', 'Hours billed'));
// Open the new scheduled invoice so the admin can add other line
// items in addition to the hours before it ships.
if (invoiceId) navigate(`/admin/clients/bills/${invoiceId}/edit`);
},
onError: (err: any) => {
toast.error(err?.response?.data?.error || 'Failed to bill hours');
},
});
// Single pass — both the count and the money total live behind the
// same filter. Memoised so a parent re-render (e.g. the
// CustomerDetailPage form state changing) doesn't reshuffle the
// entries array in JS on every keystroke.
const { unbilledCount, unbilledTotalMajor } = useMemo(() => {
let count = 0;
let minor = 0;
for (const e of entries) {
if (e.status !== 'unbilled') continue;
count += 1;
const rateMinor = e.hourlyRateMinorOverride ?? effectiveDefaultRateMinor ?? 0;
minor += rateMinor * e.durationMinutes / 60;
}
return { unbilledCount: count, unbilledTotalMajor: minor / 100 };
}, [entries, effectiveDefaultRateMinor]);
const isMonthly = billingCadence === 'monthly';
// Local lockout check — mirrors customerHoursService.isEntryLocked
// so the delete button can be disabled before the request is sent.
const isLocked = (entry: typeof entries[number]) => {
if (!entry.invoiceId) return false;
if (entry.invoiceIsMonthlyDraft) return false;
if (entry.invoiceStatus !== 'scheduled') return true;
if (!entry.invoiceScheduledSendAt) return false;
return new Date(entry.invoiceScheduledSendAt).getTime() <= Date.now();
};
return (
<Card padding="lg">
<h2 className="text-lg font-semibold text-theme mb-1 flex items-center gap-2">
<Clock className="w-5 h-5" />
{t('customers.hours.section', 'Hours')}
</h2>
<p className="text-xs text-muted-theme mb-4">
{isMonthly
? t('customers.hours.monthlyHint',
'Entries auto-append to the current monthly draft. Edit / delete remains possible until the scheduler arms the draft for send.')
: t('customers.hours.perEventHint',
'Logged entries stay unbilled until you click "Create draft invoice" — one scheduled invoice is generated with a line per entry and opened in the editor, so you can add other items before it ships.')}
</p>
{/* Rate summary — hidden in compact mode (history-only on the
customer detail page). When a caller wires onHourlyRateChange
the field is editable; otherwise (the standalone hours page)
we show the RESOLVED rate read-only so a disabled input can't
masquerade as an editable value, and surface a CTA when no rate
is configured anywhere along the chain. */}
{!compact && (
<div className="mb-4">
<label className="block text-sm font-medium text-theme mb-1">
{t('customers.field.hourlyRate', 'Default hourly rate')}
</label>
{onHourlyRateChange ? (
<>
<DecimalInput
value={customerHourlyRateMinor != null ? customerHourlyRateMinor / 100 : NaN}
fractionDigits={2}
onChange={(n) => {
if (!Number.isFinite(n)) { onHourlyRateChange(null); return; }
onHourlyRateChange(Math.max(0, Math.round(n * 100)));
}}
className="w-40 input"
placeholder="150.00"
/>
<p className="text-xs text-muted-theme mt-1">
{t('customers.field.hourlyRateHint',
'Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.',
{ currency: profileDefaultCurrency })}
</p>
</>
) : noRateConfigured ? (
<div className="rounded-md border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm">
<div className="flex items-start gap-2 text-amber-800 dark:text-amber-200">
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
<div>
<p className="font-medium">
{t('customers.hours.noRate.title', 'No hourly rate configured')}
</p>
<p className="mt-0.5 text-amber-700 dark:text-amber-300">
{t('customers.hours.noRate.body',
'Logging needs a rate. Set one for this customer, type a per-entry override below, or configure an install-wide default.')}
</p>
<div className="mt-2 flex flex-wrap gap-3">
<Link to={`/admin/clients/accounts/${customerId}`}
className="text-accent-dark hover:underline font-medium">
{t('customers.hours.noRate.setForCustomer', 'Set a rate for this customer')}
</Link>
<Link to="/admin/settings?tab=businessProfile" target="_blank" rel="noopener noreferrer"
className="text-accent-dark hover:underline font-medium">
{t('customers.hours.noRate.setInstallDefault', 'Set an install-wide default')}
</Link>
</div>
</div>
</div>
</div>
) : (
<p className="text-sm text-theme">
<span className="tabular-nums font-medium">
{profileDefaultCurrency} {((effectiveDefaultRateMinor as number) / 100).toFixed(2)}
</span>
<span className="text-xs text-muted-theme ml-2">
{customerHourlyRateMinor != null
? t('customers.hours.rateSource.customer', 'from this customer')
: t('customers.hours.rateSource.installDefault', 'install-wide default')}
</span>
</p>
)}
</div>
)}
{/* Inline log-new-entry form — hidden in compact mode. Logging
lives on the standalone /admin/clients/hours surface. */}
{!compact && (
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4 mb-4">
<h3 className="text-sm font-semibold mb-3">{t('customers.hours.form.title', 'Log new entry')}</h3>
<div className="grid grid-cols-2 md:grid-cols-6 gap-3">
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.date', 'Date')}
</label>
<LocalizedDateInput value={entryDate} onChange={setEntryDate} />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.start', 'Start')}
</label>
<TimeField value={startTime} onChange={setStartTime} />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.end', 'End')}
</label>
<TimeField value={endTime} onChange={setEndTime} />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.duration', 'Duration')}
</label>
<input
type="text"
inputMode="decimal"
value={duration}
onChange={(e) => setDuration(e.target.value)}
onBlur={(e) => applyDuration(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
applyDuration((e.target as HTMLInputElement).value);
}
}}
placeholder={t('customers.hours.form.durationPlaceholder', '1h · 1.5 · 1:30') as string}
title={t('customers.hours.form.durationHint',
'Type a duration to auto-fill End: 1h, 1.5, 1,5 or 1:30') as string}
className="input w-full" />
</div>
<div>
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.rateOverride', 'Rate override')}
</label>
<input
type="text"
inputMode="decimal"
value={rateOverride}
onChange={(e) => setRateOverride(e.target.value)}
placeholder={effectiveDefaultRateMinor != null
? (effectiveDefaultRateMinor / 100).toFixed(2)
: '—'}
className="input w-full" />
</div>
</div>
<div className="mt-3">
<label className="block text-xs text-muted-theme mb-1">
{t('customers.hours.form.note', 'Note / description')}
</label>
<textarea rows={2} value={description}
onChange={(e) => setDescription(e.target.value)}
className="input w-full text-sm"
placeholder={t('customers.hours.form.notePlaceholder',
'What was worked on?') as string} />
</div>
{/* Book to project — renders only when the projects feature is on. */}
<ProjectSelect
className="mt-3"
label={t('customers.hours.form.bookToProject', 'Book to project') as string}
value={projectId}
customerAccountId={customerId}
onChange={setProjectId}
/>
<div className="mt-3 flex items-center justify-end gap-3">
{noRateConfigured && !overrideTyped && (
<span className="text-xs text-amber-700 dark:text-amber-300">
{t('customers.hours.form.needRate', 'Set a rate or enter an override to log time.')}
</span>
)}
<Button
variant="primary"
disabled={createMutation.isPending || (noRateConfigured && !overrideTyped)}
isLoading={createMutation.isPending}
onClick={() => createMutation.mutate()}
>
{t('customers.hours.form.save', 'Add entry')}
</Button>
</div>
</div>
)}
{/* Bill-these-hours button for per-event customers only. Stays
visible in compact mode so the customer-detail page can
still trigger the on-demand billing action. */}
{!isMonthly && unbilledCount > 0 && (
<div className="mb-4 flex items-center justify-between bg-blue-50 dark:bg-blue-900/20 rounded p-3">
<span className="text-sm">
{t('customers.hours.unbilledCount',
'{{count}} unbilled entries totaling {{total}}',
{
count: unbilledCount,
total: unbilledTotalMajor.toFixed(2),
})}
</span>
<Button
variant="primary"
disabled={billMutation.isPending}
isLoading={billMutation.isPending}
onClick={() => billMutation.mutate()}
>
{t('customers.hours.billButton', 'Create draft invoice')}
</Button>
</div>
)}
{/* Entry list table. */}
{isLoading ? (
<p className="text-sm text-muted-theme">{t('common.loading', 'Loading…')}</p>
) : entries.length === 0 ? (
<p className="text-sm text-muted-theme">
{t('customers.hours.empty', 'No entries logged yet.')}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase text-muted-theme">
<th className="py-2 pr-3">{t('customers.hours.col.date', 'Date')}</th>
<th className="py-2 pr-3">{t('customers.hours.col.range', 'Time')}</th>
<th className="py-2 pr-3 text-right">{t('customers.hours.col.hours', 'Hours')}</th>
<th className="py-2 pr-3 text-right">{t('customers.hours.col.rate', 'Rate')}</th>
<th className="py-2 pr-3 text-right">{t('customers.hours.col.total', 'Total')}</th>
<th className="py-2 pr-3">{t('customers.hours.col.note', 'Note')}</th>
<th className="py-2 pr-3">{t('customers.hours.col.status', 'Status')}</th>
<th className="py-2 pr-3"></th>
</tr>
</thead>
<tbody>
{entries.map((e) => {
const rate = e.hourlyRateMinorOverride ?? effectiveDefaultRateMinor ?? 0;
const hours = e.durationMinutes / 60;
const total = (hours * rate) / 100;
const locked = isLocked(e);
return (
<tr key={e.id} className="border-t border-neutral-200 dark:border-neutral-700">
<td className="py-1.5 pr-3 tabular-nums">{fmtDate(e.entryDate)}</td>
<td className="py-1.5 pr-3 tabular-nums">{fmtTime(e.startTime)}{fmtTime(e.endTime)}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{hours.toFixed(2)}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{(rate / 100).toFixed(2)}</td>
<td className="py-1.5 pr-3 text-right tabular-nums font-medium">{total.toFixed(2)}</td>
<td className="py-1.5 pr-3 max-w-xs truncate" title={e.description || ''}>
{e.description || '—'}
</td>
<td className="py-1.5 pr-3">
{e.status === 'billed' ? (
e.invoiceId ? (
// Link straight to the invoice so a "Billed: R-…" entry
// is one click from its (possibly draft) invoice.
<Link
to={`/admin/clients/bills/${e.invoiceId}`}
className="text-xs text-green-700 dark:text-green-300 underline hover:no-underline"
>
{e.invoiceNumber
? t('customers.hours.status.billedOn', 'Billed: {{number}}', { number: e.invoiceNumber })
: t('customers.hours.status.billed', 'Billed')}
</Link>
) : (
<span className="text-xs text-green-700 dark:text-green-300">
{e.invoiceNumber
? t('customers.hours.status.billedOn', 'Billed: {{number}}', { number: e.invoiceNumber })
: t('customers.hours.status.billed', 'Billed')}
</span>
)
) : (
<span className="text-xs text-amber-700 dark:text-amber-300">
{t('customers.hours.status.unbilled', 'Unbilled')}
</span>
)}
</td>
<td className="py-1.5 pr-3 text-right">
<button
type="button"
disabled={locked || deleteMutation.isPending}
onClick={() => {
if (window.confirm(t('customers.hours.confirmDelete',
'Delete this entry? If it has been billed onto a draft, the matching invoice line will also be removed.') as string)) {
deleteMutation.mutate(e.id);
}
}}
className="text-xs text-red-600 hover:underline disabled:text-neutral-400 disabled:cursor-not-allowed"
title={locked ? t('customers.hours.locked',
'Locked: invoice already armed for send') as string : undefined}
>
{t('common.delete', 'Delete')}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Card>
);
};
export default HoursSection;
@@ -0,0 +1,228 @@
/**
* Incoming mail (IMAP) configuration — a second block under the outgoing SMTP
* settings, styled to match the SMTP card (icon inputs, password eye toggle,
* full-width Save). Shown only when the `incomingMail` feature flag is on.
*
* The Folder field auto-detects: "Detect folders" lists the mailboxes on the
* server and offers them as a dropdown (auto-selecting the inbox), instead of
* making the admin type a path.
*/
import React, { useEffect, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Save, Server, User, Lock, Eye, EyeOff, FolderSearch, PlugZap, Mailbox, RefreshCw } from 'lucide-react';
import { Button, Card, Input, Loading } from '../common';
import { emailService, type IncomingMailConfig, type ImapFolder } from '../../services/email.service';
import { useMutationWithToast, useModal } from '../../hooks';
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
const selectCls = 'w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark';
export const IncomingMailConfigCard: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['incoming-mail-config'], queryFn: () => emailService.getIncomingConfig() });
const [cfg, setCfg] = useState<IncomingMailConfig>({ imap_host: '', imap_port: 993, imap_secure: true, imap_user: '', imap_pass: '', imap_folder: 'INBOX' });
const passwordVisibilityModal = useModal();
const [folders, setFolders] = useState<ImapFolder[] | null>(null);
useEffect(() => { if (data) setCfg(data); }, [data]);
const set = (k: keyof IncomingMailConfig, v: any) => setCfg((c) => ({ ...c, [k]: v }));
const save = useMutationWithToast({
mutationFn: () => {
// Mirror the SMTP card's client-side required guard. Host + port +
// username are needed for the poller to authenticate (getImapConfig
// returns null without host+user).
if (!cfg.imap_host || !cfg.imap_port || !cfg.imap_user) {
return Promise.reject(new Error(t('email.incoming.requiredFields', 'Host, port and username are required.')));
}
return emailService.updateIncomingConfig(cfg);
},
successMessage: t('email.incoming.savedToast', 'Incoming mail settings saved.'),
invalidateKeys: [['incoming-mail-config']],
errorMessage: (e: any) => e?.response?.data?.error || e?.response?.data?.errors?.[0]?.msg || e.message || 'Failed',
});
const test = useMutationWithToast({
mutationFn: () => emailService.testIncoming(cfg),
successMessage: (r) => t('email.incoming.testOk', 'Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.', { folder: r.folder, messages: r.messages, unseen: r.unseen }),
errorMessage: (e: any) => e?.response?.data?.error || e.message || t('email.incoming.testFailed', 'Connection failed.'),
});
const roundTrip = useMutationWithToast({
mutationFn: () => emailService.roundTripIncoming(),
successMessage: (r) => t('email.incoming.roundTripOk', 'Round-trip OK — delivered to {{recipient}} in {{seconds}}s.', { recipient: r.recipient, seconds: r.seconds }),
errorMessage: (e: any) => e?.response?.data?.error || e.message || t('email.incoming.roundTripFailed', 'Round-trip test failed.'),
});
const poll = useMutation({
mutationFn: () => emailService.pollIncoming(),
onSuccess: (r) => {
if (r.skipped === 'disabled') {
toast.info(t('email.incoming.pollDisabled', 'Incoming mail is turned off — enable it under Settings → Features.'));
} else if (r.skipped === 'unconfigured') {
toast.info(t('email.incoming.pollUnconfigured', 'Save the incoming mail settings first.'));
} else if (r.skipped === 'busy') {
toast.info(t('email.incoming.pollBusy', 'A poll is already running — try again in a moment.'));
} else {
toast.success(t('email.incoming.pollOk', 'Checked mailbox — {{count}} new email(s) ingested.', { count: r.processed || 0 }));
qc.invalidateQueries({ queryKey: ['received-emails'] });
}
},
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.pollFailed', 'Mailbox poll failed.')),
});
const detect = useMutation({
mutationFn: () => emailService.listIncomingFolders(cfg),
onSuccess: (list) => {
setFolders(list);
if (list.length) {
// Auto-select the inbox (special-use '\Inbox', else a path named INBOX)
// when the current folder isn't one of the detected ones.
const has = list.some((f) => f.path === cfg.imap_folder);
if (!has) {
const inbox = list.find((f) => (f.specialUse || '').toLowerCase().includes('inbox'))
|| list.find((f) => f.path.toUpperCase() === 'INBOX') || list[0];
if (inbox) set('imap_folder', inbox.path);
}
toast.success(t('email.incoming.foldersDetected', '{{count}} folders found.', { count: list.length }));
} else {
toast.info(t('email.incoming.noFolders', 'No folders returned by the server.'));
}
},
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.detectFailed', 'Could not detect folders.')),
});
if (isLoading) return <Loading />;
return (
<Card padding="md" className="mt-6">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">{t('email.incoming.title', 'Incoming mail (IMAP)')}</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">{t('email.incoming.subtitle', 'A dedicated mailbox polled every minute; attachments land in Accounting → Incoming invoices.')}</p>
<div className="space-y-4">
<div>
<label className={labelCls}>{t('email.incoming.host', 'IMAP Host')} <span className="text-red-500">*</span></label>
<Input
type="text"
value={cfg.imap_host}
onChange={(e) => set('imap_host', e.target.value)}
placeholder="imap.example.com"
leftIcon={<Server className="w-5 h-5 text-neutral-400" />}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className={labelCls}>{t('email.incoming.port', 'Port')} <span className="text-red-500">*</span></label>
<Input type="number" value={cfg.imap_port} onChange={(e) => set('imap_port', parseInt(e.target.value, 10) || 0)} placeholder="993" />
</div>
<div>
<label className={labelCls}>{t('email.incoming.security', 'Security')}</label>
<select className={selectCls} value={cfg.imap_secure ? 'ssl' : 'plain'} onChange={(e) => set('imap_secure', e.target.value === 'ssl')}>
<option value="ssl">{t('email.incoming.ssl', 'SSL/TLS')}</option>
<option value="plain">{t('email.incoming.plain', 'None / STARTTLS')}</option>
</select>
</div>
</div>
<div>
<label className={labelCls}>{t('email.incoming.user', 'Username')} <span className="text-red-500">*</span></label>
<Input
type="text"
value={cfg.imap_user}
onChange={(e) => set('imap_user', e.target.value)}
autoComplete="off"
placeholder="[email protected]"
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
/>
</div>
<div>
<label className={labelCls}>{t('email.incoming.pass', 'Password')}</label>
<div className="relative">
<Input
type={passwordVisibilityModal.isOpen ? 'text' : 'password'}
value={cfg.imap_pass}
onChange={(e) => set('imap_pass', e.target.value)}
autoComplete="new-password"
placeholder={t('email.enterPassword', 'Enter password')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button type="button" onClick={passwordVisibilityModal.toggle} className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600">
{passwordVisibilityModal.isOpen ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div>
<label className={labelCls}>{t('email.incoming.folder', 'Folder')}</label>
<div className="flex gap-2">
{folders && folders.length > 0 ? (
<select className={selectCls} value={cfg.imap_folder} onChange={(e) => set('imap_folder', e.target.value)}>
{folders.some((f) => f.path === cfg.imap_folder) ? null : <option value={cfg.imap_folder}>{cfg.imap_folder}</option>}
{folders.map((f) => <option key={f.path} value={f.path}>{f.path}</option>)}
</select>
) : (
<Input type="text" value={cfg.imap_folder} onChange={(e) => set('imap_folder', e.target.value)} placeholder="INBOX" />
)}
<Button
variant="outline"
onClick={() => detect.mutate()}
isLoading={detect.isPending}
disabled={!cfg.imap_host || !cfg.imap_user}
leftIcon={<FolderSearch className="w-4 h-4" />}
className="whitespace-nowrap"
>
{t('email.incoming.detectFolders', 'Detect')}
</Button>
</div>
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">{t('email.incoming.folderHint', 'Enter host, username and password, then Detect to list the mailbox folders.')}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={() => test.mutate()}
isLoading={test.isPending}
disabled={!cfg.imap_host || !cfg.imap_user}
leftIcon={<PlugZap className="w-5 h-5" />}
className="whitespace-nowrap"
>
{t('email.incoming.test', 'Test connection')}
</Button>
<Button
variant="outline"
onClick={() => roundTrip.mutate()}
isLoading={roundTrip.isPending}
disabled={!cfg.imap_host || !cfg.imap_user}
leftIcon={<Mailbox className="w-5 h-5" />}
className="whitespace-nowrap"
title={t('email.incoming.roundTripHint', 'Sends a test email via your SMTP settings to this mailbox and confirms it arrives. Save both first.') as string}
>
{t('email.incoming.roundTrip', 'Round-trip test')}
</Button>
<Button
variant="outline"
onClick={() => poll.mutate()}
isLoading={poll.isPending}
disabled={!cfg.imap_host || !cfg.imap_user}
leftIcon={<RefreshCw className="w-5 h-5" />}
className="whitespace-nowrap"
title={t('email.incoming.pollHint', 'Check the mailbox now instead of waiting for the 60-second poll. Ingests unread attachments into Incoming invoices.') as string}
>
{t('email.incoming.poll', 'Check now')}
</Button>
<Button variant="primary" onClick={() => save.mutate()} isLoading={save.isPending} leftIcon={<Save className="w-5 h-5" />} className="flex-1 min-w-[12rem]">
{t('email.incoming.save', 'Save Incoming Mail Settings')}
</Button>
</div>
</div>
</Card>
);
};
export default IncomingMailConfigCard;
@@ -0,0 +1,372 @@
/**
* Inline "+ Create new customer" form mounted inside the quote /
* invoice editor's customer card.
*
* Two save modes:
* - "Save as passive customer" → POST /admin/customers. No email,
* no invitation. The customer becomes a usable record
* immediately for the current quote/invoice.
* - "Save & send portal invitation" → POST /admin/customers
* followed by POST /admin/customers/:id/send-invite. Customer is
* created (passive in DB), then a standard onboarding email is
* queued so they can claim portal access. Orchestrated client-
* side so the backend endpoints stay simple and single-purpose.
*
* If the second call fails after the first succeeds, the customer
* stays saved (passive) and a warning toast asks the admin to retry
* from the customer detail page.
*
* Field set mirrors the customer detail page so admins see the same
* shape regardless of where they're editing.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { Save, Send, X } from 'lucide-react';
import { Button, CountrySelect, Input } from '../common';
import {
customerAdminService,
type CustomerAccountDetail,
type CustomerInvitePrefill,
} from '../../services/customerAdmin.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { useQuery } from '@tanstack/react-query';
interface Props {
/**
* Fires after a successful save. The customer payload is the same
* shape the customer-detail endpoint returns, so callers can use
* its id/email/company directly to populate the quote/invoice's
* customer pin.
*/
onCreated: (customer: CustomerAccountDetail) => void;
/** Revert the editor card back to the search-only state. */
onCancel: () => void;
/**
* Which save action(s) the form should expose.
* - 'both' (default): renders both buttons — used by the quote /
* invoice editors where the admin picks the mode in-place.
* - 'passive': renders only "Save as passive customer".
* - 'invite': renders only "Save & send portal invitation".
* The "passive" / "invite" specialisations let CustomerManagementPage
* route both header buttons through the same modal — the only
* difference between the two flows is which action button shows.
*/
mode?: 'both' | 'passive' | 'invite';
}
type FormState = {
email: string;
salutation: string;
firstName: string;
lastName: string;
displayName: string;
phone: string;
companyName: string;
vatId: string;
addressLine1: string;
addressLine2: string;
postalCode: string;
city: string;
state: string;
countryCode: string;
preferredLanguage: string;
};
const empty: FormState = {
email: '', salutation: '', firstName: '', lastName: '', displayName: '',
phone: '', companyName: '', vatId: '',
addressLine1: '', addressLine2: '', postalCode: '', city: '', state: '',
countryCode: '', preferredLanguage: '',
};
function buildPrefill(f: FormState): CustomerInvitePrefill {
// Backend's PREFILLABLE_FIELDS uses snake_case. Translate at the
// wire boundary and drop empty strings so the server doesn't store
// "" where null would be more honest.
const out: CustomerInvitePrefill = {};
if (f.salutation) out.salutation = f.salutation;
if (f.firstName) out.first_name = f.firstName;
if (f.lastName) out.last_name = f.lastName;
if (f.displayName) out.display_name = f.displayName;
if (f.phone) out.phone = f.phone;
if (f.companyName) out.company_name = f.companyName;
if (f.vatId) out.vat_id = f.vatId;
if (f.addressLine1) out.address_line1 = f.addressLine1;
if (f.addressLine2) out.address_line2 = f.addressLine2;
if (f.postalCode) out.postal_code = f.postalCode;
if (f.city) out.city = f.city;
if (f.state) out.state = f.state;
if (f.countryCode) out.country_code = f.countryCode.toUpperCase();
if (f.preferredLanguage) out.preferred_language = f.preferredLanguage;
return out;
}
export const InlineCustomerCreate: React.FC<Props> = ({ onCreated, onCancel, mode = 'both' }) => {
const { t } = useTranslation();
const [form, setForm] = useState<FormState>(empty);
const [busy, setBusy] = useState<'passive' | 'invite' | null>(null);
// Resolve a title + subtitle that matches the selected mode. The
// 'both' branch keeps the legacy copy so inline (in-editor) callers
// see the same wording they had before this prop existed.
const heading = mode === 'invite'
? {
title: t('customers.invite.title', 'Invite a customer'),
subtitle: t('customers.invite.description',
'They will receive an email with a link to set up their account. Once they have accepted, you can assign them to events.'),
}
: mode === 'passive'
? {
title: t('customers.create.openButton', 'Create passive customer'),
subtitle: t('customers.create.passiveSubtitle',
'Adds an admin-only customer record. The customer is not notified and cannot log in until you send them an invitation later.'),
}
: {
title: t('customers.create.title', 'Create new customer'),
subtitle: t('customers.create.subtitle',
'Fill in the details below. Choose "Save as passive customer" to create an admin-only record, or "Save & send portal invitation" to also email the customer a sign-up link.'),
};
// Business-profile default locale powers the preferred-language
// hint AND seeds the field on mount.
const { data: profileSnapshot } = useQuery({
queryKey: ['business-profile-snapshot'],
queryFn: () => businessProfileService.get(),
staleTime: 5 * 60 * 1000,
});
const profileDefaultLocale = profileSnapshot?.profile?.defaultLocale || 'en';
const profileCountryCode = profileSnapshot?.profile?.countryCode || '';
// Seed preferredLanguage + countryCode with the profile defaults once
// the profile arrives (only if the field is still empty so we don't
// clobber explicit user input).
React.useEffect(() => {
setForm((prev) => {
const next = { ...prev };
if (profileDefaultLocale && !prev.preferredLanguage) next.preferredLanguage = profileDefaultLocale;
if (profileCountryCode && !prev.countryCode) next.countryCode = profileCountryCode.toUpperCase();
return next;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [profileDefaultLocale, profileCountryCode]);
const setField = (key: keyof FormState) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm((prev) => ({ ...prev, [key]: e.target.value }));
const hasEmail = !!form.email && /\S+@\S+\.\S+/.test(form.email);
// At least one human-readable identifier so the record isn't a
// nameless row that's impossible to recognise in lists later.
const hasName = !!(form.companyName.trim() || form.displayName.trim()
|| form.firstName.trim() || form.lastName.trim());
const isValid = hasEmail && hasName;
const handleSave = async (mode: 'passive' | 'invite') => {
if (!hasEmail) {
toast.error(t('customers.create.emailRequired', 'A valid email is required.'));
return;
}
if (!hasName) {
toast.error(t('customers.create.nameRequired',
'Enter at least a company name or a contact name.'));
return;
}
setBusy(mode);
try {
const customer = await customerAdminService.createDirect(form.email, buildPrefill(form));
if (mode === 'invite') {
// Customer is now saved as passive. Fire the second call to
// promote them. If THIS fails, keep the customer selected
// (it exists, just no email went out) and warn the admin.
try {
await customerAdminService.sendInvite(customer.id);
toast.success(t('customers.create.savedActiveToast',
'Customer created and portal invitation sent.'));
} catch (err: any) {
toast.warn(t('customers.create.inviteFailedToast',
'Customer saved (passive). Invitation email failed — retry from the customer detail page.'));
// eslint-disable-next-line no-console
console.warn('sendInvite failed', err);
}
} else {
toast.success(t('customers.create.savedPassiveToast',
'Passive customer created.'));
}
onCreated(customer);
} catch (err: any) {
const msg = err?.response?.data?.error || err?.message || t('common.error', 'Something went wrong.');
toast.error(String(msg));
} finally {
setBusy(null);
}
};
return (
<div className="space-y-3">
<div className="flex items-start gap-3 mb-2">
<div>
<h4 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
{heading.title}
</h4>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
{heading.subtitle}
</p>
</div>
<button
type="button"
onClick={onCancel}
className="ml-auto p-1 rounded text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
aria-label={t('common.cancel', 'Cancel') as string}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input
type="email"
label={`${t('customers.detail.email', 'Email')} *`}
value={form.email}
onChange={setField('email')}
placeholder="[email protected]"
required
/>
<Input
label={t('customers.detail.companyName', 'Company name') as string}
value={form.companyName}
onChange={setField('companyName')}
/>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('customers.detail.salutation', 'Salutation')}
</label>
{/* Salutation values are stored verbatim ("Herr", "Frau",
"Mx", "Dr") — canonical tokens across locales. Display
labels are translated; the option's value stays in the
German form so the same key works regardless of which
locale the admin is editing in. Matches CustomerDetailPage. */}
<select
value={form.salutation}
onChange={setField('salutation')}
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 text-neutral-900 dark:text-neutral-100"
>
<option value="">{t('customer.profile.salutation.none', '— Not specified —')}</option>
<option value="Herr">{t('customer.profile.salutation.herr', 'Mr.')}</option>
<option value="Frau">{t('customer.profile.salutation.frau', 'Ms.')}</option>
<option value="Mx">{t('customer.profile.salutation.mx', 'Mx')}</option>
<option value="Dr">{t('customer.profile.salutation.dr', 'Dr.')}</option>
</select>
</div>
<Input
label={t('customers.detail.phone', 'Phone') as string}
value={form.phone}
onChange={setField('phone')}
/>
<Input
label={t('customers.detail.firstName', 'First name') as string}
value={form.firstName}
onChange={setField('firstName')}
/>
<Input
label={t('customers.detail.lastName', 'Last name') as string}
value={form.lastName}
onChange={setField('lastName')}
/>
<Input
label={t('customers.detail.displayName', 'Display name') as string}
value={form.displayName}
onChange={setField('displayName')}
/>
<Input
label={t('customers.detail.vatId', 'VAT ID') as string}
value={form.vatId}
onChange={setField('vatId')}
/>
<div className="md:col-span-2">
<Input
label={t('customers.detail.addressLine1', 'Address line 1') as string}
value={form.addressLine1}
onChange={setField('addressLine1')}
/>
</div>
<div className="md:col-span-2">
<Input
label={t('customers.detail.addressLine2', 'Address line 2') as string}
value={form.addressLine2}
onChange={setField('addressLine2')}
/>
</div>
<Input
label={t('customers.detail.postalCode', 'Postal code') as string}
value={form.postalCode}
onChange={setField('postalCode')}
/>
<Input
label={t('customers.detail.city', 'City') as string}
value={form.city}
onChange={setField('city')}
/>
<Input
label={t('customers.detail.state', 'State / canton') as string}
value={form.state}
onChange={setField('state')}
/>
<CountrySelect
label={t('customers.detail.country', 'Country') as string}
value={form.countryCode}
onChange={(code) => setForm((prev) => ({ ...prev, countryCode: code }))}
/>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('customers.detail.preferredLanguage', 'Preferred language')}
</label>
<select
value={form.preferredLanguage || ''}
onChange={setField('preferredLanguage')}
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 text-neutral-900 dark:text-neutral-100"
>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="fr">Français</option>
<option value="nl">Nederlands</option>
<option value="pt">Português</option>
<option value="ru">Русский</option>
</select>
</div>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 pt-2 border-t border-neutral-200 dark:border-neutral-700">
<Button variant="outline" onClick={onCancel} disabled={busy !== null}>
{t('common.cancel', 'Cancel')}
</Button>
{(mode === 'both' || mode === 'passive') && (
<Button
variant={mode === 'passive' ? 'primary' : 'outline'}
onClick={() => handleSave('passive')}
disabled={busy !== null || !isValid}
isLoading={busy === 'passive'}
leftIcon={<Save className="w-4 h-4" />}
>
{/* Mode 'passive' is the dedicated CTA: promote it to the
primary variant so the button hierarchy mirrors what
an admin who opened the modal from "Create passive
customer" expects. */}
{t('customers.create.saveAsPassive', 'Save as passive customer')}
</Button>
)}
{(mode === 'both' || mode === 'invite') && (
<Button
variant="primary"
onClick={() => handleSave('invite')}
disabled={busy !== null || !isValid}
isLoading={busy === 'invite'}
leftIcon={<Send className="w-4 h-4" />}
>
{t('customers.create.saveAndInvite', 'Save & send portal invitation')}
</Button>
)}
</div>
</div>
);
};
@@ -0,0 +1,315 @@
/**
* <InstallmentsPanel> — shared editor surface for the per-document
* installment plan. Used by both QuoteEditorPage and BillEditorPage.
*
* Two render modes:
* - Simple (default): per-row date picker mapped to trigger='fixed_date'
* - Advanced (toggle): per-row trigger dropdown + offset_days
*
* Rows store the canonical {label, percent, trigger, offset_days}
* shape (PaymentTermInstallment from quotes.service.ts). Date pickers
* are a UX convenience layered over `trigger='fixed_date' + offset_days`.
*
* When the panel is "off" (`value: null`), the document is treated as
* a single-invoice / single-payment plan and the parent editor skips
* the installment field on save. Switching the panel on inserts a
* default 100% row pre-populated from `useInstallmentDefaults()`.
*
* Validation: percents must sum to 100 (rendered inline below the
* footer; parent uses `onValidityChange` to disable Save when wrong).
*
* The parent owns the `value` state; this component is fully
* controlled. Render is React-Strict-Mode safe (no state in refs that
* outlives the props).
*/
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Trash2, Plus } from 'lucide-react';
import { Button, Input, LocalizedDateInput } from '../common';
import type { PaymentTermInstallment } from '../../services/quotes.service';
import { useInstallmentDefaults } from '../../hooks/useInstallmentDefaults';
export type InstallmentPlan = PaymentTermInstallment[];
export interface InstallmentsPanelProps {
/** null = single-document mode (no installments). Array = the plan. */
value: InstallmentPlan | null;
onChange: (next: InstallmentPlan | null) => void;
/** Reports whether percents sum to 100. Parent uses to gate Save. */
onValidityChange?: (valid: boolean) => void;
/** Event date used for trigger preview text in advanced mode. */
eventDate?: string | null;
/** Disable inputs (e.g. document is locked / sent). */
disabled?: boolean;
}
const ALL_TRIGGERS: PaymentTermInstallment['trigger'][] = [
'quote_accepted', 'before_event', 'after_event', 'after_delivery', 'fixed_date',
];
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function addDays(base: string, days: number): string {
const d = new Date(base);
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
function daysBetween(from: string, to: string): number {
const f = new Date(from);
const t = new Date(to);
return Math.round((t.getTime() - f.getTime()) / 86_400_000);
}
export const InstallmentsPanel: React.FC<InstallmentsPanelProps> = ({
value, onChange, onValidityChange, eventDate, disabled,
}) => {
const { t } = useTranslation();
const defaults = useInstallmentDefaults();
const [advanced, setAdvanced] = React.useState(false);
const enabled = value !== null;
const rows = value || [];
const totalPercent = useMemo(
() => rows.reduce((s, r) => s + (Number(r.percent) || 0), 0),
[rows],
);
const isValid = !enabled || Math.abs(totalPercent - 100) < 0.001;
React.useEffect(() => {
onValidityChange?.(isValid);
}, [isValid, onValidityChange]);
const update = (idx: number, patch: Partial<PaymentTermInstallment>) => {
const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r));
onChange(next);
};
const remove = (idx: number) => {
const next = rows.filter((_, i) => i !== idx);
onChange(next.length === 0 ? null : next);
};
const addRow = () => {
// Default-shape new row: use the admin's defaults via a positional
// heuristic — first row inherits the "first installment" trigger,
// middle rows default to before_event, last row inherits after_event.
let trigger: PaymentTermInstallment['trigger'] = defaults.triggerFirst;
let offsetDays = 0;
if (rows.length === 0) {
trigger = defaults.triggerFirst;
offsetDays = 0;
} else {
// Second row onwards. If there's already a row tagged after_event,
// a new one slots in as before_event; else default to after_event.
const hasAfterEvent = rows.some((r) => r.trigger === 'after_event');
trigger = hasAfterEvent ? 'before_event' : 'after_event';
offsetDays = hasAfterEvent ? -defaults.daysBeforeEvent : defaults.daysAfterEvent;
}
// Suggest a percent that fills the gap (clamped to 0-100).
const gap = Math.max(0, 100 - totalPercent);
const next: PaymentTermInstallment = {
label: '',
percent: gap > 0 ? gap : 0,
trigger,
offset_days: offsetDays,
};
onChange([...rows, next]);
};
const toggleEnabled = () => {
if (enabled) {
onChange(null);
return;
}
// Switch on: seed with one 100% row using the "first installment"
// default trigger so the panel doesn't open with an empty list.
onChange([{
label: t('installments.firstRowDefaultLabel', 'Anzahlung') as string,
percent: 100,
trigger: defaults.triggerFirst,
offset_days: 0,
}]);
};
// Map a row to its "Send on" date in simple mode. Only meaningful
// when trigger is fixed_date; for dynamic triggers we display the
// resolved date if we have eventDate, else show a placeholder.
const previewDate = (row: PaymentTermInstallment): string | null => {
const baseline = todayIso();
if (row.trigger === 'fixed_date' || row.trigger === 'quote_accepted') {
return addDays(baseline, row.offset_days || 0);
}
if ((row.trigger === 'before_event' || row.trigger === 'after_event') && eventDate) {
return addDays(eventDate, row.offset_days || 0);
}
return null;
};
return (
<div>
<div className="flex items-center justify-between gap-2 mb-2 flex-wrap">
<label className="flex items-center gap-2 text-sm font-medium cursor-pointer">
<input
type="checkbox"
checked={enabled}
onChange={toggleEnabled}
disabled={disabled}
/>
{t('installments.enableLabel', 'Split into installments')}
</label>
{enabled && (
<button
type="button"
className="text-xs text-primary-600 dark:text-primary-400 hover:underline"
onClick={() => setAdvanced((v) => !v)}
disabled={disabled}
>
{advanced
? t('installments.simpleToggle', 'Use simple date picker')
: t('installments.advancedToggle', 'Use dynamic triggers')}
</button>
)}
</div>
{enabled && (
<>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
{advanced
? t('installments.advancedHint',
'Pick a trigger (quote accepted, before/after event, on delivery, fixed date) plus offset in days. Triggers re-resolve if the event date later shifts.')
: t('installments.simpleHint',
'Each row fires on a specific date. Switch to dynamic triggers for plans tied to event date or delivery.')}
</p>
<div className="space-y-2">
{rows.map((row, idx) => (
<div
key={idx}
className="grid grid-cols-12 gap-2 items-end p-2 rounded-md bg-neutral-50 dark:bg-neutral-800/40"
>
<div className="col-span-2">
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">
{t('installments.percent', '%')}
</label>
<Input
type="number"
min={0}
max={100}
step="0.01"
value={row.percent}
onChange={(e) => update(idx, { percent: Number(e.target.value) })}
disabled={disabled}
/>
</div>
<div className="col-span-4">
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">
{t('installments.label', 'Label')}
</label>
<Input
value={row.label}
onChange={(e) => update(idx, { label: e.target.value })}
disabled={disabled}
placeholder={t('installments.labelPlaceholder', 'Anzahlung / vor Event …') as string}
/>
</div>
{!advanced ? (
<div className="col-span-5">
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">
{t('installments.sendOn', 'Send on')}
</label>
{row.trigger === 'after_delivery' ? (
<div className="text-xs text-neutral-500 dark:text-neutral-400 py-2">
{t('installments.onDeliveryHint',
'On delivery — admin releases manually. Switch to advanced to change.')}
</div>
) : (
<LocalizedDateInput
value={previewDate(row) || ''}
onChange={(next) => {
if (!next) return;
const offset = daysBetween(todayIso(), next);
update(idx, { trigger: 'fixed_date', offset_days: offset });
}}
disabled={disabled}
/>
)}
</div>
) : (
<>
<div className="col-span-3">
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">
{t('installments.trigger', 'Trigger')}
</label>
<select
value={row.trigger}
onChange={(e) => update(idx, {
trigger: e.target.value as PaymentTermInstallment['trigger'],
offset_days: e.target.value === 'after_delivery' ? 0 : row.offset_days,
})}
disabled={disabled}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
>
{ALL_TRIGGERS.map((tr) => (
<option key={tr} value={tr}>
{t(`installments.triggerOption.${tr}`, tr)}
</option>
))}
</select>
</div>
<div className="col-span-2">
<label className="block text-xs text-neutral-500 dark:text-neutral-400 mb-1">
{t('installments.offsetDays', 'Offset (days)')}
</label>
<Input
type="number"
value={row.offset_days}
onChange={(e) => update(idx, { offset_days: Number(e.target.value) })}
disabled={disabled || row.trigger === 'after_delivery'}
/>
</div>
</>
)}
<div className="col-span-1 flex justify-end">
<button
type="button"
onClick={() => remove(idx)}
disabled={disabled}
className="p-2 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 text-red-600"
aria-label={t('installments.removeRow', 'Remove row') as string}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
<div className="flex items-center justify-between mt-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={addRow}
disabled={disabled || totalPercent >= 100}
leftIcon={<Plus className="w-4 h-4" />}
>
{t('installments.addRow', 'Add installment')}
</Button>
<div className={`text-sm font-medium ${isValid ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'}`}>
{t('installments.total', 'Total')}: {totalPercent.toFixed(2)}%
{!isValid && `${t('installments.mustSumTo100', 'must sum to 100%')}`}
</div>
</div>
</>
)}
</div>
);
};
export default InstallmentsPanel;
@@ -0,0 +1,517 @@
/**
* Reusable line-items editor for quotes + invoices.
*
* Two-level hierarchy (migration 119):
* - Top-level items roll into the document net/VAT/total.
* - Sub-items render indented under their parent. Their line total
* is shown in parentheses for transparency but is display-only;
* only the parent's price contributes to net.
* - Per-item `detailsText` is an optional free-form notes block
* rendered below the description on the PDF + customer view.
*
* Items in `items` are kept in DISPLAY ORDER (parent immediately
* followed by its sub-items, then the next parent, etc.). `position`
* is a stable unique identifier used to link sub-items to parents in
* the payload — once assigned at row creation we never renumber it.
* Move up/down only swaps within the same level (top-level among
* top-level, sub-items among siblings of the same parent).
*
* Money values are stored in MAJOR units in the form state (e.g. 250.00)
* for editor ergonomics, then converted to minor (25000) when persisting.
* The conversion happens at the save boundary in the parent page.
*/
import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, X, ArrowUp, ArrowDown, Save as SaveIcon, ChevronDown, ChevronRight, CornerDownRight } from 'lucide-react';
import { Button } from '../common';
import { DecimalInput } from '../common/DecimalInput';
import { formatMoney } from '../../utils/money';
export interface EditableLineItem {
id?: number;
/** Stable unique identifier used to link sub-items to parents. */
position: number;
quantity: number;
description: string;
/** Stored in major units (CHF / EUR) for UX. */
unitPrice: number;
discountPercent: number;
/** NULL = top-level (rolls into net). Non-null = sub-item under that parent's position. */
parentPosition?: number | null;
/** Optional free-form notes rendered below the description. */
detailsText?: string;
}
export interface LineItemPresetMinimal {
id: number;
name: string;
description: string;
unitPriceMinor: number;
quantityDefault: number;
}
interface Props {
items: EditableLineItem[];
currency: string;
showDiscount?: boolean;
vatRate?: number;
shippingAmount?: number;
/**
* Sub-cent rounding reconciliation (crm_invoice_round_total). When true,
* the net is the full-precision sum rounded once and the per-line
* rounding drift is shown as a "Rundung" row — mirrors the backend
* computeTotals + the PDF so the editor preview matches the saved
* document. Off ⇒ net is the plain sum of rounded lines (unchanged).
*/
roundTotal?: boolean;
onChange: (items: EditableLineItem[]) => void;
presets?: LineItemPresetMinimal[];
onSaveAsPreset?: (item: EditableLineItem) => void;
}
function nextFreshPosition(items: EditableLineItem[]) {
return items.reduce((m, it) => Math.max(m, it.position), 0) + 1;
}
function isSub(li: EditableLineItem) {
return li.parentPosition != null;
}
export const LineItemsTable: React.FC<Props> = ({
items, currency, showDiscount = true, vatRate = 0, shippingAmount = 0, roundTotal = false,
onChange, presets = [], onSaveAsPreset,
}) => {
const { t } = useTranslation();
// Track which rows have the details textarea expanded. Keyed by
// `position` since that's stable across renders.
const [detailsOpen, setDetailsOpen] = useState<Set<number>>(() => new Set(
items.filter((it) => it.detailsText && it.detailsText.trim().length > 0).map((it) => it.position)
));
const toggleDetails = (pos: number) => {
setDetailsOpen((prev) => {
const next = new Set(prev);
if (next.has(pos)) next.delete(pos); else next.add(pos);
return next;
});
};
const setItem = (idx: number, patch: Partial<EditableLineItem>) => {
const next = items.map((it, i) => (i === idx ? { ...it, ...patch } : it));
onChange(next);
};
const addRow = (preset?: LineItemPresetMinimal) => {
const pos = nextFreshPosition(items);
const next = [...items, {
position: pos,
quantity: preset ? Number(preset.quantityDefault) || 1 : 1,
description: preset ? `${preset.name}${preset.description ? `\n${preset.description}` : ''}` : '',
unitPrice: preset ? Number(preset.unitPriceMinor) / 100 : 0,
discountPercent: 0,
parentPosition: null,
detailsText: '',
}];
onChange(next);
};
/**
* Insert a fresh sub-item immediately AFTER the parent's last
* existing sub-item (or the parent itself if there are none yet).
* Keeps display order grouped: parent → its sub-items → next parent.
*/
const addSubItem = (parentIdx: number) => {
const parent = items[parentIdx];
if (!parent || isSub(parent)) return; // can't nest under a sub-item (1 level deep)
let insertAt = parentIdx + 1;
while (insertAt < items.length && items[insertAt].parentPosition === parent.position) {
insertAt += 1;
}
const newRow: EditableLineItem = {
position: nextFreshPosition(items),
quantity: 1,
description: '',
unitPrice: 0,
discountPercent: 0,
parentPosition: parent.position,
detailsText: '',
};
const next = [...items];
next.splice(insertAt, 0, newRow);
onChange(next);
};
/**
* Remove a row. When removing a top-level parent, also sweep its
* sub-items (CASCADE-equivalent in the editor, matches the DB FK
* cascade so the editor's behaviour matches what would persist).
*/
const removeRow = (idx: number) => {
const target = items[idx];
if (!target) return;
if (!isSub(target)) {
onChange(items.filter((it, i) => i !== idx && it.parentPosition !== target.position));
} else {
onChange(items.filter((_, i) => i !== idx));
}
};
/**
* Move up/down — restricted to siblings of the same level. For
* top-level items, the entire "group" (parent + its sub-items) is
* moved as a unit. For sub-items, the swap is within the same
* parent's children only.
*/
const move = (idx: number, dir: -1 | 1) => {
const target = items[idx];
if (!target) return;
if (isSub(target)) {
// Find sibling sub-items with same parent.
const siblings: number[] = [];
for (let i = 0; i < items.length; i += 1) {
if (items[i].parentPosition === target.parentPosition) siblings.push(i);
}
const here = siblings.indexOf(idx);
const other = here + dir;
if (other < 0 || other >= siblings.length) return;
const next = [...items];
[next[siblings[here]], next[siblings[other]]] = [next[siblings[other]], next[siblings[here]]];
onChange(next);
} else {
// Move top-level group as a block. Find the range of this group
// and the adjacent group's range, then swap them.
const groupStart = idx;
let groupEnd = idx + 1;
while (groupEnd < items.length && items[groupEnd].parentPosition === target.position) {
groupEnd += 1;
}
if (dir === -1) {
if (groupStart === 0) return;
// Find the previous top-level item's group range.
let prevTopIdx = groupStart - 1;
while (prevTopIdx > 0 && isSub(items[prevTopIdx])) prevTopIdx -= 1;
const prevGroupStart = prevTopIdx;
const prevGroupEnd = groupStart; // exclusive
const before = items.slice(0, prevGroupStart);
const prevGroup = items.slice(prevGroupStart, prevGroupEnd);
const thisGroup = items.slice(groupStart, groupEnd);
const after = items.slice(groupEnd);
onChange([...before, ...thisGroup, ...prevGroup, ...after]);
} else {
if (groupEnd >= items.length) return;
const nextTopIdx = groupEnd; // is a top-level by construction
let nextGroupEnd = nextTopIdx + 1;
while (nextGroupEnd < items.length && isSub(items[nextGroupEnd])) nextGroupEnd += 1;
const before = items.slice(0, groupStart);
const thisGroup = items.slice(groupStart, groupEnd);
const nextGroup = items.slice(nextTopIdx, nextGroupEnd);
const after = items.slice(nextGroupEnd);
onChange([...before, ...nextGroup, ...thisGroup, ...after]);
}
}
};
const rawLineTotal = (li: EditableLineItem) =>
Math.round(li.quantity * li.unitPrice * (1 - li.discountPercent / 100) * 100) / 100;
/**
* A parent has "priced sub-items" when at least one of its
* children has unitPrice > 0. In that mode the parent's own
* unit_price / qty / discount inputs are disabled and its line
* total auto-resolves to the sum of those priced sub-items.
* Matches the backend resolveParentTotalsFromSubItems() rule
* (migration 119) so the editor mirrors what gets persisted.
*/
// D.4 — memoize the per-parent child-pricing aggregates. The previous
// shape rescanned `items` on every call, and the helpers were called
// inside the JSX loop AND from the subtotal reduce — so on a 20-item
// quote each keystroke ran ~O(n²) array scans. Build a Map once per
// render and read O(1) afterwards.
const childPricingByParent = useMemo(() => {
const map = new Map<number, { hasPriced: boolean; pricedSum: number; pricedSumExact: number }>();
for (const c of items) {
if (c.parentPosition == null) continue;
if (!(c.unitPrice > 0)) continue;
const cur = map.get(c.parentPosition) || { hasPriced: false, pricedSum: 0, pricedSumExact: 0 };
cur.hasPriced = true;
cur.pricedSum += rawLineTotal(c);
// Un-rounded contribution for the clean-net reconciliation below.
cur.pricedSumExact += c.quantity * c.unitPrice * (1 - c.discountPercent / 100);
map.set(c.parentPosition, cur);
}
return map;
// rawLineTotal is a pure function of the closure's `items`; the
// dependency array gets the items snapshot directly.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items]);
const hasPricedChildren = (parentPos: number) =>
childPricingByParent.get(parentPos)?.hasPriced || false;
const pricedChildrenSum = (parentPos: number) =>
childPricingByParent.get(parentPos)?.pricedSum || 0;
/** Resolved line total: parent auto-sums when sub-items are priced. */
const lineTotal = (li: EditableLineItem) => {
if (!isSub(li) && hasPricedChildren(li.position)) {
return pricedChildrenSum(li.position);
}
return rawLineTotal(li);
};
// Subtotal: top-level items ONLY (their resolved totals). Sub-items
// never roll directly into net — they only feed their parent's
// auto-resolved line total.
const subtotal = items.filter((li) => !isSub(li)).reduce((s, li) => s + lineTotal(li), 0);
// Sub-cent reconciliation (crm_invoice_round_total) — mirrors backend
// utils/invoiceRounding.cleanNetMinor: sum each contributing row's
// FULL-PRECISION product (parent with priced sub-items uses the
// children) and round ONCE. The drift vs the sum-of-rounded-lines
// `subtotal` is shown as a "Rundung" row and folded into the total, so
// the editor preview matches the saved invoice + PDF.
const cleanExact = items
.filter((li) => !isSub(li))
.reduce((s, li) => (
hasPricedChildren(li.position)
? s + (childPricingByParent.get(li.position)?.pricedSumExact || 0)
: s + li.quantity * li.unitPrice * (1 - li.discountPercent / 100)
), 0);
const cleanSubtotal = Math.round(cleanExact * 100) / 100;
const roundingAdjustment = roundTotal ? Math.round((cleanSubtotal - subtotal) * 100) / 100 : 0;
// Net the VAT + total work off: clean when reconciling, raw subtotal otherwise.
const netForTotals = subtotal + roundingAdjustment;
// vatRate is a FRACTION (0.081). Round to cents: round(net * vatRate * 100)
// / 100 — the *100 inside round was missing, which divided the VAT by 100
// (CHF 0.63 instead of 63.18). Backend computeTotals + the PDF were always
// correct; only this live editor preview was wrong, and it only surfaced
// once invoices stopped defaulting to 0% VAT.
const vatAmount = Math.round(netForTotals * vatRate * 100) / 100;
const total = netForTotals + vatAmount + (Number(shippingAmount) || 0);
// Display numbering: top-level items get 1, 2, 3...; sub-items
// render as N.1, N.2 under the parent for clarity.
const displayNumbers = (() => {
const out: string[] = [];
let topCount = 0;
let subCount = 0;
for (const li of items) {
if (!isSub(li)) {
topCount += 1;
subCount = 0;
out.push(String(topCount));
} else {
subCount += 1;
out.push(`${topCount}.${subCount}`);
}
}
return out;
})();
return (
<div className="space-y-3">
<div className="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-700">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-2 py-2 text-left w-14">{t('crm.lineItems.position', 'Pos.')}</th>
<th className="px-2 py-2 text-left w-20">{t('crm.lineItems.quantity', 'Anzahl')}</th>
<th className="px-2 py-2 text-left">{t('crm.lineItems.description', 'Beschreibung')}</th>
<th className="px-2 py-2 text-right w-28">{t('crm.lineItems.unitPrice', 'Einzelpreis')}</th>
{showDiscount && (
<th className="px-2 py-2 text-right w-24">{t('crm.lineItems.discount', 'Rabatt %')}</th>
)}
<th className="px-2 py-2 text-right w-28">{t('crm.lineItems.total', 'Summe')}</th>
<th className="px-2 py-2 w-28"></th>
</tr>
</thead>
<tbody>
{items.map((li, idx) => {
const sub = isSub(li);
const open = detailsOpen.has(li.position);
// Parent is "auto-totaled" when at least one of its
// sub-items has a price. In that mode the qty / unit
// price / discount inputs are disabled — the parent's
// total is the sum of priced sub-items, computed by
// the backend on save.
const parentAutoTotaled = !sub && hasPricedChildren(li.position);
const disabledInputClass = parentAutoTotaled
? 'bg-neutral-100 dark:bg-neutral-700 text-neutral-400 cursor-not-allowed'
: 'bg-white dark:bg-neutral-800';
return (
<React.Fragment key={li.position}>
<tr className={`border-t border-neutral-200 dark:border-neutral-700 ${
sub ? 'bg-neutral-50/60 dark:bg-neutral-900/40' : ''
}`}>
<td className="px-2 py-2 text-neutral-600 dark:text-neutral-400 align-top">
<div className="flex items-center gap-1">
{sub && <CornerDownRight className="w-3.5 h-3.5 text-neutral-400" aria-hidden />}
<span>{displayNumbers[idx]}</span>
</div>
</td>
<td className="px-2 py-2 align-top">
<DecimalInput
className={`w-20 rounded border border-neutral-300 dark:border-neutral-600 px-2 py-1 text-sm ${disabledInputClass}`}
value={li.quantity}
onChange={(n) => setItem(idx, { quantity: Number.isFinite(n) ? n : 0 })}
disabled={parentAutoTotaled}
title={parentAutoTotaled ? t('crm.lineItems.autoTotaledHint', 'Total auto-computed from sub-items below') as string : undefined}
/>
</td>
<td className={`px-2 py-2 align-top ${sub ? 'pl-6' : ''}`}>
<textarea
rows={2}
className="w-full rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-2 py-1 text-sm"
value={li.description}
onChange={(e) => setItem(idx, { description: e.target.value })}
placeholder={t('crm.lineItems.descriptionPlaceholder', 'Description (multi-line OK)') as string}
/>
<button
type="button"
onClick={() => toggleDetails(li.position)}
className="mt-1 inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
>
{open
? <ChevronDown className="w-3.5 h-3.5" aria-hidden />
: <ChevronRight className="w-3.5 h-3.5" aria-hidden />}
<span>
{(li.detailsText && li.detailsText.trim().length > 0)
? t('crm.lineItems.detailsFilled', 'Details')
: t('crm.lineItems.detailsAdd', '+ Add details / notes')}
</span>
</button>
{open && (
<textarea
rows={2}
maxLength={2000}
className="mt-2 w-full rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-2 py-1 text-xs italic"
value={li.detailsText || ''}
onChange={(e) => setItem(idx, { detailsText: e.target.value })}
placeholder={t('crm.lineItems.detailsPlaceholder', 'Optional notes — fine print, package inclusions, conditions…') as string}
/>
)}
</td>
<td className="px-2 py-2 align-top">
<DecimalInput
className={`w-24 rounded border border-neutral-300 dark:border-neutral-600 px-2 py-1 text-sm text-right ${disabledInputClass}`}
value={li.unitPrice}
fractionDigits={2}
onChange={(n) => setItem(idx, { unitPrice: Number.isFinite(n) ? n : 0 })}
disabled={parentAutoTotaled}
title={parentAutoTotaled ? t('crm.lineItems.autoTotaledHint', 'Total auto-computed from sub-items below') as string : undefined}
/>
</td>
{showDiscount && (
<td className="px-2 py-2 align-top">
<DecimalInput
className={`w-20 rounded border border-neutral-300 dark:border-neutral-600 px-2 py-1 text-sm text-right ${disabledInputClass}`}
value={li.discountPercent}
onChange={(n) => {
// Clamp to 0..100 — match the original input's min/max.
const clamped = !Number.isFinite(n) ? 0 : Math.max(0, Math.min(100, n));
setItem(idx, { discountPercent: clamped });
}}
disabled={parentAutoTotaled}
title={parentAutoTotaled ? t('crm.lineItems.autoTotaledHint', 'Total auto-computed from sub-items below') as string : undefined}
/>
</td>
)}
<td className={`px-2 py-2 text-right tabular-nums align-top ${
sub
? 'text-neutral-500 dark:text-neutral-400 italic'
: 'font-medium'
}`}>
{sub
? li.unitPrice > 0
? `(${formatMoney(lineTotal(li), currency)})`
: ''
: formatMoney(lineTotal(li), currency)}
{parentAutoTotaled && (
<div className="text-[10px] font-normal text-neutral-500 dark:text-neutral-400 italic mt-0.5">
{t('crm.lineItems.autoTotaledNote', '= Σ Unterpositionen') as string}
</div>
)}
</td>
<td className="px-2 py-2 align-top">
<div className="flex items-center gap-1 justify-end flex-wrap">
<button type="button" onClick={() => move(idx, -1)} aria-label="Move up"
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 disabled:opacity-30">
<ArrowUp className="w-4 h-4" />
</button>
<button type="button" onClick={() => move(idx, 1)} aria-label="Move down"
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700 disabled:opacity-30">
<ArrowDown className="w-4 h-4" />
</button>
{!sub && (
<button type="button" onClick={() => addSubItem(idx)} aria-label="Add sub-item"
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
title={t('crm.lineItems.addSubItem', 'Add sub-item') as string}>
<CornerDownRight className="w-4 h-4" />
</button>
)}
{onSaveAsPreset && !sub && (
<button type="button" onClick={() => onSaveAsPreset(li)} aria-label="Save as preset"
className="p-1 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700"
title={t('crm.lineItems.saveAsPreset', 'Save as preset') as string}>
<SaveIcon className="w-4 h-4" />
</button>
)}
<button type="button" onClick={() => removeRow(idx)} aria-label="Remove"
className="p-1 rounded hover:bg-red-50 dark:hover:bg-red-900/30 text-red-600">
<X className="w-4 h-4" />
</button>
</div>
</td>
</tr>
</React.Fragment>
);
})}
{items.length === 0 && (
<tr><td colSpan={showDiscount ? 7 : 6} className="px-2 py-6 text-center text-neutral-500">
{t('crm.lineItems.empty', 'No line items yet — add one to get started.')}
</td></tr>
)}
</tbody>
</table>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => addRow()}>
<Plus className="w-4 h-4 mr-1" />{t('crm.lineItems.addRow', 'Add row')}
</Button>
{presets.length > 0 && (
<select
className="text-sm rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-1.5"
onChange={(e) => {
const id = parseInt(e.target.value, 10);
const preset = presets.find((p) => p.id === id);
if (preset) addRow(preset);
e.target.value = '';
}}
defaultValue=""
>
<option value="" disabled>{t('crm.lineItems.addFromPreset', 'Add from preset…')}</option>
{presets.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
)}
</div>
<div className="flex flex-col items-end gap-1 text-sm pt-2 border-t border-neutral-200 dark:border-neutral-700">
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('crm.lineItems.subtotal', 'Subtotal')}:</span><span className="tabular-nums w-28 text-right">{formatMoney(subtotal, currency)}</span></div>
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('crm.lineItems.vat', 'VAT')} ({(vatRate * 100).toFixed(1)}%):</span><span className="tabular-nums w-28 text-right">{formatMoney(vatAmount, currency)}</span></div>
{!!shippingAmount && (
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('crm.lineItems.shipping', 'Shipping')}:</span><span className="tabular-nums w-28 text-right">{formatMoney(shippingAmount, currency)}</span></div>
)}
{roundingAdjustment !== 0 && (
<div className="flex gap-6"><span className="text-neutral-600 dark:text-neutral-400">{t('crm.lineItems.rounding', 'Rounding')}:</span><span className="tabular-nums w-28 text-right">{formatMoney(roundingAdjustment, currency)}</span></div>
)}
<div className="flex gap-6 font-semibold text-base"><span>{t('crm.lineItems.total', 'Total')}:</span><span className="tabular-nums w-28 text-right">{formatMoney(total, currency)}</span></div>
</div>
</div>
);
};
// `formatMoney` is now the canonical helper from utils/money. Re-exported
// here so call-sites that historically imported from this file
// (CustomerCrmPanels, page-level summaries) keep working without churn.
export { formatMoney };
@@ -0,0 +1,42 @@
import React from 'react';
import { AlertTriangle, X } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { settingsService } from '../../services/settings.service';
export const MaintenanceBanner: React.FC = () => {
const [dismissed, setDismissed] = React.useState(false);
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings(),
refetchInterval: 60000 // Check every minute
});
const isMaintenanceMode = settings?.general_maintenance_mode === true ||
settings?.general_maintenance_mode === 'true';
if (!isMaintenanceMode || dismissed) {
return null;
}
return (
<div className="bg-amber-50 border-b border-amber-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600" />
<p className="text-sm font-medium text-amber-900">
Maintenance mode is currently enabled. Public access to galleries is restricted.
</p>
</div>
<button
onClick={() => setDismissed(true)}
className="text-amber-600 hover:text-amber-700"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,237 @@
import React, { useState } from 'react';
import { Lock, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card } from '../common';
import { adminService } from '../../services/admin.service';
import { useAdminAuth } from '../../contexts';
export const MandatoryPasswordChangeModal: React.FC = () => {
const { t } = useTranslation();
const { updatePasswordChanged } = useAdminAuth();
const [formData, setFormData] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [showPasswords, setShowPasswords] = useState({
current: false,
new: false,
confirm: false
});
const [errors, setErrors] = useState<Record<string, string>>({});
const changePasswordMutation = useMutation({
mutationFn: adminService.changePassword,
onSuccess: () => {
toast.success(t('mandatoryPasswordChange.success'));
// Force a full page reload so the browser picks up the new JWT cookie
// set by the backend. A React state update alone causes a race condition
// where the auth context checks the session before the cookie is stored.
setTimeout(() => {
window.location.href = '/admin/dashboard';
}, 2000);
},
onError: (error: any) => {
if (error.response?.data?.error) {
toast.error(error.response.data.error);
} else {
toast.error(t('passwordChange.failed'));
}
}
});
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.currentPassword) {
newErrors.currentPassword = t('passwordChange.currentRequired');
}
if (!formData.newPassword) {
newErrors.newPassword = t('passwordChange.newRequired');
} else if (formData.newPassword.length < 12) {
newErrors.newPassword = t('mandatoryPasswordChange.minLengthError');
} else {
// Check for character types
if (!/[a-z]/.test(formData.newPassword)) {
newErrors.newPassword = t('mandatoryPasswordChange.mustContainLowercase');
} else if (!/[A-Z]/.test(formData.newPassword)) {
newErrors.newPassword = t('mandatoryPasswordChange.mustContainUppercase');
} else if (!/[0-9]/.test(formData.newPassword)) {
newErrors.newPassword = t('mandatoryPasswordChange.mustContainNumbersError');
} else if (!/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(formData.newPassword)) {
newErrors.newPassword = t('mandatoryPasswordChange.mustContainSpecialError');
}
}
if (!formData.confirmPassword) {
newErrors.confirmPassword = t('passwordChange.confirmRequired');
} else if (formData.newPassword !== formData.confirmPassword) {
newErrors.confirmPassword = t('passwordChange.noMatch');
}
if (formData.currentPassword === formData.newPassword) {
newErrors.newPassword = t('passwordChange.mustBeDifferent');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
changePasswordMutation.mutate({
currentPassword: formData.currentPassword,
newPassword: formData.newPassword
});
};
const handleInputChange = (field: keyof typeof formData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, [field]: e.target.value }));
// Clear error when user types
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="mb-6 text-center">
<div className="mx-auto w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center mb-4">
<AlertCircle className="w-6 h-6 text-amber-600" />
</div>
<h2 className="text-xl font-semibold text-neutral-900 mb-2">{t('mandatoryPasswordChange.title')}</h2>
<p className="text-sm text-neutral-600">
{t('mandatoryPasswordChange.description')}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Current Password */}
<div>
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 mb-1">
{t('passwordChange.currentPassword')}
</label>
<div className="relative">
<Input
id="currentPassword"
type={showPasswords.current ? 'text' : 'password'}
value={formData.currentPassword}
onChange={handleInputChange('currentPassword')}
error={errors.currentPassword}
placeholder={t('passwordChange.currentPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, current: !prev.current }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
>
{showPasswords.current ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* New Password */}
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 mb-1">
{t('passwordChange.newPassword')}
</label>
<div className="relative">
<Input
id="newPassword"
type={showPasswords.new ? 'text' : 'password'}
value={formData.newPassword}
onChange={handleInputChange('newPassword')}
error={errors.newPassword}
placeholder={t('passwordChange.newPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
>
{showPasswords.new ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* Confirm Password */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
{t('passwordChange.confirmPassword')}
</label>
<div className="relative">
<Input
id="confirmPassword"
type={showPasswords.confirm ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={handleInputChange('confirmPassword')}
error={errors.confirmPassword}
placeholder={t('passwordChange.confirmPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
>
{showPasswords.confirm ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* Password Requirements */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-medium">{t('passwordChange.requirements')}</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>{t('mandatoryPasswordChange.minLength')}</li>
<li>{t('mandatoryPasswordChange.mustContainUpperLower')}</li>
<li>{t('mandatoryPasswordChange.mustContainNumbers')}</li>
<li>{t('mandatoryPasswordChange.mustContainSpecial')}</li>
<li>{t('passwordChange.mustDiffer')}</li>
</ul>
</div>
</div>
</div>
{/* Action Button */}
<div className="pt-2">
<Button
type="submit"
variant="primary"
className="w-full"
isLoading={changePasswordMutation.isPending}
>
{t('passwordChange.title')}
</Button>
</div>
</form>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,69 @@
import React from 'react';
import { Info, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
// Hard-coded "feature flag" — flip to false (or remove this component from
// AdminLayout) once the migration window settles, after operators have had
// ~1 quarter to update their docker-compose.yml. See #669.
const MIGRATION_BANNER_ENABLED = true;
// localStorage key — versioned (`:v1`) so a future "we've moved again" banner
// can show without inheriting the user's earlier dismissal.
const DISMISS_KEY = 'picpeak:migration-banner:v1';
/**
* One-time migration banner shown at the top of the admin layout (#669).
*
* Surfaces the org rename + new GHCR registry path so an operator who hasn't
* read the release notes sees the change when they next log in. Dismissible
* per-admin via localStorage; the toggle above can flip it off globally.
*/
export const MigrationBanner: React.FC = () => {
const { t } = useTranslation();
const [dismissed, setDismissed] = React.useState<boolean>(() => {
try { return localStorage.getItem(DISMISS_KEY) === '1'; } catch { return false; }
});
if (!MIGRATION_BANNER_ENABLED || dismissed) return null;
const handleDismiss = () => {
try { localStorage.setItem(DISMISS_KEY, '1'); } catch { /* localStorage blocked */ }
setDismissed(true);
};
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border-b border-blue-200 dark:border-blue-800">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-start justify-between gap-3 py-3">
<div className="flex items-start gap-3 min-w-0">
<Info className="w-5 h-5 text-blue-600 dark:text-blue-300 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-900 dark:text-blue-100 min-w-0">
<p className="font-medium">{t('migrationBanner.title', "PicPeak's image registry has moved")}</p>
<p className="text-blue-800 dark:text-blue-200 mt-0.5">
{t('migrationBanner.body', {
defaultValue: 'Update your docker-compose.yml to pull from {{newPath}} — the old path is no longer being updated.',
newPath: 'ghcr.io/picpeak/picpeak/{backend,frontend}',
})}{' '}
<a
href="https://github.com/PicPeak/picpeak/blob/main/docs/migration-to-org.md"
target="_blank"
rel="noreferrer"
className="underline hover:no-underline"
>
{t('migrationBanner.link', 'See migration notes')}
</a>
</p>
</div>
</div>
<button
onClick={handleDismiss}
className="text-blue-600 dark:text-blue-300 hover:text-blue-700 dark:hover:text-blue-200 flex-shrink-0"
aria-label={t('common.dismiss', 'Dismiss')}
>
<X className="w-5 h-5" />
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,234 @@
import React, { useState } from 'react';
import { X, Lock, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card } from '../common';
import { adminService } from '../../services/admin.service';
interface PasswordChangeModalProps {
isOpen: boolean;
onClose: () => void;
}
export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen, onClose }) => {
const { t } = useTranslation();
const [formData, setFormData] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [showPasswords, setShowPasswords] = useState({
current: false,
new: false,
confirm: false
});
const [errors, setErrors] = useState<Record<string, string>>({});
const changePasswordMutation = useMutation({
mutationFn: adminService.changePassword,
onSuccess: () => {
toast.success(t('passwordChange.success'));
// Full page reload so the browser picks up the new JWT cookie.
// Same fix as MandatoryPasswordChangeModal — without this, the old
// token gets rejected and causes a redirect loop.
setTimeout(() => {
window.location.href = '/admin/dashboard';
}, 2000);
},
onError: (error: any) => {
if (error.response?.data?.error) {
toast.error(error.response.data.error);
} else {
toast.error(t('passwordChange.failed'));
}
}
});
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.currentPassword) {
newErrors.currentPassword = t('passwordChange.currentRequired');
}
if (!formData.newPassword) {
newErrors.newPassword = t('passwordChange.newRequired');
} else if (formData.newPassword.length < 6) {
newErrors.newPassword = t('passwordChange.minLengthError');
}
if (!formData.confirmPassword) {
newErrors.confirmPassword = t('passwordChange.confirmRequired');
} else if (formData.newPassword !== formData.confirmPassword) {
newErrors.confirmPassword = t('passwordChange.noMatch');
}
if (formData.currentPassword === formData.newPassword) {
newErrors.newPassword = t('passwordChange.mustBeDifferent');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
changePasswordMutation.mutate({
currentPassword: formData.currentPassword,
newPassword: formData.newPassword
});
};
const handleInputChange = (field: keyof typeof formData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, [field]: e.target.value }));
// Clear error when user types
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('passwordChange.title')}</h2>
<button
onClick={onClose}
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-neutral-500" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Current Password */}
<div>
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('passwordChange.currentPassword')}
</label>
<div className="relative">
<Input
id="currentPassword"
type={showPasswords.current ? 'text' : 'password'}
value={formData.currentPassword}
onChange={handleInputChange('currentPassword')}
error={errors.currentPassword}
placeholder={t('passwordChange.currentPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, current: !prev.current }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
>
{showPasswords.current ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* New Password */}
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('passwordChange.newPassword')}
</label>
<div className="relative">
<Input
id="newPassword"
type={showPasswords.new ? 'text' : 'password'}
value={formData.newPassword}
onChange={handleInputChange('newPassword')}
error={errors.newPassword}
placeholder={t('passwordChange.newPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
>
{showPasswords.new ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* Confirm Password */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('passwordChange.confirmPassword')}
</label>
<div className="relative">
<Input
id="confirmPassword"
type={showPasswords.confirm ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={handleInputChange('confirmPassword')}
error={errors.confirmPassword}
placeholder={t('passwordChange.confirmPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded"
>
{showPasswords.confirm ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* Password Requirements */}
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium">{t('passwordChange.requirements')}</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>{t('passwordChange.minLength')}</li>
<li>{t('passwordChange.mustDiffer')}</li>
</ul>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3 pt-2">
<Button
type="button"
variant="outline"
onClick={onClose}
>
{t('passwordChange.cancel')}
</Button>
<Button
type="submit"
variant="primary"
isLoading={changePasswordMutation.isPending}
>
{t('passwordChange.title')}
</Button>
</div>
</form>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,266 @@
import React, { useState } from 'react';
import { X, Key, Copy, CheckCircle, Mail, Lock, Eye, EyeOff } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { Button, Card, Input, PasswordGenerator } from '../common';
interface PasswordResetModalProps {
eventName: string;
eventDate?: string;
eventType?: string;
onConfirm: (sendEmail: boolean, password?: string) => Promise<{ newPassword: string; emailSent: boolean }>;
onClose: () => void;
}
export const PasswordResetModal: React.FC<PasswordResetModalProps> = ({
eventName,
eventDate,
eventType,
onConfirm,
onClose
}) => {
const { t } = useTranslation();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [sendEmail, setSendEmail] = useState(true);
const [isResetting, setIsResetting] = useState(false);
const [errors, setErrors] = useState<{ password?: string; confirmPassword?: string }>({});
const [resultPassword, setResultPassword] = useState<string | null>(null);
const [resultWasGenerated, setResultWasGenerated] = useState(false);
const [copied, setCopied] = useState(false);
const validate = (): boolean => {
const next: typeof errors = {};
// Empty is allowed → server auto-generates. Only validate when typed.
if (password) {
if (password.length < 6) {
next.password = t('events.passwordReset.errorMinLength');
}
if (password !== confirmPassword) {
next.confirmPassword = t('events.passwordReset.errorMismatch');
}
}
setErrors(next);
return Object.keys(next).length === 0;
};
const handleReset = async () => {
if (!validate()) return;
setIsResetting(true);
try {
const supplied = password.length > 0 ? password : undefined;
const result = await onConfirm(sendEmail, supplied);
setResultPassword(result.newPassword);
setResultWasGenerated(!supplied);
if (supplied) {
toast.success(t('events.passwordReset.toastSuccess'));
}
} catch (error: any) {
const serverError = error?.response?.data;
if (serverError?.error === 'Password does not meet security requirements') {
setErrors({ password: serverError.feedback?.join?.(' ') || t('events.passwordReset.errorMinLength') });
} else {
toast.error(serverError?.error || t('events.passwordReset.toastError'));
}
} finally {
setIsResetting(false);
}
};
const handleCopy = async () => {
if (resultPassword) {
await navigator.clipboard.writeText(resultPassword);
setCopied(true);
toast.success(t('events.passwordReset.toastCopied'));
setTimeout(() => setCopied(false), 2000);
}
};
const handlePasswordGenerated = (generated: string) => {
setPassword(generated);
setConfirmPassword(generated);
setErrors({});
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-md w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{resultPassword ? t('events.passwordReset.newTitle') : t('events.passwordReset.title')}
</h2>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
>
<X className="w-5 h-5" />
</button>
</div>
{!resultPassword ? (
<>
<p className="text-neutral-600 mb-4">
{t('events.passwordReset.description', { eventName })}
</p>
<div className="space-y-4 mb-4">
<div>
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.passwordReset.newPasswordLabel')}
placeholder={t('events.passwordReset.placeholder')}
value={password}
onChange={(e) => {
setPassword(e.target.value);
if (errors.password) setErrors((prev) => ({ ...prev, password: undefined }));
}}
error={errors.password}
helperText={t('events.passwordReset.helperText')}
leftIcon={<Lock className="w-5 h-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/>
<div className="mt-2">
<PasswordGenerator
eventName={eventName}
eventDate={eventDate}
eventType={eventType}
onPasswordGenerated={handlePasswordGenerated}
passwordComplexity="moderate"
className="w-full"
/>
</div>
</div>
{password.length > 0 && (
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.passwordReset.confirmLabel')}
placeholder={t('events.passwordReset.confirmLabel')}
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
if (errors.confirmPassword) setErrors((prev) => ({ ...prev, confirmPassword: undefined }));
}}
error={errors.confirmPassword}
leftIcon={<Lock className="w-5 h-5" />}
/>
)}
</div>
<div className="mb-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={sendEmail}
onChange={(e) => setSendEmail(e.target.checked)}
className="w-4 h-4 text-accent bg-neutral-100 dark:bg-neutral-700 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500 focus:ring-2"
/>
<div className="flex-1">
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-neutral-500" />
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('events.passwordReset.sendEmail')}
</span>
</div>
<p className="text-xs text-neutral-500 mt-1">
{t('events.passwordReset.sendEmailHelp')}
</p>
</div>
</label>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-6">
<p className="text-sm text-amber-800">
{t('events.passwordReset.warning')}
</p>
</div>
<div className="flex gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={isResetting}
className="flex-1"
>
{t('common.cancel')}
</Button>
<Button
variant="primary"
onClick={handleReset}
disabled={isResetting}
isLoading={isResetting}
leftIcon={<Key className="w-4 h-4" />}
className="flex-1"
>
{t('events.passwordReset.submit')}
</Button>
</div>
</>
) : (
<>
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
<div className="flex items-center gap-3 mb-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<p className="font-medium text-green-900">{t('events.passwordReset.successHeading')}</p>
</div>
{sendEmail && (
<p className="text-sm text-green-700">
{t('events.passwordReset.emailSentNote')}
</p>
)}
</div>
{resultWasGenerated && (
<>
<div className="mb-4">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.passwordReset.generatedLabel')}
</label>
<div className="flex gap-2">
<input
type="text"
value={resultPassword}
readOnly
className="flex-1 px-3 py-2 bg-neutral-50 dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-700 text-neutral-900 dark:text-neutral-100 rounded-lg font-mono text-sm"
/>
<Button
variant="outline"
onClick={handleCopy}
leftIcon={copied ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
>
{copied ? t('events.copied') : t('events.copy')}
</Button>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-6">
<p className="text-sm text-blue-800">
{t('events.passwordReset.saveSecurelyNote')}
</p>
</div>
</>
)}
<Button
variant="primary"
onClick={onClose}
className="w-full"
>
{t('events.passwordReset.done')}
</Button>
</>
)}
</Card>
</div>
);
};
@@ -0,0 +1,102 @@
/**
* Settings → Branding card: lets the admin pick the font used on every
* PDF (quotes, invoices, tax report) from the bundled families. Sits
* directly beneath the web Typography customizer card on
* `BrandingPage`, inside the left column — matches the typography
* box width so the two visually pair.
*
* Controlled component. State + persistence live on the parent so the
* page's top-level "Save changes" button writes this together with
* the rest of the branding form (no card-local save button).
*
* Loads the same `/public/fonts` list the web font picker consumes,
* so any family bundled in `backend/assets/fonts/` shows up here too
* automatically. The selected value persists to
* `business_profile.pdf_font_family` and pdfService maps it to
* `<family>/400.ttf` (body) + `<family>/700.ttf` (bold) at render
* time.
*
* Hidden by the caller when no PDF-producing feature is enabled
* (quotes / bills / taxReport all off) — when there's no PDF surface
* the setting is irrelevant.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Type } from 'lucide-react';
import { Card } from '../common';
import { fontsService } from '../../services/fonts.service';
/**
* The bundled-fonts API returns the DISPLAY name (e.g. "Playfair
* Display") but pdfService resolves a DIRECTORY name (e.g.
* "Playfair-Display"). They're always related by space ↔ hyphen.
* The helpers below convert between them so the dropdown can show
* a clean human label while persisting the on-disk identifier.
*/
const familyToDirectory = (family: string) => family.replace(/ /g, '-');
const directoryToFamily = (dir: string) => dir.replace(/-/g, ' ');
export interface PdfTypographyCardProps {
/** Directory name (e.g. "Inter", "Playfair-Display") or null/""
* for "Use Helvetica (default)". */
value: string | null;
onChange: (value: string | null) => void;
}
export const PdfTypographyCard: React.FC<PdfTypographyCardProps> = ({ value, onChange }) => {
const { t } = useTranslation();
// Same query the web typography picker consumes — single source of
// truth for "which bundled families exist on disk".
const { data: availableFonts } = useQuery({
queryKey: ['fonts'],
queryFn: () => fontsService.list(),
staleTime: 60 * 60 * 1000, // fonts don't change at runtime
});
const selection = value || '';
return (
<Card className="p-6">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
<Type className="w-5 h-5" />
{t('branding.pdfTypography', 'PDF typography')}
</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('branding.pdfTypographyHelp',
'Used for invoice + quote letterheads. Pick one of the bundled fonts, or leave on default to use Helvetica.')}
</p>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.pdfFontFamily', 'Body font')}
</label>
<select
value={selection}
onChange={(e) => onChange(e.target.value ? e.target.value : null)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="">
{t('branding.pdfFontFamilyDefault', 'Use Helvetica (default)')}
</option>
{(availableFonts || []).map((f) => (
<option key={f.family} value={familyToDirectory(f.family)}>
{/* Show the display name (with spaces) — but persist
the directory name (with hyphens) so pdfService can
find the on-disk family without an extra lookup. */}
{f.family}
</option>
))}
{/* When the saved value points at a family that's no
longer on disk (e.g. uploaded by an earlier admin,
later removed), still show it so the admin sees what
they have rather than silently re-mapping to default. */}
{selection && !(availableFonts || []).some((f) => familyToDirectory(f.family) === selection) && (
<option value={selection}>
{directoryToFamily(selection)} ({t('branding.pdfFontFamilyMissing', 'missing')})
</option>
)}
</select>
</Card>
);
};
@@ -0,0 +1,60 @@
import React from 'react';
import type { ReactNode } from 'react';
import { usePermissions } from '../../contexts/PermissionsContext';
interface PermissionGateProps {
permission?: string;
permissions?: string[];
requireAll?: boolean;
fallback?: ReactNode;
children: ReactNode;
}
/**
* PermissionGate component that conditionally renders children based on user permissions.
*
* @param permission - A single permission to check
* @param permissions - An array of permissions to check
* @param requireAll - If true, requires all permissions (AND logic). If false, requires any permission (OR logic). Default: false
* @param fallback - Content to render if permission check fails. Default: null
* @param children - Content to render if permission check passes
*/
export const PermissionGate: React.FC<PermissionGateProps> = ({
permission,
permissions,
requireAll = false,
fallback = null,
children,
}) => {
const { hasPermission, hasAnyPermission, hasAllPermissions, isSuperAdmin } = usePermissions();
// Super admin bypasses all permission checks
if (isSuperAdmin) {
return <>{children}</>;
}
// Check single permission
if (permission) {
if (hasPermission(permission)) {
return <>{children}</>;
}
return <>{fallback}</>;
}
// Check multiple permissions
if (permissions && permissions.length > 0) {
const hasAccess = requireAll
? hasAllPermissions(permissions)
: hasAnyPermission(permissions);
if (hasAccess) {
return <>{children}</>;
}
return <>{fallback}</>;
}
// If no permissions specified, render children (allow access)
return <>{children}</>;
};
PermissionGate.displayName = 'PermissionGate';
@@ -0,0 +1,234 @@
import React, { useState } from 'react';
import { Download, FileText, FileSpreadsheet, Archive, FileJson, ChevronDown, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useMutation } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { photosService, ExportOptions, FeedbackFilters } from '../../services/photos.service';
import { ExportPreviewModal } from './ExportPreviewModal';
import { useMutationWithToast, useModal } from '../../hooks';
// TXT + CSV render through the preview modal (with copy-to-clipboard and a
// fallback download button). XMP is a ZIP archive — no textarea preview makes
// sense. JSON stays a direct download because operators consuming it want a
// file for tooling. See #631.
const PREVIEW_FORMATS: ReadonlyArray<'txt' | 'csv'> = ['txt', 'csv'];
interface PhotoExportMenuProps {
eventId: number;
selectedPhotoIds: number[];
filters?: FeedbackFilters;
disabled?: boolean;
}
const EXPORT_FORMATS = [
{
value: 'txt',
label: 'Filename List (TXT)',
description: 'Simple text list for Lightroom search',
icon: FileText
},
{
value: 'csv',
label: 'Filename List (CSV)',
description: 'Spreadsheet with metadata',
icon: FileSpreadsheet
},
{
value: 'xmp',
label: 'XMP Sidecar Files (ZIP)',
description: 'Import ratings into Lightroom/Bridge',
icon: Archive
},
{
value: 'json',
label: 'Metadata (JSON)',
description: 'Structured data for automation',
icon: FileJson
},
];
export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
eventId,
selectedPhotoIds,
filters,
disabled = false
}) => {
const { t } = useTranslation();
const menuModal = useModal();
const [preview, setPreview] = useState<{
format: 'txt' | 'csv';
content: string;
filename: string;
} | null>(null);
const exportMutation = useMutationWithToast({
mutationFn: (options: ExportOptions) => photosService.exportPhotos(eventId, options),
successMessage: t('export.success', 'Export downloaded successfully'),
onSuccess: () => {
menuModal.close();
},
errorMessage: (error: Error) => t('export.error', 'Export failed: ') + error.message
});
const previewMutation = useMutation({
mutationFn: ({ options, format }: { options: ExportOptions; format: 'txt' | 'csv' }) =>
photosService.exportPhotosAsText(eventId, options).then((result) => ({
...result,
format,
})),
onSuccess: (result) => {
setPreview(result);
menuModal.close();
},
onError: (error: Error) => {
toast.error(t('export.error', 'Export failed: ') + error.message);
},
});
const handleExport = (format: 'txt' | 'csv' | 'xmp' | 'json') => {
const options: ExportOptions = {
format,
options: {
filename_format: 'original',
// TXT is labelled "for Lightroom search" — Lightroom's filename
// search field takes one comma-separated line, and the gallery
// JPEGs may correspond to RAW files in the catalog so the search
// has to match on the stem only (issue #623).
...(format === 'txt' ? { separator: 'comma' as const, include_extension: false } : {}),
include_rating: true,
include_label: true,
include_description: true,
include_keywords: true
}
};
// Use selected photos if any, otherwise use filters
if (selectedPhotoIds.length > 0) {
options.photo_ids = selectedPhotoIds;
} else if (filters) {
// Convert camelCase filter keys to snake_case for backend
options.filter = {
min_rating: filters.minRating,
max_rating: filters.maxRating,
has_likes: filters.hasLikes,
min_likes: filters.minLikes,
has_favorites: filters.hasFavorites,
min_favorites: filters.minFavorites,
has_comments: filters.hasComments,
category_id: filters.categoryId,
logic: filters.logic,
sort: filters.sort,
order: filters.order,
};
}
if ((PREVIEW_FORMATS as readonly string[]).includes(format)) {
previewMutation.mutate({ options, format: format as 'txt' | 'csv' });
} else {
exportMutation.mutate(options);
}
};
const hasSelection = selectedPhotoIds.length > 0;
const hasFilters = filters && (
filters.minRating !== null ||
filters.hasLikes ||
filters.hasFavorites ||
filters.hasComments
);
const isDisabled = disabled || (!hasSelection && !hasFilters);
const isWorking = exportMutation.isPending || previewMutation.isPending;
return (
<div className="relative">
<button
type="button"
onClick={menuModal.toggle}
disabled={isDisabled || isWorking}
className={`
inline-flex items-center gap-2 px-4 py-2 rounded-lg border font-medium text-sm
transition-colors
${isDisabled
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-400 border-neutral-200 dark:border-neutral-700 cursor-not-allowed'
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600 hover:bg-neutral-50 dark:hover:bg-neutral-700'
}
`}
>
{isWorking ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Download className="w-4 h-4" />
)}
{t('export.button', 'Export')}
{hasSelection && (
<span className="bg-accent-dark/15 text-accent-dark text-xs px-2 py-0.5 rounded-full">
{selectedPhotoIds.length}
</span>
)}
<ChevronDown className={`w-4 h-4 transition-transform ${menuModal.isOpen ? 'rotate-180' : ''}`} />
</button>
{menuModal.isOpen && !isDisabled && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-10"
onClick={menuModal.close}
/>
{/* Dropdown Menu */}
<div className="absolute right-0 mt-2 w-72 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700 z-20">
<div className="p-2">
<p className="px-3 py-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
{hasSelection
? t('export.exportSelected', 'Export {{count}} selected', { count: selectedPhotoIds.length })
: t('export.exportFiltered', 'Export filtered photos')
}
</p>
{EXPORT_FORMATS.map((format) => {
const Icon = format.icon;
return (
<button
key={format.value}
onClick={() => handleExport(format.value as 'txt' | 'csv' | 'xmp' | 'json')}
disabled={isWorking}
className="w-full flex items-start gap-3 px-3 py-2 rounded-md hover:bg-neutral-50 dark:hover:bg-neutral-700 text-left transition-colors"
>
<Icon className="w-5 h-5 text-neutral-500 dark:text-neutral-400 mt-0.5" />
<div>
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{format.label}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{format.description}
</div>
</div>
</button>
);
})}
</div>
</div>
</>
)}
{!hasSelection && !hasFilters && (
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('export.hint', 'Select photos or apply filters to export')}
</p>
)}
{preview && (
<ExportPreviewModal
format={preview.format}
content={preview.content}
filename={preview.filename}
onClose={() => setPreview(null)}
/>
)}
</div>
);
};
export default PhotoExportMenu;
@@ -0,0 +1,202 @@
import React from 'react';
import { Star, Heart, Bookmark, MessageCircle, Filter, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '../common';
import { FeedbackFilters, FilterSummary } from '../../services/photos.service';
interface PhotoFilterPanelProps {
filters: FeedbackFilters;
onChange: (filters: FeedbackFilters) => void;
summary: FilterSummary | null;
isLoading?: boolean;
}
const RATING_OPTIONS = [
{ value: null, label: 'filter.allPhotos' },
{ value: 0.1, label: 'filter.anyRating' },
{ value: 1, label: 'filter.oneStarPlus' },
{ value: 2, label: 'filter.twoStarsPlus' },
{ value: 3, label: 'filter.threeStarsPlus' },
{ value: 4, label: 'filter.fourStarsPlus' },
{ value: 5, label: 'filter.fiveStarsOnly' },
] as const;
export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
filters,
onChange,
summary,
isLoading = false
}) => {
const { t } = useTranslation();
const handleRatingChange = (value: number | null) => {
onChange({ ...filters, minRating: value });
};
const handleCheckboxChange = (field: 'hasLikes' | 'hasFavorites' | 'hasComments') => {
onChange({ ...filters, [field]: !filters[field] });
};
const handleLogicChange = (logic: 'AND' | 'OR') => {
onChange({ ...filters, logic });
};
const clearFilters = () => {
onChange({
minRating: null,
hasLikes: false,
hasFavorites: false,
hasComments: false,
logic: 'AND'
});
};
const hasActiveFilters = filters.minRating !== null ||
filters.hasLikes ||
filters.hasFavorites ||
filters.hasComments;
return (
<div className="bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 p-4 mb-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<Filter className="w-4 h-4" />
{t('filter.feedbackFilters', 'Feedback Filters')}
</h3>
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
leftIcon={<X className="w-3 h-3" />}
>
{t('filter.clear', 'Clear')}
</Button>
)}
</div>
<div className="space-y-4">
{/* Rating Filter */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
<Star className="w-4 h-4 inline mr-1" />
{t('filter.rating', 'Rating')}
</label>
<select
value={filters.minRating ?? ''}
onChange={(e) => handleRatingChange(e.target.value === '' ? null : parseFloat(e.target.value))}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
disabled={isLoading}
>
{RATING_OPTIONS.map(option => (
<option key={option.label} value={option.value ?? ''}>
{t(option.label, { defaultValue: option.label.split('.').pop() })}
</option>
))}
</select>
</div>
{/* Checkbox Filters */}
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.hasLikes || false}
onChange={() => handleCheckboxChange('hasLikes')}
className="rounded border-neutral-300 text-accent focus:ring-primary-500"
disabled={isLoading}
/>
<Heart className="w-4 h-4 text-red-500" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{t('filter.hasLikes', 'Has likes')}
{summary && (
<span className="text-neutral-500 dark:text-neutral-400 ml-1">({summary.withLikes})</span>
)}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.hasFavorites || false}
onChange={() => handleCheckboxChange('hasFavorites')}
className="rounded border-neutral-300 text-accent focus:ring-primary-500"
disabled={isLoading}
/>
<Bookmark className="w-4 h-4 text-yellow-500" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{t('filter.hasFavorites', 'Has favorites')}
{summary && (
<span className="text-neutral-500 dark:text-neutral-400 ml-1">({summary.withFavorites})</span>
)}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={filters.hasComments || false}
onChange={() => handleCheckboxChange('hasComments')}
className="rounded border-neutral-300 text-accent focus:ring-primary-500"
disabled={isLoading}
/>
<MessageCircle className="w-4 h-4 text-blue-500" />
<span className="text-sm text-neutral-700 dark:text-neutral-300">
{t('filter.hasComments', 'Has comments')}
{summary && (
<span className="text-neutral-500 dark:text-neutral-400 ml-1">({summary.withComments})</span>
)}
</span>
</label>
</div>
{/* Logic Toggle */}
{(filters.hasLikes || filters.hasFavorites || filters.hasComments) && (
<div className="flex items-center gap-2">
<span className="text-sm text-neutral-600 dark:text-neutral-400">{t('filter.combineWith', 'Combine with')}:</span>
<div className="flex rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<button
type="button"
onClick={() => handleLogicChange('AND')}
className={`px-3 py-1 text-sm font-medium transition-colors ${
filters.logic === 'AND' || !filters.logic
? 'bg-accent-dark text-white'
: 'bg-white dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700'
}`}
disabled={isLoading}
>
AND
</button>
<button
type="button"
onClick={() => handleLogicChange('OR')}
className={`px-3 py-1 text-sm font-medium transition-colors ${
filters.logic === 'OR'
? 'bg-accent-dark text-white'
: 'bg-white dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700'
}`}
disabled={isLoading}
>
OR
</button>
</div>
</div>
)}
{/* Summary */}
{summary && (
<div className="pt-2 border-t border-neutral-100 dark:border-neutral-700 text-sm text-neutral-600 dark:text-neutral-400">
{t('filter.showingPhotos', 'Total photos')}: {summary.total}
{summary.withRatings > 0 && (
<span className="ml-2">
| {t('filter.withRatings', 'With ratings')}: {summary.withRatings}
</span>
)}
</div>
)}
</div>
</div>
);
};
export default PhotoFilterPanel;
@@ -0,0 +1,118 @@
import React from 'react';
import { Search, Filter, SortAsc, SortDesc } from 'lucide-react';
import { Input } from '../common';
import { useTranslation } from 'react-i18next';
interface PhotoFiltersProps {
categories: Array<{ id: number | string; name: string; slug: string }>;
selectedCategory: number | string | null | undefined;
searchTerm: string;
sortBy: 'date' | 'name' | 'size' | 'rating';
sortOrder: 'asc' | 'desc';
onCategoryChange: (categoryId: number | string | null | undefined) => void;
onSearchChange: (search: string) => void;
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating', order: 'asc' | 'desc') => void;
mediaType?: 'all' | 'photo' | 'video';
onMediaTypeChange?: (mediaType: 'all' | 'photo' | 'video') => void;
showMediaFilter?: boolean;
}
export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
categories,
selectedCategory,
searchTerm,
sortBy,
sortOrder,
onCategoryChange,
onSearchChange,
onSortChange,
mediaType = 'all',
onMediaTypeChange,
showMediaFilter = false
}) => {
const { t } = useTranslation();
const handleSortToggle = () => {
onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc');
};
return (
<div className="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 mb-6">
<div className="flex flex-col lg:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<Input
type="text"
placeholder={t('gallery.searchByFilename', 'Search by filename...')}
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
/>
</div>
{/* Category Filter */}
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-neutral-400" />
<select
value={selectedCategory === null ? '' : selectedCategory || ''}
onChange={(e) => {
const raw = e.target.value;
if (raw === '') return onCategoryChange(null);
const numeric = Number(raw);
onCategoryChange(Number.isNaN(numeric) ? raw : numeric);
}}
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
>
<option value="">{t('gallery.allCategories', 'All Categories')}</option>
<option value="0">{t('gallery.uncategorized', 'Uncategorized')}</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>
{cat.name}
</option>
))}
</select>
</div>
{showMediaFilter && onMediaTypeChange && (
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-neutral-400" />
<select
value={mediaType}
onChange={(e) => onMediaTypeChange(e.target.value as 'all' | 'photo' | 'video')}
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
>
<option value="all">{t('gallery.allMedia', 'All media')}</option>
<option value="photo">{t('gallery.photosOnly', 'Photos only')}</option>
<option value="video">{t('gallery.videosOnly', 'Videos only')}</option>
</select>
</div>
)}
{/* Sort Options */}
<div className="flex items-center gap-2">
<select
value={sortBy}
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)}
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-accent-dark"
>
<option value="date">{t('gallery.sortByDate', 'Sort by Date')}</option>
<option value="name">{t('gallery.sortByName', 'Sort by Name')}</option>
<option value="size">{t('gallery.sortBySize', 'Sort by Size')}</option>
<option value="rating">{t('gallery.sortByRating', 'Sort by Rating')}</option>
</select>
<button
onClick={handleSortToggle}
className="p-2 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
aria-label={sortOrder === 'asc' ? t('gallery.sortDescending', 'Sort descending') : t('gallery.sortAscending', 'Sort ascending')}
>
{sortOrder === 'asc' ? (
<SortAsc className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
) : (
<SortDesc className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
)}
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,719 @@
import React, { useState, useRef, useMemo, useEffect } from 'react';
import { Upload, X, Image, Loader2, Cog, AlertTriangle } from 'lucide-react';
import { Button } from '../common';
import { clsx } from 'clsx';
import { api } from '../../config/api';
import { toast } from 'react-toastify';
import { useQuery } from '@tanstack/react-query';
import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
import { useUploadProgress } from '../../hooks/useUploadProgress';
interface PhotoUploadProps {
eventId: number;
/** Refresh the photo grid. Called early (as bytes land) and again when
* processing finishes. Never closes the modal. */
onUploadComplete?: () => void;
/** Fired once the transfer stage is done, reporting whether any file
* failed. The host (modal) uses this to decide whether to auto-close:
* a clean upload closes as before; a partial failure keeps the modal
* open so the failure report stays visible. */
onUploadSettled?: (result: { hasFailures: boolean }) => void;
}
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
// Upload phase machine. The user perceives "frozen" during 'processing'
// because the bytes are already on the server and we're waiting for
// thumbnail/EXIF/etc. work — the explicit phase + hint message kills
// that perception (#352 / contributor analysis on issue 357 review).
type UploadPhase =
| { kind: 'idle' }
| { kind: 'transferring'; chunkIndex: number; totalChunks: number; bytePct: number }
| { kind: 'processing'; chunkIndex: number; totalChunks: number; filesInChunk: number };
// Why a file didn't make it into the gallery. Each maps to a distinct
// stage so the user knows whether to re-pick the file (rejected), retry
// the network (transfer), or check the source image (processing).
// - rejected: validation/queueing refused it (bad type, too large,
// corrupt) — returned per-file in the upload response.
// - transfer: the whole chunk request failed (timeout, 5xx, network).
// - processing: stored fine, but the background worker couldn't process
// it (from useUploadProgress's failedPhotos).
type UploadFailureKind = 'rejected' | 'transfer' | 'processing';
interface UploadFailure {
filename: string;
reason: string;
kind: UploadFailureKind;
}
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete, onUploadSettled }) => {
const { t } = useTranslation();
const [isUploading, setIsUploading] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [currentChunk, setCurrentChunk] = useState(0);
const [totalChunks, setTotalChunks] = useState(0);
const [phase, setPhase] = useState<UploadPhase>({ kind: 'idle' });
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [replaceByName, setReplaceByName] = useState(false);
// Upload IDs returned from each chunk POST. The processing tracker
// hook merges status across all of them so the user sees one unified
// progress count even when the upload spans multiple HTTP requests.
const [uploadIds, setUploadIds] = useState<string[]>([]);
// Per-file failures surfaced from the transfer stage (chunk POSTs):
// validation rejections (response.errors) and whole-chunk failures.
// Processing failures are merged in from the progress hook below.
const [transferFailures, setTransferFailures] = useState<UploadFailure[]>([]);
// Processing failures are captured into state (not read live) because the
// completion effect clears uploadIds, which empties the progress hook's
// failedPhotos — reading live would make the rows vanish the instant they
// appear.
const [processingFailures, setProcessingFailures] = useState<UploadFailure[]>([]);
const [failuresDismissed, setFailuresDismissed] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { aggregate: processingAggregate } = useUploadProgress(uploadIds, {
enabled: phase.kind === 'processing' && uploadIds.length > 0,
});
// Single source of truth for the "which files failed" report: transfer
// stage failures (collected during handleUpload) plus processing failures
// (captured on completion). Both carry filename + reason.
const failures = useMemo<UploadFailure[]>(
() => [...transferFailures, ...processingFailures],
[transferFailures, processingFailures]
);
// Fetch categories for this event
const { data: categories = [] } = useQuery({
queryKey: ['event-categories', eventId],
queryFn: () => categoriesService.getEventCategories(eventId),
});
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings(),
});
const maxFilesPerUpload = React.useMemo(() => {
const rawValue = settings?.general_max_files_per_upload;
const parsed = Number(rawValue);
if (!Number.isFinite(parsed)) {
return DEFAULT_MAX_FILES_PER_UPLOAD;
}
return Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, Math.floor(parsed)));
}, [settings]);
const allowedMimeTypes = useMemo(
() => extensionsToMimeTypes(settings?.general_allowed_file_types),
[settings?.general_allowed_file_types]
);
const acceptString = useMemo(
() => extensionsToAcceptString(settings?.general_allowed_file_types),
[settings?.general_allowed_file_types]
);
const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
const [isDragOver, setIsDragOver] = useState(false);
// Shared filter + per-upload-limit pipeline used by both the file-input
// change handler and the drop handler. #504 — without the drop handler
// the dashed-border zone looked draggable but silently fell through to
// the browser's default "open the file in a new tab" behaviour.
const addFiles = (incoming: File[]) => {
const imageFiles = incoming.filter((file) => allowedMimeTypes.includes(file.type));
if (imageFiles.length === 0) return;
const totalFiles = selectedFiles.length + imageFiles.length;
if (totalFiles > maxFilesPerUpload) {
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
if (allowedNewFiles <= 0) {
toast.error(
t('upload.maxFilesReached', { limit: maxFilesPerUpload }) ||
`Maximum ${maxFilesPerUpload} files allowed`
);
return;
}
toast.warning(
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
`Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
);
setSelectedFiles((prev) => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
return;
}
setSelectedFiles((prev) => [...prev, ...imageFiles]);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
addFiles(Array.from(e.target.files || []));
// Reset the input so picking the same files again still fires onChange.
if (e.target.value) e.target.value = '';
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
// dropEffect must be set on every dragover for the cursor to render
// the "copy" affordance in Chrome/Firefox.
e.dataTransfer.dropEffect = 'copy';
if (!isDragOver) setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
// dragleave fires for every child node the cursor passes — only flip
// the highlight off when the cursor leaves the zone itself, otherwise
// it strobes on/off as the user moves over the icon and text.
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files || []);
addFiles(files);
};
const removeFile = (index: number) => {
setSelectedFiles(prev => prev.filter((_, i) => i !== index));
};
const handleUpload = async () => {
if (selectedFiles.length === 0) return;
// Validate file count
if (selectedFiles.length > maxFilesPerUpload) {
toast.error(
t('upload.tooManyFiles', { limit: maxFilesPerUpload }) ||
`Maximum ${maxFilesPerUpload} files can be uploaded at once`
);
return;
}
setIsUploading(true);
setUploadProgress(0);
setUploadIds([]);
// Clear any prior failure report before this run.
setTransferFailures([]);
setProcessingFailures([]);
setFailuresDismissed(false);
// For large uploads, chunk the files by both count AND size to prevent memory/network issues.
// #509: the per-chunk byte cap MUST be tunable so users behind Cloudflare Tunnel and other
// reverse proxies with request-size limits can drop it below their proxy's cap. Falls back
// to 95MB (Cloudflare-safe headroom under 100MB) when the setting is unset — that matches
// the value the migration seeds and is what worked in #208's resolution.
const MAX_FILES_PER_CHUNK = Math.max(1, Math.min(50, maxFilesPerUpload)); // Max 50 files per chunk
const maxBatchSizeMb = Number(settings?.general_max_upload_batch_size_mb) || 95;
const MAX_BYTES_PER_CHUNK = maxBatchSizeMb * 1024 * 1024;
const chunks: File[][] = [];
let currentChunk: File[] = [];
let currentChunkSize = 0;
for (const file of selectedFiles) {
// Start a new chunk if adding this file would exceed limits
if (currentChunk.length >= MAX_FILES_PER_CHUNK ||
(currentChunkSize + file.size > MAX_BYTES_PER_CHUNK && currentChunk.length > 0)) {
chunks.push(currentChunk);
currentChunk = [];
currentChunkSize = 0;
}
currentChunk.push(file);
currentChunkSize += file.size;
}
// Don't forget the last chunk
if (currentChunk.length > 0) {
chunks.push(currentChunk);
}
setTotalChunks(chunks.length);
let totalReplaced = 0;
// Accumulates transfer-stage failures (per-file rejections + whole-chunk
// failures) with their reasons, so the report can name each one.
const collected: UploadFailure[] = [];
// Whether at least one chunk was accepted for background processing.
let anyQueued = false;
try {
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
setCurrentChunk(chunkIndex + 1);
const chunk = chunks[chunkIndex];
const formData = new FormData();
chunk.forEach((file) => {
formData.append('photos', file);
});
if (selectedCategoryId) {
formData.append('category_id', selectedCategoryId.toString());
}
if (replaceByName) {
formData.append('replace_by_name', 'true');
}
setPhase({
kind: 'transferring',
chunkIndex,
totalChunks: chunks.length,
bytePct: 0,
});
try {
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const chunkProgress = progressEvent.loaded / progressEvent.total;
const overallProgress = ((chunkIndex + chunkProgress) / chunks.length) * 100;
setUploadProgress(Math.round(overallProgress));
// Once bytes have all left the browser, the request is
// sitting in the backend processing pipeline. Flip to
// 'processing' so the UI explains the wait instead of
// looking frozen at the chunk's max progress.
if (chunkProgress >= 1) {
setPhase((prev) =>
prev.kind === 'transferring' && prev.chunkIndex === chunkIndex
? {
kind: 'processing',
chunkIndex,
totalChunks: chunks.length,
filesInChunk: chunk.length,
}
: prev
);
} else {
setPhase({
kind: 'transferring',
chunkIndex,
totalChunks: chunks.length,
bytePct: Math.round(chunkProgress * 100),
});
}
}
},
});
totalReplaced += (response.data?.replacedCount || 0);
// The backend accepts the request (202) but may reject individual
// files (bad type, too large, corrupt) and reports them in
// `errors: [{ filename, error }]`. Surface each one by name.
const rejected = response.data?.errors;
if (Array.isArray(rejected)) {
for (const r of rejected) {
collected.push({
filename: r?.filename || t('upload.failures.unknownFile', 'Unknown file'),
reason: r?.error || t('upload.failures.unknownReason', 'Unknown error'),
kind: 'rejected',
});
}
}
// Track the per-request upload_id so the processing hook can poll
// for live progress — but only when photos were actually queued
// (count > 0). The backend returns an upload_id even when every
// file was rejected (count 0); tracking it there would make us wait
// for a processing phase that never starts, hanging the spinner.
if (response.data?.upload_id && (response.data?.count ?? 0) > 0) {
anyQueued = true;
const newId = response.data.upload_id as string;
setUploadIds((prev) => (prev.includes(newId) ? prev : [...prev, newId]));
}
} catch (error: any) {
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
const reason =
error?.response?.data?.error ||
error?.message ||
t('upload.failures.transferReason', 'Transfer failed');
collected.push(
...chunk.map((f) => ({ filename: f.name, reason, kind: 'transfer' as const }))
);
// Continue with next chunk even if one fails
continue;
}
}
// Bytes are all on the server. Clear the file picker so the
// user can queue another batch — but DON'T dismiss the upload
// UI yet; we'll watch the processing aggregate (useEffect below)
// to know when the backend has finished generating thumbnails
// and metadata.
setSelectedFiles([]);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
if (totalReplaced > 0) {
toast.info(t('upload.replacedFiles', { count: totalReplaced }) || `${totalReplaced} photo(s) replaced`);
}
// Publish transfer-stage failures to the report (rendered below with
// each filename + reason). The toast is just the headline; the list
// is where the user finds out *which* files failed.
setTransferFailures(collected);
if (collected.length > 0) {
toast.warning(
t('upload.failures.toast', '{{count}} file(s) could not be uploaded — see the list below.', {
count: collected.length,
})
);
}
// Refresh the grid early so the user sees their photos appearing
// as the worker processes them. The processing-aggregate effect
// below will refresh again on completion.
if (onUploadComplete) {
onUploadComplete();
}
// Settling (and the modal's close decision) is deferred until we know
// the WHOLE outcome, including background processing. If nothing was
// queued (every file rejected, or a pre-async backend), the transfer
// stage is already terminal — settle now and reset the UI. Otherwise
// the processing effect below settles once the worker finishes, so
// processing failures are included before the modal decides to close.
if (!anyQueued) {
onUploadSettled?.({ hasFailures: collected.length > 0 });
setIsUploading(false);
setUploadProgress(0);
setCurrentChunk(0);
setTotalChunks(0);
setPhase({ kind: 'idle' });
}
} catch (error: any) {
console.error('Upload error:', error);
toast.error(error.response?.data?.error || t('toast.uploadError'));
setIsUploading(false);
setUploadProgress(0);
setCurrentChunk(0);
setTotalChunks(0);
setPhase({ kind: 'idle' });
setUploadIds([]);
}
};
// When the background worker finishes processing every photo from
// this upload, dismiss the upload UI and surface the result.
useEffect(() => {
if (!isUploading) return;
if (uploadIds.length === 0) return;
if (!processingAggregate.isComplete) return;
if (processingAggregate.failed > 0) {
// Persist the failed photos into the report before uploadIds is cleared
// below (which would otherwise empty the progress hook's failedPhotos).
setProcessingFailures(
processingAggregate.failedPhotos.map((p) => ({
filename: p.filename,
reason: p.error || t('upload.failures.unknownReason', 'Unknown error'),
kind: 'processing' as const,
}))
);
toast.warning(
t('upload.processingFailed', { count: processingAggregate.failed }) ||
`${processingAggregate.failed} photo(s) failed to process`
);
} else {
toast.success(
t('upload.uploadComplete') || `Successfully uploaded ${processingAggregate.complete} photo(s)`
);
}
if (onUploadComplete) onUploadComplete();
// Now the whole outcome is known — settle. The modal auto-closes only
// when nothing failed at either stage; any transfer OR processing
// failure keeps it open so the report (which lists both) stays visible.
onUploadSettled?.({
hasFailures: transferFailures.length > 0 || processingAggregate.failed > 0,
});
setIsUploading(false);
setUploadProgress(0);
setCurrentChunk(0);
setTotalChunks(0);
setPhase({ kind: 'idle' });
setUploadIds([]);
// We intentionally only react to processingAggregate.isComplete /
// .failed — the rest of the deps either don't move during this
// effect's lifetime or are stable callbacks.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [processingAggregate.isComplete, processingAggregate.failed, isUploading]);
const formatFileSize = (bytes: number) => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
return (
<div className="space-y-4">
{/* Category Selection */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('upload.photoCategory')}
</label>
<select
value={selectedCategoryId || ''}
onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : null)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500"
>
<option value="">{t('upload.noCategory')}</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name} {!category.is_global && t('upload.eventSpecific')}
</option>
))}
</select>
</div>
{/* Replace by name toggle */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="replace-by-name"
checked={replaceByName}
onChange={(e) => setReplaceByName(e.target.checked)}
className="rounded border-neutral-300 text-accent focus:ring-primary-500"
/>
<label htmlFor="replace-by-name" className="text-sm text-neutral-700 dark:text-neutral-300">
{t('upload.replaceByName', 'Replace existing photos with same name')}
</label>
</div>
{/* File Input Area — accepts both click-to-pick and drag-and-drop (#504). */}
<div
className={clsx(
"border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer",
"hover:border-accent-dark hover:bg-accent-dark/15",
isDragOver
? "border-accent-dark bg-accent-dark/25"
: selectedFiles.length > 0
? "border-accent-dark bg-accent-dark/15"
: "border-neutral-300 dark:border-neutral-600"
)}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Upload className="w-12 h-12 mx-auto text-neutral-400 dark:text-neutral-500 mb-4" />
<p className="text-neutral-700 dark:text-neutral-300 font-medium mb-1">
{t('upload.clickToUpload')}
</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
</p>
<p
className={clsx(
"text-xs mt-2",
remainingSlots === 0 ? "text-red-600" : "text-neutral-500 dark:text-neutral-400"
)}
>
{remainingSlots === 0
? t('upload.limitReached', { limit: maxFilesPerUpload })
: t('upload.limitInfo', {
selected: selectedFiles.length,
limit: maxFilesPerUpload,
remaining: remainingSlots,
})}
</p>
<input
ref={fileInputRef}
type="file"
multiple
accept={acceptString}
onChange={handleFileSelect}
className="hidden"
/>
</div>
{/* Selected Files */}
{selectedFiles.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('upload.selectedFiles')} ({selectedFiles.length})
</p>
<div className="max-h-48 overflow-y-auto space-y-2">
{selectedFiles.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-2 bg-neutral-50 dark:bg-neutral-800 rounded-lg"
>
<div className="flex items-center gap-3">
<Image className="w-5 h-5 text-neutral-400" />
<div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300 truncate max-w-xs">
{file.name}
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{formatFileSize(file.size)}
</p>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
removeFile(index);
}}
className="p-1 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
</div>
)}
{/* Upload Button */}
<div className="flex justify-end">
<Button
variant="primary"
onClick={handleUpload}
disabled={selectedFiles.length === 0 || isUploading}
leftIcon={isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
>
{isUploading ? t('upload.uploading') : t('common.upload') + ` ${selectedFiles.length} ${t(selectedFiles.length === 1 ? 'common.photo' : 'common.photos')}`}
</Button>
</div>
{/* Failure report — names every file that didn't make it into the
gallery, grouped by failure stage, so the user can act on each.
Persists until dismissed or a new upload starts. */}
{!failuresDismissed && failures.length > 0 && (
<div
data-testid="upload-failure-report"
role="status"
aria-live="polite"
className="rounded-lg border border-amber-300 dark:border-amber-700/60 bg-amber-50 dark:bg-amber-900/20 p-4"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2 text-amber-800 dark:text-amber-300">
<AlertTriangle className="w-5 h-5 flex-shrink-0" />
<p className="text-sm font-medium">
{t('upload.failures.title', '{{count}} file(s) could not be uploaded', {
count: failures.length,
})}
</p>
</div>
<button
type="button"
onClick={() => setFailuresDismissed(true)}
aria-label={t('common.dismiss', 'Dismiss')}
className="p-1 -m-1 text-amber-700 dark:text-amber-300 hover:bg-amber-100 dark:hover:bg-amber-800/40 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
<ul className="mt-3 max-h-48 overflow-y-auto space-y-1.5">
{failures.map((f, i) => (
<li key={`${f.kind}-${f.filename}-${i}`} className="flex items-start gap-2 text-xs">
<span
className={clsx(
'flex-shrink-0 mt-0.5 px-1.5 py-0.5 rounded font-medium whitespace-nowrap',
f.kind === 'rejected' && 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
f.kind === 'transfer' && 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
f.kind === 'processing' && 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300'
)}
>
{f.kind === 'rejected' && t('upload.failures.kindRejected', 'Rejected')}
{f.kind === 'transfer' && t('upload.failures.kindTransfer', 'Transfer failed')}
{f.kind === 'processing' && t('upload.failures.kindProcessing', 'Processing failed')}
</span>
<span className="min-w-0">
<span className="font-medium text-neutral-800 dark:text-neutral-200 break-all">
{f.filename}
</span>
<span className="text-neutral-500 dark:text-neutral-400"> {f.reason}</span>
</span>
</li>
))}
</ul>
</div>
)}
{/* Progress display — two distinct phases. Bytes-on-wire ('transferring')
drives the determinate bar; the post-bytes wait ('processing') swaps
in an indeterminate spinner with an explanatory hint so users don't
assume the upload froze. */}
{isUploading && (
<div className="mt-4">
{phase.kind === 'processing' ? (
<div className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-4">
<div className="flex items-start gap-3">
<Cog className="w-5 h-5 text-amber-600 dark:text-amber-400 animate-spin shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
{t('upload.processing')}
</p>
{processingAggregate.total > 0 && (
<>
<p className="text-xs text-amber-900 dark:text-amber-100 font-medium mt-2">
{t('upload.processingProgress', {
complete: processingAggregate.complete + processingAggregate.failed,
total: processingAggregate.total,
})}
</p>
<div className="w-full bg-amber-100 dark:bg-amber-900/40 rounded-full h-2 mt-1">
<div
className="bg-amber-600 dark:bg-amber-500 h-2 rounded-full transition-all duration-300"
style={{
width: `${
processingAggregate.total === 0
? 0
: Math.round(
((processingAggregate.complete + processingAggregate.failed) /
processingAggregate.total) *
100
)
}%`,
}}
/>
</div>
</>
)}
<p className="text-xs text-amber-800 dark:text-amber-200 mt-2">
{t('upload.processingHint')}
</p>
</div>
</div>
</div>
) : (
<>
<div className="flex justify-between text-sm text-neutral-600 dark:text-neutral-400 mb-1">
<span>
{t('upload.transferring')}
{totalChunks > 1 && ` (${t('common.chunk')} ${currentChunk}/${totalChunks})`}
</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
<div
className="bg-accent-dark h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
{totalChunks > 1 && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('upload.uploadingChunks', { count: selectedFiles.length, total: totalChunks })}
</p>
)}
</>
)}
</div>
)}
</div>
);
};
PhotoUpload.displayName = 'PhotoUpload';
@@ -0,0 +1,68 @@
import React from 'react';
import { X } from 'lucide-react';
import { Button } from '../common';
import { PhotoUpload } from './PhotoUpload';
import { useTranslation } from 'react-i18next';
interface PhotoUploadModalProps {
isOpen: boolean;
onClose: () => void;
eventId: number;
onUploadComplete?: () => void;
}
export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
isOpen,
onClose,
eventId,
onUploadComplete
}) => {
const { t } = useTranslation();
if (!isOpen) return null;
// Refresh the host's grid, but do NOT close here — closing is decided by
// onUploadSettled so a partial-failure upload keeps the modal (and its
// failure report) open.
const handleUploadComplete = () => {
onUploadComplete?.();
};
// Auto-close only on a clean upload; keep the modal open when some files
// failed so the report stays visible until the user dismisses it.
const handleUploadSettled = ({ hasFailures }: { hasFailures: boolean }) => {
if (!hasFailures) {
onClose();
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow-xl w-full max-w-2xl flex flex-col max-h-[90vh]">
{/* Fixed Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200 dark:border-neutral-700">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('upload.uploadMedia', t('events.uploadPhotos'))}</h2>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="!p-1"
>
<X className="w-5 h-5" />
</Button>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-6">
<PhotoUpload
eventId={eventId}
onUploadComplete={handleUploadComplete}
onUploadSettled={handleUploadSettled}
/>
</div>
</div>
</div>
);
};
PhotoUploadModal.displayName = 'PhotoUploadModal';
@@ -0,0 +1,219 @@
import React, { useRef, useState } from 'react';
import { Download, Upload, AlertTriangle, ShieldAlert, ExternalLink, CheckCircle2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../common';
import { api } from '../../config/api';
// Portable ".picpeak" roundtrip, split across two Backup Manager tabs:
// - PicpeakExportCard → Dashboard (making a backup)
// - PicpeakRestoreCard → Restore (restoring a backup)
// The manifest is bundled inside the .picpeak, so there is no separate
// "manifest only" download here.
interface RestoreResult {
tables: number;
filesRestored: number;
usesExternalMedia: boolean;
}
// ── Download half (Dashboard) ────────────────────────────────────────────────
export const PicpeakExportCard: React.FC = () => {
const { t } = useTranslation();
const [includePhotos, setIncludePhotos] = useState(false);
const [downloading, setDownloading] = useState(false);
const handleDownload = async () => {
setDownloading(true);
try {
const res = await api.get('/admin/backup/picpeak/export', {
params: { includePhotos },
responseType: 'blob',
});
const cd = (res.headers['content-disposition'] as string) || '';
const match = cd.match(/filename="?([^"]+)"?/);
const filename = (match && match[1]) || 'picpeak-backup.picpeak';
const url = window.URL.createObjectURL(res.data as Blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (_) {
toast.error(t('backup.picpeak.downloadFailed', 'Could not create the backup file.'));
} finally {
setDownloading(false);
}
};
return (
<Card padding="lg">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.picpeak.title', 'Portable backup (.picpeak)')}
</h3>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('backup.picpeak.intro', 'Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.')}
</p>
<div className="mt-6">
<label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
<input
type="checkbox"
className="h-4 w-4 rounded border-neutral-300"
checked={includePhotos}
onChange={(e) => setIncludePhotos(e.target.checked)}
/>
{t('backup.picpeak.includePhotos', 'Include original gallery photos (larger file)')}
</label>
<div className="mt-3 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/50 dark:bg-amber-900/20">
<ShieldAlert className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
<p className="text-xs text-amber-800 dark:text-amber-200">
{t('backup.picpeak.secretsWarning', 'This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.')}
</p>
</div>
<Button
variant="outline"
className="mt-3"
isLoading={downloading}
onClick={handleDownload}
leftIcon={<Download className="h-4 w-4" />}
>
{t('backup.picpeak.download', 'Download .picpeak')}
</Button>
</div>
</Card>
);
};
PicpeakExportCard.displayName = 'PicpeakExportCard';
// ── Restore half (Restore tab) ───────────────────────────────────────────────
export const PicpeakRestoreCard: React.FC = () => {
const { t } = useTranslation();
const fileRef = useRef<HTMLInputElement>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const [restoring, setRestoring] = useState(false);
const [result, setResult] = useState<RestoreResult | null>(null);
const onFilePick = (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0];
if (f) setPendingFile(f);
e.target.value = ''; // let the user re-pick the same file after cancelling
};
const confirmRestore = async () => {
if (!pendingFile) return;
setRestoring(true);
try {
const fd = new FormData();
fd.append('backup', pendingFile);
const res = await api.post<RestoreResult>('/admin/backup/picpeak/import', fd);
setResult(res.data);
setPendingFile(null);
toast.success(t('backup.picpeak.restoreDone', 'Backup restored.'));
} catch (e: any) {
const msg = e.response?.data?.error || t('backup.picpeak.restoreFailed', 'Restore failed.');
toast.error(msg);
setPendingFile(null);
} finally {
setRestoring(false);
}
};
return (
<Card padding="lg">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')}
</h3>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')}
</p>
<input ref={fileRef} type="file" accept=".picpeak,application/zip" className="hidden" onChange={onFilePick} />
<Button
variant="outline"
className="mt-4"
onClick={() => fileRef.current?.click()}
leftIcon={<Upload className="h-4 w-4" />}
>
{t('backup.picpeak.chooseFile', 'Choose .picpeak file…')}
</Button>
{result && (
<div className="mt-4 rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-900/50 dark:bg-green-900/20">
<div className="flex items-start gap-2">
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0 text-green-600 dark:text-green-400" />
<div className="min-w-0">
<p className="text-sm font-medium text-green-800 dark:text-green-200">
{t('backup.picpeak.restoreDone', 'Backup restored.')}
</p>
<p className="mt-0.5 text-xs text-green-700 dark:text-green-300">
{t('backup.picpeak.restoreSummary', '{{tables}} tables and {{files}} files restored.', {
tables: result.tables,
files: result.filesRestored,
})}
</p>
{result.usesExternalMedia && (
<p className="mt-2 flex items-start gap-1 text-xs text-amber-800 dark:text-amber-300">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
<span>
{t('backup.picpeak.externalMediaNote', 'This backup references an external-media library. Make sure external-media routing is configured on this instance.')}{' '}
<a
href="https://github.com/PicPeak/picpeak/blob/main/README.md"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline"
>
{t('backup.picpeak.externalMediaLink', 'Setup guide')}
<ExternalLink className="h-3 w-3" />
</a>
</span>
</p>
)}
<Button variant="primary" size="sm" className="mt-3" onClick={() => window.location.reload()}>
{t('backup.picpeak.reload', 'Reload app')}
</Button>
</div>
</div>
</div>
)}
{/* Destructive confirmation */}
{pendingFile && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-neutral-800">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-6 w-6 flex-shrink-0 text-red-600 dark:text-red-400" />
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('backup.picpeak.confirmTitle', 'Restore will delete all current data')}
</h3>
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-300">
{t('backup.picpeak.confirmBody', 'This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.')}
</p>
<p className="mt-2 truncate text-xs text-neutral-500 dark:text-neutral-400">{pendingFile.name}</p>
</div>
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="outline" onClick={() => setPendingFile(null)} disabled={restoring}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
className="!bg-red-600 hover:!bg-red-700"
isLoading={restoring}
onClick={confirmRestore}
>
{t('backup.picpeak.confirmRestore', 'Delete & restore')}
</Button>
</div>
</div>
</div>
)}
</Card>
);
};
PicpeakRestoreCard.displayName = 'PicpeakRestoreCard';
@@ -0,0 +1,74 @@
/**
* ProjectSelect — a gated project picker reused by the quote / contract /
* hours / event editors to link a document to a Project Overview project.
*
* Renders nothing when the `projects` feature flag is off, so every call
* site stays a one-liner that simply vanishes when the feature is disabled
* (the maintainer's "book to project must not show unless projects is
* enabled" requirement). Customers never see this — admin surfaces only.
*/
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { projectsService } from '../../services/projects.service';
interface ProjectSelectProps {
value: number | null;
onChange: (projectId: number | null) => void;
/** Optional label above the select. When omitted the select renders bare. */
label?: string;
/** Restrict the list to a single customer's projects when set. */
customerAccountId?: number | null;
disabled?: boolean;
className?: string;
}
export const ProjectSelect: React.FC<ProjectSelectProps> = ({
value,
onChange,
label,
customerAccountId,
disabled,
className,
}) => {
const { t } = useTranslation();
const { flags } = useFeatureFlags();
const { data: projects, isLoading } = useQuery({
queryKey: ['projects', 'select'],
queryFn: () => projectsService.list(),
enabled: !!flags.projects,
staleTime: 60_000,
});
// Hard gate: hidden entirely when the feature is off.
if (!flags.projects) return null;
const options = (projects || []).filter(
(p) => customerAccountId == null || p.customerAccountId == null || p.customerAccountId === customerAccountId,
);
return (
<div className={className}>
{label && (
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{label}
</label>
)}
<select
value={value ?? ''}
disabled={disabled || isLoading}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : null)}
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 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:opacity-60"
>
<option value="">{t('projects.picker.none', 'No project')}</option>
{options.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
);
};
@@ -0,0 +1,162 @@
import React, { useState } from 'react';
import { X, Send, Lock, Eye, EyeOff } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card, Input } from '../common';
interface PublishGalleryDialogProps {
eventName: string;
requirePassword: boolean;
customerEmail?: string | null;
/** Assigned customer accounts — notified via the account "your galleries" email when there's no inline email. */
assignedCustomerCount?: number;
isPublishing: boolean;
onConfirm: (password?: string) => void;
onClose: () => void;
}
/**
* Confirmation dialog for the "Publish & Notify" action on a draft gallery.
*
* When the gallery is password-protected, the admin re-types the password
* here so the gallery_created email can carry the real plaintext instead of
* the "(set at creation)" sentinel (#627). The backend also re-hashes what
* the admin types so the stored hash matches what was just emailed — admins
* who mistype at creation get a self-healing publish flow.
*
* For galleries without a password, the dialog is a plain confirm + Publish
* button (mirrors the previous window.confirm() flow).
*/
export const PublishGalleryDialog: React.FC<PublishGalleryDialogProps> = ({
eventName,
requirePassword,
customerEmail,
assignedCustomerCount = 0,
isPublishing,
onConfirm,
onClose,
}) => {
const { t } = useTranslation();
// Someone gets notified if there's an inline email OR an assigned account
// (the latter via the account "your galleries" email).
const willNotify = !!customerEmail || assignedCustomerCount > 0;
// The password is only collected (and required) on the inline-email path,
// because the gallery_created email carries it. With no inline email the field
// is hidden and the existing hash is kept — so don't gate submit on it, or a
// password-protected gallery without an email could never be published.
const needsPassword = requirePassword && !!customerEmail;
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const handleSubmit = () => {
if (needsPassword) {
if (!password || password.trim().length < 6) {
setError(t('events.publishDialog.errorMinLength', 'Password must be at least 6 characters long.'));
return;
}
}
setError(undefined);
onConfirm(needsPassword ? password : undefined);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="max-w-md w-full">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{t('events.publishDialog.title', 'Publish gallery')}
</h2>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
aria-label={t('common.close', 'Close')}
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-neutral-600 dark:text-neutral-400 mb-4">
{customerEmail
? t('events.publishDialog.descriptionWithEmail', {
eventName,
customerEmail,
defaultValue:
'Publishing "{{eventName}}" makes the gallery accessible and sends the notification email to {{customerEmail}}.',
})
: assignedCustomerCount > 0
? t('events.publishDialog.descriptionAssignedAccount', {
eventName,
count: assignedCustomerCount,
defaultValue:
'Publishing "{{eventName}}" makes the gallery accessible. The assigned customer account(s) will be notified by email (in their language) that it is available.',
})
: t('events.publishDialog.descriptionNoEmail', {
eventName,
defaultValue:
'Publishing "{{eventName}}" makes the gallery accessible. No customer email is set, so no notification will be sent.',
})}
</p>
{needsPassword && (
<div className="space-y-3 mb-4">
<Input
type={showPassword ? 'text' : 'password'}
label={t('events.publishDialog.passwordLabel', 'Gallery password')}
placeholder={t('events.publishDialog.passwordPlaceholder', 'Enter the gallery password')}
value={password}
onChange={(e) => {
setPassword(e.target.value);
if (error) setError(undefined);
}}
error={error}
helperText={t(
'events.publishDialog.passwordHelp',
'Re-type the password set at creation (or pick a new one). The email includes this exact text; the backend re-hashes it so the gallery login still works.',
)}
leftIcon={<Lock className="w-5 h-5" />}
rightIcon={
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="p-1"
aria-label={showPassword ? t('events.passwordReset.hide', 'Hide') : t('events.passwordReset.show', 'Show')}
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
}
/>
</div>
)}
{/* Stack both buttons vertically (always). The German primary label
"Veröffentlichen & Kunden benachrichtigen" is ~40 chars including
the icon — at max-w-md, no side-by-side row layout fits it on one
line, and the base .btn class has @apply whitespace-nowrap (see
index.css:149) which overrides a whitespace-normal className via
CSS cascade order, so the text won't wrap either. Side-by-side
would silently push the button past the modal frame (#670).
col-reverse keeps the DOM order semantically secondary-then-primary
while putting the primary action visually on top — standard
confirmation-dialog pattern. */}
<div className="flex flex-col-reverse gap-3">
<Button
variant="outline"
onClick={onClose}
disabled={isPublishing}
>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={isPublishing}
isLoading={isPublishing}
leftIcon={willNotify ? <Send className="w-4 h-4" /> : undefined}
>
{willNotify ? t('events.publishAndNotify') : t('events.publishDialog.justPublish', 'Publish')}
</Button>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,80 @@
/**
* Received-emails feed — read-only, paginated view of the received_emails log
* (the IMAP poller's audit trail). Rendered as the "Received emails" tab in
* EmailConfigPage, next to "Sent emails".
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Inbox, Paperclip } from 'lucide-react';
import { Card, Loading, Button } from '../common';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { emailService } from '../../services/email.service';
const statusClass = (s: string): string =>
s === 'ingested' ? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
: s === 'error' ? 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
: 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300';
export const ReceivedEmailsPanel: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({ queryKey: ['received-emails', page], queryFn: () => emailService.listReceived({ page, pageSize: 25 }), refetchInterval: 30000, refetchOnWindowFocus: true });
if (isLoading) return <Loading />;
const items = data?.items ?? [];
const pg = data?.pagination;
if (items.length === 0) {
return (
<Card className="p-8 text-center">
<Inbox className="w-10 h-10 mx-auto mb-3 text-neutral-400" />
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('email.received.empty', 'No received emails yet. Enable incoming mail and configure the mailbox.')}</p>
</Card>
);
}
return (
<Card className="p-0 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800/50 text-left text-xs uppercase text-neutral-500 dark:text-neutral-400">
<tr>
<th className="px-4 py-2">{t('email.received.from', 'From')}</th>
<th className="px-4 py-2">{t('email.received.subject', 'Subject')}</th>
<th className="px-4 py-2">{t('email.received.received', 'Received')}</th>
<th className="px-4 py-2">{t('email.received.status', 'Status')}</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
{items.map((r) => (
<tr key={r.id}>
<td className="px-4 py-2 text-neutral-700 dark:text-neutral-300 truncate max-w-[14rem]">{r.from_address || '—'}</td>
<td className="px-4 py-2 text-neutral-900 dark:text-neutral-100">
<span className="truncate inline-block max-w-[18rem] align-middle">{r.subject || '—'}</span>
{r.attachment_count > 0 && (
<span className="ml-2 inline-flex items-center gap-0.5 text-xs text-neutral-500">
<Paperclip className="w-3 h-3" />{r.attachment_count}
{r.inbound_document_id && <Link to="/admin/accounting/inbox" className="ml-1 text-primary-600 hover:underline">{t('email.received.inbox', 'inbox')}</Link>}
</span>
)}
</td>
<td className="px-4 py-2 text-neutral-500 dark:text-neutral-400 whitespace-nowrap">{r.received_at ? fmtDateTime(r.received_at) : '—'}</td>
<td className="px-4 py-2"><span className={`inline-block rounded px-2 py-0.5 text-xs font-medium ${statusClass(r.status)}`}>{t(`email.received.statusValue.${r.status}`, r.status)}</span></td>
</tr>
))}
</tbody>
</table>
{pg && pg.totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-neutral-100 dark:border-neutral-800 text-sm">
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>{t('common.previous', 'Previous')}</Button>
<span className="text-neutral-500">{page} / {pg.totalPages}</span>
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.min(pg.totalPages, p + 1))} disabled={page >= pg.totalPages}>{t('common.next', 'Next')}</Button>
</div>
)}
</Card>
);
};
export default ReceivedEmailsPanel;
@@ -0,0 +1,25 @@
import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
interface RequireFeatureProps {
flag: FeatureKey;
fallback?: string;
}
/**
* Route guard that redirects to /admin/dashboard when the named feature
* flag is OFF. Used so deep links (or stale bookmarks) to disabled
* surfaces don't render an empty page or a half-loaded view.
*
* Mounted as the `element` of a parent <Route>, with the gated routes as
* children — see App.tsx.
*/
export const RequireFeature: React.FC<RequireFeatureProps> = ({ flag, fallback = '/admin/dashboard' }) => {
const { flags, isLoading } = useFeatureFlags();
// Wait for the first fetch — otherwise we'd briefly fall back to the
// default-flags object and could redirect on a transient false.
if (isLoading) return null;
if (!flags[flag]) return <Navigate to={fallback} replace />;
return <Outlet />;
};
+3
View File
@@ -0,0 +1,3 @@
import type { ComponentType } from 'react';
export const RestoreWizard: ComponentType<any>;
@@ -0,0 +1,965 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PicpeakRestoreCard } from './PicpeakBackupCard';
import {
RefreshCw,
AlertTriangle,
CheckCircle,
XCircle,
Upload,
HardDrive,
Cloud,
Server,
Database,
Image,
FileArchive,
Info,
ChevronRight,
ChevronLeft,
Loader2,
Shield,
Download,
Eye,
Calendar,
Clock,
AlertCircle,
ShieldCheck
} from 'lucide-react';
import { toast } from 'react-toastify';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Button, Card, Input, Loading } from '../common';
import { api } from '../../config/api';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
export const RestoreWizard = ({ onVerifyIntegrity } = {}) => {
const { t } = useTranslation();
const { format: fmtDate, formatTime: fmtTime, formatDateTime: fmtDateTime } = useLocalizedDate();
const [currentStep, setCurrentStep] = useState(0);
const steps = [
{ id: 'source', title: t('backup.restore.steps.selectSource') },
{ id: 'backup', title: t('backup.restore.steps.chooseBackup') },
{ id: 'options', title: t('backup.restore.steps.restoreOptions') },
{ id: 'confirm', title: t('backup.restore.steps.reviewConfirm') },
{ id: 'progress', title: t('backup.restore.steps.progress') }
];
const restoreTypes = [
{
id: 'full',
name: t('backup.restore.restoreTypes.full.name'),
description: t('backup.restore.restoreTypes.full.description'),
icon: RefreshCw,
warning: t('backup.restore.restoreTypes.full.warning')
},
{
id: 'database',
name: t('backup.restore.restoreTypes.database.name'),
description: t('backup.restore.restoreTypes.database.description'),
icon: Database,
warning: t('backup.restore.restoreTypes.database.warning')
},
{
id: 'files',
name: t('backup.restore.restoreTypes.files.name'),
description: t('backup.restore.restoreTypes.files.description'),
icon: Image,
warning: t('backup.restore.restoreTypes.files.warning')
},
{
id: 'selective',
name: t('backup.restore.restoreTypes.selective.name'),
description: t('backup.restore.restoreTypes.selective.description'),
icon: CheckCircle,
warning: t('backup.restore.restoreTypes.selective.warning')
}
];
const [restoreData, setRestoreData] = useState({
source: null,
sourceConfig: {},
selectedBackup: null,
restoreType: 'full',
selectedItems: [],
skipPreBackup: false,
force: false,
encryptionPassphrase: ''
});
const [validationResult, setValidationResult] = useState(null);
// Fetch restore status
const { data: restoreStatus } = useQuery({
queryKey: ['restore-status'],
queryFn: async () => {
const response = await api.get('/admin/restore/status');
return response.data.data;
},
refetchInterval: currentStep === 4 ? 2000 : false // Poll during restore
});
// Fetch available backups
const { data: availableBackups, isLoading: loadingBackups } = useQuery({
queryKey: ['available-backups', restoreData.source, restoreData.sourceConfig],
queryFn: async () => {
const response = await api.post('/admin/restore/list-backups', {
source: restoreData.source,
...restoreData.sourceConfig
});
return response.data.data;
},
enabled: currentStep === 1 && !!restoreData.source
});
// Validate restore
const validateMutation = useMutation({
mutationFn: async () => {
const response = await api.post('/admin/restore/validate', {
source: restoreData.source,
manifestPath: restoreData.selectedBackup.manifest_path,
restoreType: restoreData.restoreType,
selectedItems: restoreData.selectedItems,
...restoreData.sourceConfig
});
return response.data.data;
},
onSuccess: (data) => {
setValidationResult(data);
setCurrentStep(3);
},
onError: (error) => {
toast.error(error.response?.data?.error || 'Validation failed');
}
});
// Start restore
const restoreMutation = useMutation({
mutationFn: async () => {
const response = await api.post('/admin/restore/start', {
source: restoreData.source,
manifestPath: restoreData.selectedBackup.manifest_path,
restoreType: restoreData.restoreType,
selectedItems: restoreData.selectedItems,
skipPreBackup: restoreData.skipPreBackup,
force: restoreData.force,
encryptionPassphrase: restoreData.encryptionPassphrase,
...restoreData.sourceConfig
});
return response.data;
},
onSuccess: () => {
setCurrentStep(4);
toast.success('Restore started successfully');
},
onError: (error) => {
toast.error(error.response?.data?.error || 'Failed to start restore');
}
});
const handleNext = () => {
if (currentStep === 2) {
// Validate before confirmation
validateMutation.mutate();
} else if (currentStep === 3) {
// Start restore
restoreMutation.mutate();
} else {
setCurrentStep(prev => Math.min(prev + 1, steps.length - 1));
}
};
const handleBack = () => {
setCurrentStep(prev => Math.max(prev - 1, 0));
};
const canProceed = () => {
switch (currentStep) {
case 0:
return !!restoreData.source;
case 1:
return !!restoreData.selectedBackup;
case 2:
return !!restoreData.restoreType;
case 3:
return !!validationResult && !validateMutation.isLoading;
default:
return false;
}
};
// Step Components
const renderSourceSelection = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.restore.source.title')}</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.source.subtitle')}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
onClick={() => setRestoreData(prev => ({ ...prev, source: 'local' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.source === 'local'
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
<HardDrive className={`h-12 w-12 mb-3 mx-auto ${
restoreData.source === 'local' ? 'text-primary' : 'text-neutral-400'
}`} />
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.local.name')}</h4>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.local.description')}</p>
</button>
<button
onClick={() => setRestoreData(prev => ({ ...prev, source: 's3' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.source === 's3'
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
<Cloud className={`h-12 w-12 mb-3 mx-auto ${
restoreData.source === 's3' ? 'text-primary' : 'text-neutral-400'
}`} />
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.s3.name')}</h4>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.s3.description')}</p>
</button>
<button
onClick={() => setRestoreData(prev => ({ ...prev, source: 'upload' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.source === 'upload'
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
<Upload className={`h-12 w-12 mb-3 mx-auto ${
restoreData.source === 'upload' ? 'text-primary' : 'text-neutral-400'
}`} />
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.upload.name')}</h4>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.upload.description')}</p>
</button>
</div>
{/* Source-specific configuration */}
{restoreData.source === 's3' && (
<Card className="p-4 space-y-4">
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.configuration.s3')}</h4>
<div className="grid grid-cols-2 gap-4">
<Input
placeholder={t('backup.restore.source.configuration.endpoint')}
value={restoreData.sourceConfig.s3Endpoint || ''}
onChange={(e) => setRestoreData(prev => ({
...prev,
sourceConfig: { ...prev.sourceConfig, s3Endpoint: e.target.value }
}))}
/>
<Input
placeholder={t('backup.restore.source.configuration.bucket')}
value={restoreData.sourceConfig.s3Bucket || ''}
onChange={(e) => setRestoreData(prev => ({
...prev,
sourceConfig: { ...prev.sourceConfig, s3Bucket: e.target.value }
}))}
/>
<Input
placeholder={t('backup.restore.source.configuration.accessKey')}
value={restoreData.sourceConfig.s3AccessKey || ''}
onChange={(e) => setRestoreData(prev => ({
...prev,
sourceConfig: { ...prev.sourceConfig, s3AccessKey: e.target.value }
}))}
/>
<Input
type="password"
placeholder={t('backup.restore.source.configuration.secretKey')}
value={restoreData.sourceConfig.s3SecretKey || ''}
onChange={(e) => setRestoreData(prev => ({
...prev,
sourceConfig: { ...prev.sourceConfig, s3SecretKey: e.target.value }
}))}
/>
</div>
</Card>
)}
{restoreData.source === 'upload' && (
<div className="space-y-4">
{/* Two upload kinds: the working portable .picpeak restore, and the
legacy manifest+files upload (still a stub). */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={() => setRestoreData(prev => ({ ...prev, uploadType: 'picpeak' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.uploadType === 'picpeak'
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
<FileArchive className={`h-10 w-10 mb-2 mx-auto ${restoreData.uploadType === 'picpeak' ? 'text-primary' : 'text-neutral-400'}`} />
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.upload.picpeak.name', '.picpeak backup')}</h4>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.upload.picpeak.description', 'Portable full backup — restores everything (full override, keeps your current account).')}</p>
</button>
<button
onClick={() => setRestoreData(prev => ({ ...prev, uploadType: 'manifest' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.uploadType === 'manifest'
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
<Upload className={`h-10 w-10 mb-2 mx-auto ${restoreData.uploadType === 'manifest' ? 'text-primary' : 'text-neutral-400'}`} />
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.upload.manifest.name', 'Manifest + files')}</h4>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.upload.manifest.description', 'Upload a manifest and its backup files (legacy format).')}</p>
</button>
</div>
{restoreData.uploadType === 'picpeak' && <PicpeakRestoreCard />}
{restoreData.uploadType === 'manifest' && (
<Card className="p-4">
<div className="text-center py-8">
<Upload className="h-12 w-12 mx-auto mb-3 text-neutral-400" />
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.source.upload.manifestComingSoon', 'Manifest Upload functionality coming soon')}</p>
</div>
</Card>
)}
</div>
)}
</div>
);
const renderBackupSelection = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.restore.backup.title')}</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.backup.subtitle')}</p>
</div>
{loadingBackups ? (
<Loading />
) : availableBackups?.length === 0 ? (
<Card className="p-8 text-center">
<FileArchive className="h-12 w-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" />
<p className="text-neutral-500 dark:text-neutral-400">{t('backup.restore.backup.noBackupsFound')}</p>
</Card>
) : (
<div className="space-y-3">
{availableBackups?.map((backup) => (
<Card
key={backup.id}
className={`p-4 cursor-pointer transition-all ${
restoreData.selectedBackup?.id === backup.id
? 'ring-2 ring-primary bg-accent-dark/15'
: 'hover:shadow-md'
}`}
onClick={() => setRestoreData(prev => ({ ...prev, selectedBackup: backup }))}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className={`p-2 rounded-lg ${
backup.status === 'completed' ? 'bg-green-100 dark:bg-green-900/40' : 'bg-amber-100 dark:bg-amber-900/40'
}`}>
{backup.status === 'completed' ? (
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
) : (
<AlertCircle className="h-6 w-6 text-amber-600 dark:text-amber-400" />
)}
</div>
<div>
<p className="font-medium text-neutral-900 dark:text-neutral-100">
{fmtDate(backup.created_at)} {t('backup.restore.backup.at')} {fmtTime(backup.created_at)}
</p>
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('backup.dashboard.backupType', { type: backup.backup_type })} {formatBytes(backup.total_size || 0)}
</p>
</div>
</div>
<div className="flex items-center space-x-2">
{/* Files-only warning — backend's /list-backups now
returns `database_included: boolean` parsed from
the manifest's database.backup_file field. A row
where this is false is exactly the data-loss
scenario the Stage A guard prevents going forward:
a manifest written without an inline DB dump.
Restoring it would NOT bring CRM data back. */}
{backup.database_included === false && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-red-100 dark:bg-red-900/40 text-red-700 dark:text-red-300 border border-red-300 dark:border-red-700"
title={t('backup.restore.backup.filesOnlyHint',
'This backup has no database dump — restoring it will NOT recover the database (CRM data, customers, quotes, invoices, contracts will be empty after restore).')}
>
<AlertCircle className="h-3 w-3" />
{t('backup.restore.backup.filesOnlyBadge', 'No DB')}
</span>
)}
{backup.corrupt && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300 border border-amber-300 dark:border-amber-700"
title={t('backup.restore.backup.corruptHint',
'The manifest file is unreadable — the backup may be incomplete or damaged.')}
>
<AlertCircle className="h-3 w-3" />
{t('backup.restore.backup.corruptBadge', 'Corrupt')}
</span>
)}
{backup.encrypted && (
<Shield className="h-5 w-5 text-neutral-400" />
)}
</div>
</div>
</Card>
))}
</div>
)}
{/* Files-only callout below the selected card. Reinforces the
badge with a longer explanation + reminds the admin that
restoring this WILL still proceed — they just won't get the
DB back. Stops the silent-failure class that originally
caused Ralf's 2026-05-29 data loss (four files-only manifests
mistaken for full backups). */}
{restoreData.selectedBackup && restoreData.selectedBackup.database_included === false && (
<Card className="p-4 bg-red-50 dark:bg-red-900/30 border-red-300 dark:border-red-700">
<div className="flex items-start space-x-3">
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-semibold text-red-800 dark:text-red-200">
{t('backup.restore.backup.filesOnlyWarning.title',
'Selected backup has no database dump')}
</p>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{t('backup.restore.backup.filesOnlyWarning.message',
'Restoring this backup will recover files (photos, PDFs) but the database — including admin users, customers, quotes, invoices, contracts, and settings — will NOT come back. Pick a different backup if you have one with a database dump, or proceed only if files-only is what you want.')}
</p>
</div>
</div>
</Card>
)}
{restoreData.selectedBackup?.encrypted && (
<Card className="p-4 bg-amber-50 dark:bg-amber-900/30 border-amber-200 dark:border-amber-800">
<div className="flex items-start space-x-3">
<Shield className="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">{t('backup.restore.backup.encrypted')}</p>
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
{t('backup.restore.backup.encryptedMessage')}
</p>
<Input
type="password"
placeholder={t('backup.restore.backup.enterPassphrase')}
className="mt-3"
value={restoreData.encryptionPassphrase}
onChange={(e) => setRestoreData(prev => ({
...prev,
encryptionPassphrase: e.target.value
}))}
/>
</div>
</div>
</Card>
)}
</div>
);
const renderRestoreOptions = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.restore.options.title')}</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.options.subtitle')}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{restoreTypes.map((type) => {
const Icon = type.icon;
return (
<button
key={type.id}
onClick={() => setRestoreData(prev => ({ ...prev, restoreType: type.id }))}
className={`p-4 rounded-lg border-2 text-left transition-all ${
restoreData.restoreType === type.id
? 'border-primary bg-accent-dark/15'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
<div className="flex items-start space-x-3">
<Icon className={`h-6 w-6 mt-1 ${
restoreData.restoreType === type.id ? 'text-primary' : 'text-neutral-400'
}`} />
<div className="flex-1">
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{type.name}</h4>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">{type.description}</p>
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
<AlertTriangle className="inline h-3 w-3 mr-1" />
{type.warning}
</p>
</div>
</div>
</button>
);
})}
</div>
{/* Additional Options */}
<Card className="p-4 space-y-4">
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.options.additionalOptions.title')}</h4>
<label className="flex items-start space-x-3">
<input
type="checkbox"
checked={restoreData.skipPreBackup}
onChange={(e) => setRestoreData(prev => ({
...prev,
skipPreBackup: e.target.checked
}))}
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
/>
<div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.restore.options.additionalOptions.skipPreBackup')}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('backup.restore.options.additionalOptions.skipPreBackupHelp')}
</p>
</div>
</label>
<label className="flex items-start space-x-3">
<input
type="checkbox"
checked={restoreData.force}
onChange={(e) => setRestoreData(prev => ({
...prev,
force: e.target.checked
}))}
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
/>
<div>
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.restore.options.additionalOptions.force')}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('backup.restore.options.additionalOptions.forceHelp')}
</p>
</div>
</label>
</Card>
</div>
);
const renderConfirmation = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.restore.confirmation.title')}</h3>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.subtitle')}</p>
</div>
{validationResult ? (
<>
{/* Validation Results */}
<Card className={`p-4 ${
validationResult.validation?.isValid
? 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800'
}`}>
<div className="flex items-start space-x-3">
{validationResult.validation?.isValid ? (
<CheckCircle className="h-5 w-5 text-green-600 mt-0.5" />
) : (
<XCircle className="h-5 w-5 text-red-600 mt-0.5" />
)}
<div className="flex-1">
<p className={`text-sm font-medium ${
validationResult.validation?.isValid ? 'text-green-900 dark:text-green-200' : 'text-red-900 dark:text-red-200'
}`}>
{validationResult.validation?.isValid
? t('backup.restore.confirmation.validation.passed')
: t('backup.restore.confirmation.validation.failed')}
</p>
{validationResult.validation?.errors?.length > 0 && (
<ul className="mt-2 text-sm text-red-700 dark:text-red-300 list-disc list-inside">
{validationResult.validation.errors.map((error, idx) => (
<li key={idx}>{error}</li>
))}
</ul>
)}
</div>
</div>
</Card>
{/* Space Check */}
{validationResult.spaceCheck && (
<Card className="p-4">
<h4 className="font-medium text-neutral-900 dark:text-neutral-100 mb-3">{t('backup.restore.confirmation.spaceCheck.title')}</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.spaceCheck.required')}:</span>
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{validationResult.spaceCheck.requiredFormatted || formatBytes(validationResult.spaceCheck.required || 0)}
</span>
</div>
<div className="flex justify-between">
<span className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.spaceCheck.available')}:</span>
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{validationResult.spaceCheck.availableFormatted ||
(validationResult.spaceCheck.available != null ? formatBytes(validationResult.spaceCheck.available) : t('common.unknown', 'Unknown'))}
</span>
</div>
{validationResult.spaceCheck.sufficient === false && (
<p className="text-red-600 text-xs mt-2">
<AlertCircle className="inline h-3 w-3 mr-1" />
{t('backup.restore.confirmation.spaceCheck.insufficient')}
</p>
)}
</div>
</Card>
)}
{/* Summary */}
<Card className="p-4">
<h4 className="font-medium text-neutral-900 dark:text-neutral-100 mb-3">{t('backup.restore.confirmation.summary.title')}</h4>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.summary.source')}:</dt>
<dd className="font-medium text-neutral-900 dark:text-neutral-100 capitalize">{restoreData.source}</dd>
</div>
<div className="flex justify-between">
<dt className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.summary.backupDate')}:</dt>
<dd className="font-medium text-neutral-900 dark:text-neutral-100">
{fmtDateTime(restoreData.selectedBackup.created_at)}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.summary.restoreType')}:</dt>
<dd className="font-medium text-neutral-900 dark:text-neutral-100 capitalize">{restoreData.restoreType}</dd>
</div>
<div className="flex justify-between">
<dt className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.summary.preBackup')}:</dt>
<dd className="font-medium text-neutral-900 dark:text-neutral-100">{restoreData.skipPreBackup ? t('backup.restore.confirmation.summary.skipped') : t('backup.restore.confirmation.summary.enabled')}</dd>
</div>
</dl>
</Card>
{/* Warning */}
<div className="bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-amber-400 mt-0.5" />
<div className="ml-3">
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">
{t('backup.restore.confirmation.warning.title')}
</h3>
<p className="mt-1 text-sm text-amber-700 dark:text-amber-300">
{t('backup.restore.confirmation.warning.message')}
</p>
</div>
</div>
</div>
</>
) : (
<div className="text-center py-8">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.validation.checking')}</p>
</div>
)}
</div>
);
const renderProgress = () => {
const progress = restoreStatus?.currentProgress || {};
const isRunning = restoreStatus?.isRunning;
// Pull the most recent restore_runs row from history so we can
// tell whether the "not running" state means success, failure, or
// never-started. The history endpoint already returns rows newest
// first.
const lastRun = restoreStatus?.history?.[0];
const lastRunFailed =
!isRunning && lastRun && (lastRun.status === 'failed' || lastRun.was_successful === false);
const lastRunSucceeded =
!isRunning && lastRun && lastRun.status === 'completed' && lastRun.was_successful === true;
// Strip the noisy stack-trace tail from the error message so the
// user sees the actionable line first.
const lastRunError = lastRun?.error_message
? lastRun.error_message.split('\n')[0].slice(0, 500)
: null;
const subtitle = isRunning
? t('backup.restore.progress.inProgress')
: lastRunFailed
? t('backup.restore.progress.failedSubtitle', 'Restore failed — see error below. Destination has been rolled back to its pre-restore state.')
: lastRunSucceeded
? t('backup.restore.progress.completed')
: t('backup.restore.progress.idle', 'No restore in progress.');
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.restore.progress.title')}</h3>
<p className={`text-sm ${
lastRunFailed
? 'text-red-700 dark:text-red-300 font-medium'
: 'text-neutral-600 dark:text-neutral-400'
}`}>
{subtitle}
</p>
</div>
{lastRunFailed && (
<div className="bg-red-50 dark:bg-red-900/30 border border-red-300 dark:border-red-700 rounded-lg p-4">
<div className="flex items-start gap-3">
<XCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-semibold text-red-800 dark:text-red-200 mb-1">
{t('backup.restore.progress.errorTitle', 'Restore did not complete')}
</h4>
<p className="text-sm text-red-700 dark:text-red-300 font-mono break-all">
{lastRunError || t('backup.restore.progress.errorUnknown', 'No error message recorded.')}
</p>
{lastRun.was_rollback_attempted && (
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
{t('backup.restore.progress.rolledBack',
'Pre-restore safety backup was used to roll back. Destination is in its pre-restore state — safe to retry once the issue above is resolved.')}
</p>
)}
</div>
</div>
</div>
)}
{/* Progress Bar */}
<Card className="p-6">
<div className="space-y-4">
<div className="flex justify-between text-sm">
<span className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.progress.overallProgress')}</span>
<span className="font-medium text-neutral-900 dark:text-neutral-100">{progress.percentage || 0}%</span>
</div>
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-3">
<div
className="bg-primary h-3 rounded-full transition-all duration-500"
style={{ width: `${progress.percentage || 0}%` }}
/>
</div>
{progress.currentFile && (
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t('backup.restore.progress.current')}: {progress.currentFile}
</p>
)}
</div>
</Card>
{/* Status Details */}
<Card className="p-6">
<h4 className="font-medium text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.restore.progress.statusDetails')}</h4>
<div className="space-y-3">
{progress.steps?.map((step, idx) => (
<div key={idx} className="flex items-center space-x-3">
{step.status === 'completed' ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : step.status === 'running' ? (
<Loader2 className="h-5 w-5 text-blue-500 animate-spin" />
) : step.status === 'failed' ? (
<XCircle className="h-5 w-5 text-red-500" />
) : (
<Clock className="h-5 w-5 text-neutral-300 dark:text-neutral-600" />
)}
<div className="flex-1">
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{step.name}</p>
{step.message && (
<p className="text-xs text-neutral-500 dark:text-neutral-400">{step.message}</p>
)}
</div>
{step.duration && (
<span className="text-xs text-neutral-500 dark:text-neutral-400">{step.duration}</span>
)}
</div>
))}
</div>
</Card>
{/* Logs */}
{progress.logs && progress.logs.length > 0 && (
<Card className="p-6">
<h4 className="font-medium text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.restore.progress.restoreLogs')}</h4>
<div className="bg-neutral-900 rounded-lg p-4 max-h-64 overflow-y-auto">
<pre className="text-xs text-neutral-300 font-mono">
{progress.logs.join('\n')}
</pre>
</div>
</Card>
)}
{/* Completion Actions — only when the most recent run actually
succeeded. Previously this gated on `progress.status` which
could be null between runs, so the green "Restore completed
successfully" banner could render alongside a silent failure. */}
{lastRunSucceeded && (
<div className="bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 mt-0.5" />
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-green-800 dark:text-green-200">
{t('backup.restore.progress.success.title')}
</h3>
<p className="mt-1 text-sm text-green-700 dark:text-green-300">
{t('backup.restore.progress.success.message')}
</p>
{/* Post-restore CTA: jump to the integrity check (D2). The
audit trail captured at sign / issue time is worth
nothing if the documents it refers to are missing
from the restored copy — verifier surfaces that
drift in one click before the admin trusts the
restored state. */}
{onVerifyIntegrity && (
<Button
variant="outline"
size="sm"
className="mt-3"
onClick={onVerifyIntegrity}
leftIcon={<ShieldCheck className="w-4 h-4" />}
>
{t(
'backup.restore.progress.success.verifyIntegrity',
'Verify document integrity now',
)}
</Button>
)}
</div>
</div>
</div>
)}
</div>
);
};
const formatBytes = (bytes) => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
return (
<div className="max-w-4xl mx-auto">
{/* Progress Steps */}
<div className="mb-8">
<nav aria-label="Progress">
<ol className="flex items-center">
{steps.map((step, stepIdx) => (
<li key={step.id} className={`relative ${stepIdx !== steps.length - 1 ? 'pr-8 flex-1' : ''}`}>
<div className="flex items-center">
<div className={`
relative flex h-8 w-8 items-center justify-center rounded-full
${currentStep > stepIdx
? 'bg-primary'
: currentStep === stepIdx
? 'bg-primary'
: 'bg-neutral-300 dark:bg-neutral-600'
}
`}>
{currentStep > stepIdx ? (
<CheckCircle className="h-5 w-5 text-white" />
) : (
<span className="text-white text-sm">{stepIdx + 1}</span>
)}
</div>
{stepIdx !== steps.length - 1 && (
<div className={`
absolute top-4 w-full h-0.5
${currentStep > stepIdx ? 'bg-primary' : 'bg-neutral-300 dark:bg-neutral-600'}
`} style={{ left: '2rem', right: '-2rem' }} />
)}
</div>
<span className={`
mt-2 text-xs font-medium
${currentStep >= stepIdx ? 'text-neutral-900 dark:text-neutral-100' : 'text-neutral-500 dark:text-neutral-400'}
`}>
{step.title}
</span>
</li>
))}
</ol>
</nav>
</div>
{/* Step Content */}
<Card className="p-6">
{currentStep === 0 && renderSourceSelection()}
{currentStep === 1 && renderBackupSelection()}
{currentStep === 2 && renderRestoreOptions()}
{currentStep === 3 && renderConfirmation()}
{currentStep === 4 && renderProgress()}
</Card>
{/* Navigation Buttons */}
<div className="mt-6 flex justify-between">
<Button
variant="secondary"
onClick={handleBack}
disabled={currentStep === 0 || currentStep === 4}
>
<ChevronLeft className="mr-2 h-4 w-4" />
{t('backup.restore.actions.back')}
</Button>
{currentStep < 4 && (
<Button
onClick={handleNext}
disabled={!canProceed() || validateMutation.isLoading || restoreMutation.isLoading}
>
{currentStep === 3 ? (
<>
{restoreMutation.isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('backup.restore.actions.starting')}
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
{t('backup.restore.actions.startRestore')}
</>
)}
</>
) : currentStep === 2 ? (
<>
{validateMutation.isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('backup.restore.actions.validating')}
</>
) : (
<>
{t('backup.restore.actions.next')}
<ChevronRight className="ml-2 h-4 w-4" />
</>
)}
</>
) : (
<>
{t('backup.restore.actions.next')}
<ChevronRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
)}
{currentStep === 4 && !restoreStatus?.isRunning && (
<Button
onClick={() => {
setCurrentStep(0);
setRestoreData({
source: null,
sourceConfig: {},
selectedBackup: null,
restoreType: 'full',
selectedItems: [],
skipPreBackup: false,
force: false,
encryptionPassphrase: ''
});
setValidationResult(null);
}}
>
{t('backup.restore.actions.startNewRestore')}
</Button>
)}
</div>
</div>
);
};
@@ -0,0 +1,172 @@
/**
* Sent-emails feed — read-only, paginated view of the email_queue table.
* Rendered as the "Sent emails" tab inside EmailConfigPage. Pairs with
* the "Send queued emails now" flush button on the SMTP tab: flush, then
* watch what sent / failed here.
*
* Filters: status (pending/sent/failed), free-text search (recipient or
* type), and a created-at date range. email_data is never fetched.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Search, AlertCircle } from 'lucide-react';
import { Button, Card, Loading } from '../common';
import { LocalizedDateInput } from '../common/LocalizedDateInput';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { emailService, type EmailQueueStatus } from '../../services/email.service';
const STATUSES: EmailQueueStatus[] = ['pending', 'sent', 'failed'];
const statusClass = (s: EmailQueueStatus): string =>
s === 'sent' ? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
: s === 'failed' ? 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300';
export const SentEmailsPanel: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<EmailQueueStatus | null>(null);
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['email-queue', { search, statusFilter, from, to, page }],
queryFn: () => emailService.listQueue({
q: search || undefined,
status: statusFilter || undefined,
from: from || undefined,
to: to || undefined,
page,
pageSize: 25,
}),
});
const resetTo1 = () => setPage(1);
return (
<Card padding="lg">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
{t('email.sentEmails.title', 'Sent emails')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('email.sentEmails.subtitle', 'Delivery status of every queued and sent notification.')}
</p>
<div className="flex flex-wrap items-end gap-3">
<div className="relative flex-1 min-w-[220px]">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" />
<input
type="text"
placeholder={t('email.sentEmails.searchPlaceholder', 'Search by recipient or type…') as string}
className="w-full pl-9 pr-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={search}
onChange={(e) => { setSearch(e.target.value); resetTo1(); }}
/>
</div>
<div className="w-40">
<LocalizedDateInput label={t('email.sentEmails.from', 'From') as string} value={from}
onChange={(iso) => { setFrom(iso); resetTo1(); }} />
</div>
<div className="w-40">
<LocalizedDateInput label={t('email.sentEmails.to', 'To') as string} value={to}
onChange={(iso) => { setTo(iso); resetTo1(); }} />
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1">
{STATUSES.map((s) => {
const active = statusFilter === s;
return (
<button key={s} type="button"
onClick={() => { setStatusFilter(active ? null : s); resetTo1(); }}
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition-colors ${
active
? 'bg-accent-dark text-white border-accent-dark'
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600'
}`}
>{t(`email.sentEmails.status.${s}`, s)}</button>
);
})}
</div>
<div className="mt-4">
{isLoading ? <Loading /> : !data || data.items.length === 0 ? (
<p className="text-center text-neutral-500 dark:text-neutral-400 py-8">
{t('email.sentEmails.empty', 'No emails match these filters.')}
</p>
) : (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.recipient', 'Recipient')}</th>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.type', 'Type')}</th>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.status', 'Status')}</th>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.created', 'Queued')}</th>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.sent', 'Sent')}</th>
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.event', 'Event')}</th>
</tr>
</thead>
<tbody>
{data.items.map((m) => (
<tr key={m.id} className="border-t border-neutral-200 dark:border-neutral-700 align-top">
<td className="px-3 py-2 break-all">{m.recipientEmail}</td>
<td className="px-3 py-2 font-mono text-xs">{m.emailType}</td>
<td className="px-3 py-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusClass(m.status)}`}>
{t(`email.sentEmails.status.${m.status}`, m.status)}
</span>
{m.status === 'failed' && m.errorMessage && (
<div className="mt-1 flex items-start gap-1 text-xs text-red-700 dark:text-red-400 max-w-xs">
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span className="break-words">{m.errorMessage}</span>
</div>
)}
{m.status === 'pending' && m.retryCount > 0 && (
<div className="mt-1 text-xs text-amber-700 dark:text-amber-400">
{t('email.sentEmails.retries', '{{count}} retries', { count: m.retryCount })}
</div>
)}
</td>
<td className="px-3 py-2 whitespace-nowrap">{m.createdAt ? fmtDateTime(m.createdAt) : '—'}</td>
<td className="px-3 py-2 whitespace-nowrap">{m.sentAt ? fmtDateTime(m.sentAt) : '—'}</td>
<td className="px-3 py-2">
{m.eventId ? (
<Link to={`/admin/events/${m.eventId}`} className="text-accent hover:underline" onClick={(e) => e.stopPropagation()}>
{m.eventName || `#${m.eventId}`}
</Link>
) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
{data.pagination.totalPages > 1 && (
<div className="flex justify-between items-center px-3 py-2 border-t border-neutral-200 dark:border-neutral-700 text-sm">
<span className="text-neutral-500 dark:text-neutral-400">
{t('email.sentEmails.pagination', 'Page {{page}} of {{total}} · {{count}} emails', {
page: data.pagination.page, total: data.pagination.totalPages, count: data.pagination.total,
})}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t('common.previous', 'Previous')}
</Button>
<Button variant="outline" size="sm" disabled={page >= data.pagination.totalPages} onClick={() => setPage((p) => p + 1)}>
{t('common.next', 'Next')}
</Button>
</div>
</div>
)}
</div>
)}
</div>
</Card>
);
};
@@ -0,0 +1,153 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { ShieldAlert } from 'lucide-react';
import { Button, Input } from '../common';
import type { FeatureKey } from '../../services/featureFlags.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { emailService, type EmailConfig } from '../../services/email.service';
// Features that need working SMTP to deliver anything.
const EMAIL_FEATURES: FeatureKey[] = ['reminderEmails', 'incomingMail', 'whatsapp', 'bills'];
interface Props {
selectedFeatures: Set<FeatureKey>;
onDone: () => void;
}
// Lean per-feature config, shown after the "How will you use PicPeak?" step.
// Only the sections a selected feature actually needs are rendered; everything
// else keeps its seeded defaults and is tunable later in Settings. Saving is
// best-effort per section — a failure never traps the user on setup.
export const SetupConfigStep: React.FC<Props> = ({ selectedFeatures, onDone }) => {
const { t } = useTranslation();
const showInvoicing = selectedFeatures.has('bills');
const showEmail = EMAIL_FEATURES.some((f) => selectedFeatures.has(f));
const [saving, setSaving] = useState(false);
const [inv, setInv] = useState({
companyName: '', addressLine1: '', postalCode: '', city: '', countryCode: '',
vatId: '', taxId: '', defaultCurrency: 'CHF', iban: '',
});
const [mail, setMail] = useState({
smtp_host: '', smtp_port: '587', smtp_user: '', smtp_pass: '', from_email: '', from_name: '',
});
const invField = (k: keyof typeof inv) => (e: React.ChangeEvent<HTMLInputElement>) =>
setInv((p) => ({ ...p, [k]: e.target.value }));
const mailField = (k: keyof typeof mail) => (e: React.ChangeEvent<HTMLInputElement>) =>
setMail((p) => ({ ...p, [k]: e.target.value }));
const finish = async () => {
setSaving(true);
try {
// Invoicing: only persist if they actually started filling it in.
if (showInvoicing && inv.companyName.trim()) {
await businessProfileService.update({
companyName: inv.companyName.trim(),
addressLine1: inv.addressLine1.trim(),
postalCode: inv.postalCode.trim(),
city: inv.city.trim(),
countryCode: inv.countryCode.trim(),
vatId: inv.vatId.trim(),
taxId: inv.taxId.trim(),
defaultCurrency: inv.defaultCurrency.trim() || 'CHF',
});
if (inv.iban.trim()) {
await businessProfileService.createBankAccount({
iban: inv.iban.replace(/\s+/g, ''),
accountHolder: inv.companyName.trim(),
currency: inv.defaultCurrency.trim() || 'CHF',
isDefault: true,
});
}
}
// Email: only persist if a host was entered.
if (showEmail && mail.smtp_host.trim()) {
const port = parseInt(mail.smtp_port, 10) || 587;
const config: EmailConfig = {
smtp_host: mail.smtp_host.trim(),
smtp_port: port,
smtp_secure: port === 465,
smtp_user: mail.smtp_user.trim(),
smtp_pass: mail.smtp_pass,
from_email: mail.from_email.trim(),
from_name: mail.from_name.trim(),
tls_reject_unauthorized: true,
};
await emailService.updateConfig(config);
}
} catch (_) {
toast.warn(t('setup.config.saveFailed', 'Some settings could not be saved — you can finish them in Settings.'));
} finally {
setSaving(false);
onDone();
}
};
return (
<div className="space-y-8">
<p className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2 text-xs text-neutral-600">
{t('setup.config.intro', 'A few details for the features you picked. Anything you skip keeps its default and can be set later in Settings.')}
</p>
{showInvoicing && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-800">{t('setup.config.invoicing', 'Invoicing details')}</h3>
<div className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3">
<ShieldAlert className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600" />
<p className="text-xs text-amber-800">
{t('setup.config.invoicingDisclaimer', 'Used on your invoices. Bank/IBAN and VAT details are your responsibility — verify them with your bank and Treuhänder/tax advisor.')}
</p>
</div>
<Input placeholder={t('setup.config.companyName', 'Company / legal name')} value={inv.companyName} onChange={invField('companyName')} />
<Input placeholder={t('setup.config.addressLine1', 'Street and number')} value={inv.addressLine1} onChange={invField('addressLine1')} />
<div className="grid grid-cols-3 gap-3">
<Input placeholder={t('setup.config.postalCode', 'Postal code')} value={inv.postalCode} onChange={invField('postalCode')} />
<div className="col-span-2"><Input placeholder={t('setup.config.city', 'City')} value={inv.city} onChange={invField('city')} /></div>
</div>
<div className="grid grid-cols-2 gap-3">
<Input placeholder={t('setup.config.countryCode', 'Country code (e.g. CH)')} value={inv.countryCode} onChange={invField('countryCode')} />
<Input placeholder={t('setup.config.currency', 'Currency (e.g. CHF)')} value={inv.defaultCurrency} onChange={invField('defaultCurrency')} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input placeholder={t('setup.config.vatId', 'VAT ID (or leave blank)')} value={inv.vatId} onChange={invField('vatId')} />
<Input placeholder={t('setup.config.taxId', 'Tax number (or VAT ID)')} value={inv.taxId} onChange={invField('taxId')} />
</div>
<Input placeholder={t('setup.config.iban', 'IBAN (for invoice payments)')} value={inv.iban} onChange={invField('iban')} />
</div>
)}
{showEmail && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-800">{t('setup.config.email', 'Email delivery (SMTP)')}</h3>
<p className="text-xs text-neutral-500">{t('setup.config.emailHint', 'Required to send reminders, invoices and notifications.')}</p>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2"><Input placeholder={t('setup.config.smtpHost', 'SMTP host')} value={mail.smtp_host} onChange={mailField('smtp_host')} /></div>
<Input placeholder={t('setup.config.smtpPort', 'Port')} value={mail.smtp_port} onChange={mailField('smtp_port')} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input placeholder={t('setup.config.smtpUser', 'Username')} value={mail.smtp_user} onChange={mailField('smtp_user')} autoComplete="off" />
<Input type="password" placeholder={t('setup.config.smtpPass', 'Password')} value={mail.smtp_pass} onChange={mailField('smtp_pass')} autoComplete="new-password" />
</div>
<div className="grid grid-cols-2 gap-3">
<Input type="email" placeholder={t('setup.config.fromEmail', 'From address')} value={mail.from_email} onChange={mailField('from_email')} />
<Input placeholder={t('setup.config.fromName', 'From name')} value={mail.from_name} onChange={mailField('from_name')} />
</div>
</div>
)}
<div className="flex gap-3">
<Button type="button" variant="outline" size="lg" onClick={onDone} disabled={saving}>
{t('setup.config.skip', 'Skip for now')}
</Button>
<Button type="button" variant="primary" size="lg" isLoading={saving} className="flex-1" onClick={finish}>
{t('setup.config.finish', 'Finish setup')}
</Button>
</div>
</div>
);
};
SetupConfigStep.displayName = 'SetupConfigStep';
@@ -0,0 +1,238 @@
/**
* Branded short-URL management for a single event (#699).
*
* Each event can have multiple `/s/<slug>` short URLs pointing at it.
* The public route is bot-UA aware: scrapes get OG (so the short URL
* is what shows the rich preview in chat), browsers get a 302 to the
* gallery URL stored at create time.
*
* Renders inside the event detail page as a Card. Form to create
* (custom or auto-generated slug), list of existing short URLs with
* copy + delete buttons. Errors from the backend's structured codes
* (INVALID_SLUG, SLUG_TAKEN) surface inline with a "use suggested"
* shortcut when the server proposes an alternative.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Copy, Link as LinkIcon, Trash2, Plus, Check } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { shortUrlsService, type GalleryShortUrl } from '../../services/shortUrls.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { toast } from 'react-toastify';
interface Props {
eventId: number;
}
function buildShortUrl(slug: string): string {
// Build from window.location so it survives reverse-proxy + custom-
// domain setups without needing a separate FRONTEND_URL config in the
// browser bundle. SSR-safe fallback: just the relative path.
if (typeof window === 'undefined') return `/s/${slug}`;
return `${window.location.origin}/s/${slug}`;
}
export const ShortUrlsCard: React.FC<Props> = ({ eventId }) => {
const { t } = useTranslation();
const { formatDateTime } = useLocalizedDate();
const qc = useQueryClient();
const [customSlug, setCustomSlug] = useState('');
const [error, setError] = useState<string | undefined>();
const [suggested, setSuggested] = useState<string | undefined>();
const [copiedId, setCopiedId] = useState<number | null>(null);
const { data: shortUrls = [], isLoading } = useQuery({
queryKey: ['short-urls', eventId],
queryFn: () => shortUrlsService.listForEvent(eventId),
});
const createMutation = useMutation({
mutationFn: (slug?: string) => shortUrlsService.create(eventId, slug),
onSuccess: () => {
setCustomSlug('');
setError(undefined);
setSuggested(undefined);
qc.invalidateQueries({ queryKey: ['short-urls', eventId] });
toast.success(t('events.shortUrls.created', 'Short URL created'));
},
onError: (err: any) => {
const code = err?.response?.data?.code;
const msg = err?.response?.data?.error;
if (code === 'SLUG_TAKEN') {
setSuggested(err?.response?.data?.suggested);
setError(t(
'events.shortUrls.errors.slugTaken',
'That short URL is already taken — try {{suggested}} instead.',
{ suggested: err?.response?.data?.suggested || '' },
) as string);
} else if (code === 'INVALID_SLUG') {
setSuggested(undefined);
setError(msg || (t('events.shortUrls.errors.invalidSlug',
'Short URLs must be lowercase letters, digits, and hyphens (164 chars).') as string));
} else {
setSuggested(undefined);
setError(msg || (t('common.error', 'Something went wrong') as string));
}
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => shortUrlsService.remove(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['short-urls', eventId] });
toast.success(t('events.shortUrls.deleted', 'Short URL deleted'));
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createMutation.mutate(customSlug.trim() || undefined);
};
const handleUseSuggested = () => {
if (suggested) {
setCustomSlug(suggested);
setError(undefined);
setSuggested(undefined);
}
};
const handleCopy = async (row: GalleryShortUrl) => {
const url = buildShortUrl(row.short_slug);
try {
await navigator.clipboard.writeText(url);
setCopiedId(row.id);
setTimeout(() => setCopiedId((current) => (current === row.id ? null : current)), 1500);
} catch {
toast.error(t('common.copyFailed', 'Could not copy to clipboard') as string);
}
};
const handleDelete = (row: GalleryShortUrl) => {
const confirm = window.confirm(t(
'events.shortUrls.confirmDelete',
'Delete short URL /s/{{slug}}? The link will stop working immediately.',
{ slug: row.short_slug },
) as string);
if (confirm) deleteMutation.mutate(row.id);
};
return (
<Card padding="md">
<div className="flex items-center gap-2 mb-3">
<LinkIcon className="w-5 h-5 text-primary-600 dark:text-primary-400" />
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('events.shortUrls.title', 'Branded short URLs')}
</h3>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t(
'events.shortUrls.description',
'Create memorable links like /s/sofia-graduation that resolve to this gallery. The short URL itself shows the rich social preview when shared — so iMessage, Facebook, WhatsApp etc. see the gallery photo + name even when pasting the short link.',
)}
</p>
{/* Create form */}
<form onSubmit={handleSubmit} className="mb-4">
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex-1">
<Input
value={customSlug}
onChange={(e) => {
setCustomSlug(e.target.value.toLowerCase());
if (error) setError(undefined);
if (suggested) setSuggested(undefined);
}}
placeholder={t('events.shortUrls.slugPlaceholder', 'sofia-graduation (optional)') as string}
maxLength={64}
error={error}
/>
</div>
<Button
type="submit"
variant="primary"
disabled={createMutation.isPending}
leftIcon={<Plus className="w-4 h-4" />}
>
{t('events.shortUrls.create', 'Create')}
</Button>
</div>
{suggested && (
<button
type="button"
onClick={handleUseSuggested}
className="mt-2 text-xs text-primary-600 dark:text-primary-400 underline hover:no-underline"
>
{t('events.shortUrls.useSuggested', 'Use “{{suggested}}” instead', { suggested })}
</button>
)}
<p className="mt-2 text-xs text-neutral-500 dark:text-neutral-400">
{t(
'events.shortUrls.slugHelp',
'Leave empty to auto-generate from the gallery name. Allowed characters: lowercase letters, digits, hyphens.',
)}
</p>
</form>
{/* Existing short URLs */}
{isLoading ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('common.loading', 'Loading…')}
</p>
) : shortUrls.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('events.shortUrls.empty', 'No short URLs yet. Create one above to share this gallery with a memorable link.')}
</p>
) : (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{shortUrls.map((row) => (
<li key={row.id} className="py-3 flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="font-mono text-sm text-neutral-900 dark:text-neutral-100 break-all">
/s/{row.short_slug}
</div>
<div className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('events.shortUrls.hits', '{{count}} hits', { count: row.hit_count })}
{row.last_hit_at && (
<>
{' · '}
{t('events.shortUrls.lastHit', 'last {{when}}', { when: formatDateTime(row.last_hit_at) })}
</>
)}
{' · '}
{t('events.shortUrls.createdAt', 'created {{when}}', { when: formatDateTime(row.created_at) })}
</div>
<div className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400 truncate">
{row.target_path}
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={() => handleCopy(row)}
className="p-2 text-neutral-500 hover:text-primary-600 dark:hover:text-primary-400"
title={t('common.copy', 'Copy') as string}
aria-label={t('common.copy', 'Copy') as string}
>
{copiedId === row.id ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4" />}
</button>
<button
type="button"
onClick={() => handleDelete(row)}
disabled={deleteMutation.isPending}
className="p-2 text-neutral-500 hover:text-red-600"
title={t('common.delete', 'Delete') as string}
aria-label={t('common.delete', 'Delete') as string}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</li>
))}
</ul>
)}
</Card>
);
};
@@ -0,0 +1,267 @@
/**
* <SlideshowGlobalDefaultsCard>
*
* Global default for the Live Slideshow watermark (the white, semi-transparent
* corner logo). Every event whose watermark mode is "Use global default"
* (events.show_watermark = NULL) follows this; events can still override on/off
* per event. Persisted via PUT /admin/settings/slideshow (app_settings,
* type 'slideshow').
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { MonitorPlay, Save } from 'lucide-react';
import { Button, Card } from '../common';
import { settingsService } from '../../services/settings.service';
import {
SLIDESHOW_WATERMARK_POSITIONS,
SLIDESHOW_WATERMARK_STYLES,
SLIDESHOW_FITS,
SLIDESHOW_TRANSITIONS,
SLIDESHOW_COLORFILTERS,
type SlideshowGlobalDefaults,
} from '../../services/slideshow.service';
import { WatermarkSourcePicker } from './WatermarkSourcePicker';
// Optimistic form state shown until the GET resolves; mirrors the backend
// defaults so the controls don't flicker on load (backend is the source of
// truth — these are just the pre-fetch placeholder).
const DEFAULTS: SlideshowGlobalDefaults = {
slideshow_fit: 'cover',
slideshow_interval_ms: 5000,
slideshow_transition: 'crossfade',
slideshow_transition_ms: 800,
slideshow_colorfilter: 'none',
slideshow_watermark_enabled: false,
slideshow_watermark_source: 'logo',
slideshow_watermark_position: 'bottom-right',
slideshow_watermark_opacity: 60,
slideshow_watermark_style: 'white',
slideshow_watermark_size: 12,
};
const inputClass =
'w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm';
const labelClass = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
export const SlideshowGlobalDefaultsCard: React.FC = () => {
const { t } = useTranslation();
const [val, setVal] = useState<SlideshowGlobalDefaults>(DEFAULTS);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
settingsService.getSettingsByType('slideshow').then((s) => {
if (cancelled || !s) return;
setVal({
slideshow_fit: s.slideshow_fit ?? DEFAULTS.slideshow_fit,
slideshow_interval_ms: s.slideshow_interval_ms ?? DEFAULTS.slideshow_interval_ms,
slideshow_transition: s.slideshow_transition ?? DEFAULTS.slideshow_transition,
slideshow_transition_ms: s.slideshow_transition_ms ?? DEFAULTS.slideshow_transition_ms,
slideshow_colorfilter: s.slideshow_colorfilter ?? DEFAULTS.slideshow_colorfilter,
slideshow_watermark_enabled: s.slideshow_watermark_enabled ?? DEFAULTS.slideshow_watermark_enabled,
slideshow_watermark_source: s.slideshow_watermark_source ?? DEFAULTS.slideshow_watermark_source,
slideshow_watermark_position: s.slideshow_watermark_position ?? DEFAULTS.slideshow_watermark_position,
slideshow_watermark_opacity: s.slideshow_watermark_opacity ?? DEFAULTS.slideshow_watermark_opacity,
slideshow_watermark_style: s.slideshow_watermark_style ?? DEFAULTS.slideshow_watermark_style,
slideshow_watermark_size: s.slideshow_watermark_size ?? DEFAULTS.slideshow_watermark_size,
});
}).catch(() => { /* keep defaults */ });
return () => { cancelled = true; };
}, []);
const save = async () => {
setSaving(true);
try {
await settingsService.updateSlideshowDefaults(val);
toast.success(t('slideshow.defaultsSaved', 'Slideshow defaults saved'));
} catch {
toast.error(t('common.error', 'Something went wrong'));
} finally {
setSaving(false);
}
};
return (
<Card padding="md" className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
<MonitorPlay className="w-5 h-5" />
{t('slideshow.globalTitle', 'Global slideshow settings')}
</h2>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-4">
{t('slideshow.globalDescription', 'Defaults for every slideshow. Events can override the watermark on or off.')}
</p>
<div className="space-y-4">
{/* Image fit */}
<div>
<label className={labelClass}>{t('slideshow.fitLabel', 'Image fit')}</label>
<select
value={val.slideshow_fit}
onChange={(e) => setVal({ ...val, slideshow_fit: e.target.value as SlideshowGlobalDefaults['slideshow_fit'] })}
className={inputClass}
>
{SLIDESHOW_FITS.map((f) => (
<option key={f} value={f}>
{t(`slideshow.fit.${f}`, f === 'contain' ? 'Black bars (no crop)' : 'Fill screen (crop)')}
</option>
))}
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('slideshow.fitHint', '"Fill" crops to fill the screen; "Black bars" shows the whole photo — better for portrait images.')}
</p>
</div>
{/* Default display style new slideshows inherit (override per event) */}
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('slideshow.presetTitle', 'Default style for new slideshows')}
</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
{t('slideshow.presetHint', 'Applied to events created from now on; each event can still override it.')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className={labelClass}>{t('slideshow.transitionLabel', 'Transition')}</label>
<select
value={val.slideshow_transition}
onChange={(e) => setVal({ ...val, slideshow_transition: e.target.value as SlideshowGlobalDefaults['slideshow_transition'] })}
className={inputClass}
>
{SLIDESHOW_TRANSITIONS.map((tr) => (
<option key={tr} value={tr}>{t(`slideshow.transition.${tr}`, tr)}</option>
))}
</select>
</div>
<div>
<label className={labelClass}>{t('slideshow.intervalLabel', 'Display time (sec)')}</label>
<input
type="number"
min={1}
max={120}
value={Math.round(val.slideshow_interval_ms / 1000)}
onChange={(e) => setVal({ ...val, slideshow_interval_ms: Math.min(120, Math.max(1, parseInt(e.target.value, 10) || 5)) * 1000 })}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>{t('slideshow.transitionSpeedLabel', 'Transition speed (ms)')}</label>
<input
type="number"
min={100}
max={5000}
step={100}
value={val.slideshow_transition_ms}
onChange={(e) => setVal({ ...val, slideshow_transition_ms: Math.min(5000, Math.max(100, parseInt(e.target.value, 10) || 800)) })}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>{t('slideshow.colorfilterLabel', 'Color filter')}</label>
<select
value={val.slideshow_colorfilter}
onChange={(e) => setVal({ ...val, slideshow_colorfilter: e.target.value as SlideshowGlobalDefaults['slideshow_colorfilter'] })}
className={inputClass}
>
{SLIDESHOW_COLORFILTERS.map((cf) => (
<option key={cf} value={cf}>{t(`slideshow.colorfilter.${cf}`, cf)}</option>
))}
</select>
</div>
</div>
</div>
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
<label className="flex items-start gap-2">
<input
type="checkbox"
className="mt-1 w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500"
checked={val.slideshow_watermark_enabled}
onChange={(e) => setVal({ ...val, slideshow_watermark_enabled: e.target.checked })}
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('slideshow.watermarkToggle', 'Logo watermark')}
</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('slideshow.watermarkDescription', 'Overlay a white, semi-transparent logo in a corner (like a TV station ident).')}
</p>
</div>
</label>
{val.slideshow_watermark_enabled && (
<div className="space-y-3">
<div>
<label className={labelClass}>{t('slideshow.watermarkSourceLabel', 'Logo')}</label>
<WatermarkSourcePicker
value={val.slideshow_watermark_source}
onChange={(s) => setVal({ ...val, slideshow_watermark_source: s })}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className={labelClass}>{t('slideshow.watermarkPositionLabel', 'Position')}</label>
<select
value={val.slideshow_watermark_position}
onChange={(e) => setVal({ ...val, slideshow_watermark_position: e.target.value as SlideshowGlobalDefaults['slideshow_watermark_position'] })}
className={inputClass}
>
{SLIDESHOW_WATERMARK_POSITIONS.map((pos) => (
<option key={pos} value={pos}>
{t(`slideshow.watermarkPosition.${pos}`, pos)}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>{t('slideshow.watermarkOpacityLabel', 'Opacity (%)')}</label>
<input
type="number"
min={0}
max={100}
step={5}
value={val.slideshow_watermark_opacity}
onChange={(e) => setVal({ ...val, slideshow_watermark_opacity: Math.min(100, Math.max(0, parseInt(e.target.value, 10) || 0)) })}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>{t('slideshow.watermarkStyleLabel', 'Logo style')}</label>
<select
value={val.slideshow_watermark_style}
onChange={(e) => setVal({ ...val, slideshow_watermark_style: e.target.value as SlideshowGlobalDefaults['slideshow_watermark_style'] })}
className={inputClass}
>
{SLIDESHOW_WATERMARK_STYLES.map((st) => (
<option key={st} value={st}>
{t(`slideshow.watermarkStyle.${st}`, st === 'original' ? 'Original colors' : 'White')}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>{t('slideshow.watermarkSizeLabel', 'Size (% of screen)')}</label>
<input
type="number"
min={3}
max={40}
step={1}
value={val.slideshow_watermark_size}
onChange={(e) => setVal({ ...val, slideshow_watermark_size: Math.min(40, Math.max(3, parseInt(e.target.value, 10) || 12)) })}
className={inputClass}
/>
</div>
</div>
</div>
)}
</div>
<Button variant="outline" size="md" leftIcon={<Save className="w-4 h-4" />} onClick={save} isLoading={saving}>
{t('common.save', 'Save')}
</Button>
</div>
</Card>
);
};
export default SlideshowGlobalDefaultsCard;
@@ -0,0 +1,232 @@
/**
* <SlideshowSettingsCard>
*
* Per-event "Live Slideshow" ("Diashow") admin surface (migrations 138/139).
* A token-only fullscreen kiosk link for live events that auto-picks-up new
* uploads while it runs. Mounted once on the EventDetailsPage; admin can:
* - Generate the slideshow link on demand (mints show_share_token)
* - Copy / Regenerate (rotate, kills the old link) / Disable it
* - Tune the LIVE style (transition, timing, color filter, logo watermark)
* via the shared <SlideshowStyleFields>; a running projector picks the
* changes up within a few seconds via the show page's settings poll.
*
* New events inherit their initial style from the event TYPE preset; this
* card edits the per-event override. Settings save through
* PATCH /api/admin/events/:id/slideshow; link actions through
* POST .../slideshow/{generate,disable}.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { MonitorPlay, Copy, CheckCircle, RotateCw, Trash2, Save } from 'lucide-react';
import { Button, Card } from '../common';
import { eventsService } from '../../services/events.service';
import { DEFAULT_SLIDESHOW_STYLE, type SlideshowStyle } from '../../services/slideshow.service';
import { SlideshowStyleFields } from './SlideshowStyleFields';
export interface SlideshowSettingsCardProps {
eventId: number;
slug: string;
isArchived?: boolean;
initial: {
show_share_token?: string | null;
show_interval_ms?: number;
show_transition?: string;
show_transition_ms?: number;
show_watermark?: boolean | null;
show_colorfilter?: string;
};
onChanged?: () => void;
}
// Tri-state: null/undefined → inherit the global default; true → on; false → off.
function watermarkMode(v: boolean | null | undefined): SlideshowStyle['watermark'] {
if (v === null || v === undefined) return 'inherit';
return v ? 'on' : 'off';
}
function styleFromInitial(initial: SlideshowSettingsCardProps['initial']): SlideshowStyle {
return {
interval_ms: initial.show_interval_ms ?? DEFAULT_SLIDESHOW_STYLE.interval_ms,
transition: (initial.show_transition as SlideshowStyle['transition']) ?? DEFAULT_SLIDESHOW_STYLE.transition,
transition_ms: initial.show_transition_ms ?? DEFAULT_SLIDESHOW_STYLE.transition_ms,
watermark: watermarkMode(initial.show_watermark),
colorfilter: (initial.show_colorfilter as SlideshowStyle['colorfilter']) ?? DEFAULT_SLIDESHOW_STYLE.colorfilter,
};
}
export const SlideshowSettingsCard: React.FC<SlideshowSettingsCardProps> = ({
eventId, slug, isArchived, initial, onChanged,
}) => {
const { t } = useTranslation();
const [token, setToken] = useState<string | null>(initial.show_share_token ?? null);
const [style, setStyle] = useState<SlideshowStyle>(() => styleFromInitial(initial));
const [copied, setCopied] = useState(false);
const [busy, setBusy] = useState(false);
const [saving, setSaving] = useState(false);
const link = token ? `${window.location.origin}/gallery/${slug}/show/${token}` : '';
const generate = async () => {
setBusy(true);
try {
const res = await eventsService.generateSlideshowLink(eventId);
setToken(res.show_share_token);
toast.success(t('slideshow.linkGenerated', 'Slideshow link generated'));
onChanged?.();
} catch (e: any) {
// Surface the real backend reason (the toast otherwise just says "Error").
console.error('[slideshow]', e?.response?.status, e?.response?.data || e?.message || e);
toast.error(e?.response?.data?.error || t('common.error', 'Something went wrong'));
} finally {
setBusy(false);
}
};
const disable = async () => {
if (!confirm(t('slideshow.disableConfirm', 'Disable this slideshow link? The current link will stop working.'))) {
return;
}
setBusy(true);
try {
await eventsService.disableSlideshowLink(eventId);
setToken(null);
toast.success(t('slideshow.linkDisabled', 'Slideshow link disabled'));
onChanged?.();
} catch (e: any) {
// Surface the real backend reason (the toast otherwise just says "Error").
console.error('[slideshow]', e?.response?.status, e?.response?.data || e?.message || e);
toast.error(e?.response?.data?.error || t('common.error', 'Something went wrong'));
} finally {
setBusy(false);
}
};
const copy = async () => {
try {
await navigator.clipboard.writeText(link);
} catch {
const ta = document.createElement('textarea');
ta.value = link;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const saveSettings = async () => {
setSaving(true);
try {
await eventsService.updateSlideshowSettings(eventId, {
show_interval_ms: style.interval_ms,
show_transition: style.transition,
show_transition_ms: style.transition_ms,
// Tri-state → null (inherit global) / true / false. The watermark LOOK
// is global-only (Settings → Slideshow); we only send the mode here.
show_watermark: style.watermark === 'inherit' ? null : style.watermark === 'on',
show_colorfilter: style.colorfilter,
});
toast.success(t('slideshow.settingsSaved', 'Slideshow settings saved'));
onChanged?.();
} catch (e: any) {
// Surface the real backend reason (the toast otherwise just says "Error").
console.error('[slideshow]', e?.response?.status, e?.response?.data || e?.message || e);
toast.error(e?.response?.data?.error || t('common.error', 'Something went wrong'));
} finally {
setSaving(false);
}
};
const inputClass =
'w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm';
const labelClass = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
return (
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
<MonitorPlay className="w-5 h-5" />
{t('slideshow.adminTitle', 'Live Slideshow')}
</h2>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-4">
{t('slideshow.adminDescription', 'A separate fullscreen link for projectors at live events. It shows all published photos and automatically picks up new uploads while running.')}
</p>
<div className="space-y-4">
{!token ? (
<Button
variant="primary"
size="md"
leftIcon={<MonitorPlay className="w-4 h-4" />}
onClick={generate}
isLoading={busy}
disabled={isArchived}
>
{t('slideshow.generateLink', 'Generate slideshow link')}
</Button>
) : (
<>
<div>
<label className={labelClass}>{t('slideshow.linkLabel', 'Slideshow link')}</label>
<div className="flex items-center gap-2">
<input type="text" value={link} readOnly className={`flex-1 ${inputClass}`} />
<Button
variant="outline"
size="md"
leftIcon={copied ? <CheckCircle className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
onClick={copy}
>
{copied ? t('events.copied', 'Copied') : t('events.copy', 'Copy')}
</Button>
</div>
<div className="flex items-center gap-3 mt-2">
<Button
variant="ghost"
size="sm"
className="text-xs"
leftIcon={<RotateCw className="w-3.5 h-3.5" />}
onClick={generate}
disabled={busy || isArchived}
>
{t('slideshow.regenerate', 'Regenerate')}
</Button>
<Button
variant="ghost"
size="sm"
className="text-xs text-red-600 dark:text-red-400"
leftIcon={<Trash2 className="w-3.5 h-3.5" />}
onClick={disable}
disabled={busy}
>
{t('slideshow.disable', 'Disable')}
</Button>
</div>
</div>
{/* Live style settings */}
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
<SlideshowStyleFields value={style} onChange={setStyle} />
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('slideshow.liveHint', 'Changes apply to a running slideshow within a few seconds — no need to regenerate the link.')}
</p>
<Button
variant="outline"
size="md"
leftIcon={<Save className="w-4 h-4" />}
onClick={saveSettings}
isLoading={saving}
>
{t('slideshow.saveSettings', 'Save slideshow settings')}
</Button>
</>
)}
</div>
</Card>
);
};
export default SlideshowSettingsCard;
@@ -0,0 +1,118 @@
/**
* <SlideshowStyleFields>
*
* Shared, controlled editor for a slideshow's visual style — transition,
* timing, watermark and color filter. Used in two places:
* - SlideshowSettingsCard (per-event live settings)
* - EventTypeModal (per-event-type preset that new events inherit)
*
* Purely presentational: it owns no persistence, just renders the controls
* for a SlideshowStyle value and calls onChange with the next value.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
SLIDESHOW_TRANSITIONS,
SLIDESHOW_COLORFILTERS,
SLIDESHOW_WATERMARK_MODES,
type SlideshowStyle,
} from '../../services/slideshow.service';
export interface SlideshowStyleFieldsProps {
value: SlideshowStyle;
onChange: (next: SlideshowStyle) => void;
}
const inputClass =
'w-full px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm';
const labelClass = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
const titleCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ value, onChange }) => {
const { t } = useTranslation();
const set = (patch: Partial<SlideshowStyle>) => onChange({ ...value, ...patch });
return (
<div className="space-y-4">
{/* Transition + timing */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className={labelClass}>{t('slideshow.transitionLabel', 'Transition')}</label>
<select
value={value.transition}
onChange={(e) => set({ transition: e.target.value as SlideshowStyle['transition'] })}
className={inputClass}
>
{SLIDESHOW_TRANSITIONS.map((tr) => (
<option key={tr} value={tr}>
{t(`slideshow.transition.${tr}`, titleCase(tr))}
</option>
))}
</select>
</div>
<div>
<label className={labelClass}>{t('slideshow.intervalLabel', 'Display time (sec)')}</label>
<input
type="number"
min={1}
max={120}
value={Math.round(value.interval_ms / 1000)}
onChange={(e) => set({ interval_ms: Math.min(120, Math.max(1, parseInt(e.target.value, 10) || 5)) * 1000 })}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>{t('slideshow.transitionSpeedLabel', 'Transition speed (ms)')}</label>
<input
type="number"
min={100}
max={5000}
step={100}
value={value.transition_ms}
onChange={(e) => set({ transition_ms: Math.min(5000, Math.max(100, parseInt(e.target.value, 10) || 800)) })}
className={inputClass}
/>
</div>
</div>
{/* Color filter */}
<div>
<label className={labelClass}>{t('slideshow.colorfilterLabel', 'Color filter')}</label>
<select
value={value.colorfilter}
onChange={(e) => set({ colorfilter: e.target.value as SlideshowStyle['colorfilter'] })}
className={inputClass}
>
{SLIDESHOW_COLORFILTERS.map((cf) => (
<option key={cf} value={cf}>
{t(`slideshow.colorfilter.${cf}`, titleCase(cf))}
</option>
))}
</select>
</div>
{/* Watermark — MODE only. The look (logo/position/opacity/style/size)
lives in Settings → Slideshow, so it isn't duplicated here. */}
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
<label className={labelClass}>{t('slideshow.watermarkToggle', 'Logo watermark')}</label>
<select
value={value.watermark}
onChange={(e) => set({ watermark: e.target.value as SlideshowStyle['watermark'] })}
className={inputClass}
>
{SLIDESHOW_WATERMARK_MODES.map((m) => (
<option key={m} value={m}>
{t(`slideshow.watermarkMode.${m}`, m === 'inherit' ? 'Use global default' : m === 'on' ? 'On' : 'Off')}
</option>
))}
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('slideshow.watermarkModeHint', 'The logo, position, opacity, style and size are configured under Settings → Slideshow.')}
</p>
</div>
</div>
);
};
export default SlideshowStyleFields;
@@ -0,0 +1,349 @@
import React, { useState, useEffect } from 'react';
import { Palette, RotateCcw } from 'lucide-react';
import { Button } from '../common';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
import type { EnabledTemplate } from '../../services/cssTemplates.service';
import { settingsService } from '../../services/settings.service';
import { fontsService, type FontDefinition } from '../../services/fonts.service';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { ThemePresetsCard } from './theme-customizer/ThemePresetsCard';
import { GalleryLayoutCard } from './theme-customizer/GalleryLayoutCard';
import { HeaderStyleCard } from './theme-customizer/HeaderStyleCard';
import { ControlsStyleCard } from './theme-customizer/ControlsStyleCard';
import { ColorCustomizationCard } from './theme-customizer/ColorCustomizationCard';
import { TypographyStyleCard } from './theme-customizer/TypographyStyleCard';
import { CssTemplateCard } from './theme-customizer/CssTemplateCard';
import { CustomCssCard } from './theme-customizer/CustomCssCard';
interface ThemeCustomizerEnhancedProps {
value: ThemeConfig;
onChange: (theme: ThemeConfig) => void;
presetName?: string;
onPresetChange?: (presetName: string) => void;
showGalleryLayouts?: boolean;
hideActions?: boolean;
onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise<void> | void;
isApplying?: boolean;
// CSS Template props
cssTemplates?: EnabledTemplate[];
cssTemplateId?: number | null;
onCssTemplateChange?: (templateId: number | null) => void;
// Force color mode is an instance-level branding setting (not part of the
// per-theme config), but it lives next to the per-theme Color Mode picker
// so the Branding admin can find both controls in one place. When these
// props are omitted (e.g. event-level theme editor), the section is hidden.
forceColorMode?: 'dark' | 'light' | null;
onForceColorModeChange?: (mode: 'dark' | 'light' | null) => void;
// Sync palette from Branding. When provided, a small button appears in
// the colour-pickers section header. The caller resolves the active
// Branding theme and fires onChange with the merged 8-token values —
// only the colour tokens swap, layout/header/typography stay put so an
// admin who's already arranged the structure can pull just the palette.
onSyncFromBranding?: () => void;
// Optional render slot inserted between the Typography & Style /
// CSS Templates section and the Event-specific Custom CSS card.
// Used by BrandingPage to slot in unrelated cards (PDF typography)
// so they live with the other typography choices rather than after
// the always-bulky Custom CSS editor.
slotBeforeCustomCss?: React.ReactNode;
}
// Layout descriptions will use translation keys
export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = ({
value,
onChange,
presetName = 'default',
onPresetChange,
showGalleryLayouts = true,
hideActions = false,
onApply,
isApplying = false,
cssTemplates,
cssTemplateId,
onCssTemplateChange,
forceColorMode,
onForceColorModeChange,
onSyncFromBranding,
slotBeforeCustomCss
}) => {
const { t } = useTranslation();
// A force lock (instance-wide light/dark) overrides the per-theme color
// mode. On the Branding page (where the Force control lives —
// onForceColorModeChange is provided) we hide only the now-redundant
// per-theme Color Mode picker. In per-event gallery editors (no Force
// control) we ALSO hide the colour pickers, since a gallery can't override
// the site-wide lock. Presets, fonts and style always stay.
const forcedColorActive = (forceColorMode ?? null) !== null;
const isBrandingContext = !!onForceColorModeChange;
const hideGalleryColors = forcedColorActive && !isBrandingContext;
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
const [selectedPreset, setSelectedPreset] = useState(presetName);
const [customCss, setCustomCss] = useState(value.customCss || '');
const BETA_LAYOUTS: GalleryLayoutType[] = ['gallery-premium', 'gallery-story'];
const MIN_RECOMMENDED_THUMBNAIL_SIZE = 500;
// Fetch thumbnail settings to warn about low resolution with beta themes
const { data: allSettings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings(),
staleTime: 60000,
});
// Fetch the list of self-hosted font families discovered by the backend
// scanner. Used to populate the body / heading font dropdowns. Cached
// 5 minutes — fonts rarely change without a backend restart.
const { data: availableFonts } = useQuery<FontDefinition[]>({
queryKey: ['fonts'],
queryFn: () => fontsService.list(),
staleTime: 5 * 60 * 1000,
});
const thumbnailWidth = parseInt(allSettings?.thumbnail_width) || 300;
const thumbnailHeight = parseInt(allSettings?.thumbnail_height) || 300;
const isBetaLayout = BETA_LAYOUTS.includes(localTheme.galleryLayout as GalleryLayoutType);
const isThumbnailTooSmall = Math.max(thumbnailWidth, thumbnailHeight) < MIN_RECOMMENDED_THUMBNAIL_SIZE;
useEffect(() => {
setLocalTheme(value);
setCustomCss(value.customCss || '');
}, [value]);
useEffect(() => {
setSelectedPreset(presetName);
}, [presetName]);
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
const updated: ThemeConfig = { ...localTheme, [key]: newValue };
// Legacy alias: keep primaryColor in lockstep with accentDarkColor so
// any consumer that still reads --color-primary or themeConfig.primaryColor
// doesn't drift after the 8-token migration.
if (key === 'accentDarkColor') {
updated.primaryColor = newValue;
}
setLocalTheme(updated);
// When any change is made, mark it as custom
if (selectedPreset !== 'custom' && onPresetChange) {
setSelectedPreset('custom');
onPresetChange('custom');
}
// Always propagate to parent so Save sees the latest values (#323).
// The "Apply changes immediately (Live Preview)" toggle controls whether
// the parent applies the theme globally — that gating belongs in the
// parent, not here.
onChange({ ...updated, customCss });
};
const handlePresetSelect = (presetKey: string) => {
const preset = GALLERY_THEME_PRESETS[presetKey];
if (preset) {
setSelectedPreset(presetKey);
setLocalTheme(preset.config);
// Don't wipe customCss on preset pick — preset configs carry no
// customCss, and the admin's persisted styling extras should
// survive a layout switch (#645). Matches ThemeCustomizer.tsx
// which never cleared it. The parent's handleThemeChange merges
// via `customCss: newTheme.customCss ?? currentTheme.customCss`,
// so propagating preset.config (no customCss) keeps the saved
// value intact end-to-end.
if (onPresetChange) {
onPresetChange(presetKey);
}
// Always propagate; live-apply gating is the parent's concern (#323).
onChange(preset.config);
}
};
const handleApply = async () => {
const themeWithCss = { ...localTheme, customCss };
onChange(themeWithCss);
if (onApply) {
await onApply(themeWithCss, { presetName: selectedPreset });
}
};
const handleReset = () => {
const defaultPreset = GALLERY_THEME_PRESETS['default'];
if (defaultPreset) {
setSelectedPreset('default');
setLocalTheme(defaultPreset.config);
setCustomCss('');
onChange(defaultPreset.config);
if (onPresetChange) {
onPresetChange('default');
}
}
};
// const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
// const file = e.target.files?.[0];
// if (file) {
// try {
// const logoUrl = await settingsService.uploadLogo(file);
// handleChange('logoUrl', logoUrl);
// toast.success('Logo uploaded successfully');
// } catch (error) {
// console.error('Failed to upload logo:', error);
// toast.error('Failed to upload logo');
// }
// }
// };
const updateGallerySettings = (key: string, value: any) => {
const updatedSettings = {
...localTheme.gallerySettings,
[key]: value
};
handleChange('gallerySettings', updatedSettings);
};
const handleColorModeSelect = (mode: 'light' | 'dark' | 'auto') => {
handleChange('colorMode', mode);
// When switching to dark, auto-populate dark defaults if colors are still light
if (mode === 'dark' && (!localTheme.backgroundColor || localTheme.backgroundColor === '#fafafa' || localTheme.backgroundColor === '#ffffff')) {
const updated: ThemeConfig = {
...localTheme,
colorMode: mode,
backgroundColor: '#0f0f0f',
surfaceColor: '#1a1a1a',
elevatedColor: '#242424',
surfaceBorderColor: '#2e2e2e',
textColor: '#e5e5e5',
mutedTextColor: '#a3a3a3',
};
setLocalTheme(updated);
onChange({ ...updated, customCss });
} else if (mode === 'light' && localTheme.colorMode === 'dark') {
const updated: ThemeConfig = {
...localTheme,
colorMode: mode,
backgroundColor: '#fafafa',
surfaceColor: '#ffffff',
elevatedColor: '#f5f5f5',
surfaceBorderColor: '#e5e5e5',
textColor: '#171717',
mutedTextColor: '#737373',
};
setLocalTheme(updated);
onChange({ ...updated, customCss });
}
};
const handleCustomCssChange = (newCss: string) => {
setCustomCss(newCss);
// Mark as custom when CSS is added
if (newCss && selectedPreset !== 'custom' && onPresetChange) {
setSelectedPreset('custom');
onPresetChange('custom');
}
// Propagate to parent so Save sees the latest CSS (#323).
onChange({ ...localTheme, customCss: newCss });
};
return (
<div className="space-y-6">
{/* Preset Themes */}
<ThemePresetsCard
selectedPreset={selectedPreset}
handlePresetSelect={handlePresetSelect}
showGalleryLayouts={showGalleryLayouts}
isBetaLayout={isBetaLayout}
isThumbnailTooSmall={isThumbnailTooSmall}
thumbnailWidth={thumbnailWidth}
thumbnailHeight={thumbnailHeight}
minRecommendedThumbnailSize={MIN_RECOMMENDED_THUMBNAIL_SIZE}
/>
{/* Gallery Layout */}
{showGalleryLayouts && (
<GalleryLayoutCard
localTheme={localTheme}
handleChange={handleChange}
updateGallerySettings={updateGallerySettings}
isBetaLayout={isBetaLayout}
isThumbnailTooSmall={isThumbnailTooSmall}
thumbnailWidth={thumbnailWidth}
thumbnailHeight={thumbnailHeight}
minRecommendedThumbnailSize={MIN_RECOMMENDED_THUMBNAIL_SIZE}
/>
)}
{/* Header Style - Decoupled from Layout */}
{showGalleryLayouts && (
<HeaderStyleCard localTheme={localTheme} handleChange={handleChange} />
)}
{/* Controls Style */}
{showGalleryLayouts && (
<ControlsStyleCard localTheme={localTheme} handleChange={handleChange} />
)}
{/* Color Customization */}
<ColorCustomizationCard
localTheme={localTheme}
handleChange={handleChange}
handleColorModeSelect={handleColorModeSelect}
forcedColorActive={forcedColorActive}
isBrandingContext={isBrandingContext}
hideGalleryColors={hideGalleryColors}
forceColorMode={forceColorMode}
onForceColorModeChange={onForceColorModeChange}
onSyncFromBranding={onSyncFromBranding}
/>
{/* Typography & Style */}
<TypographyStyleCard
localTheme={localTheme}
handleChange={handleChange}
availableFonts={availableFonts}
/>
{/* CSS Template Selector - only show if templates are provided */}
{cssTemplates && cssTemplates.length > 0 && onCssTemplateChange && (
<CssTemplateCard
cssTemplates={cssTemplates}
cssTemplateId={cssTemplateId}
onCssTemplateChange={onCssTemplateChange}
/>
)}
{/* Caller-provided slot — used by BrandingPage to keep the
PDF typography card adjacent to the web typography section
instead of trailing the (often-collapsed) Custom CSS block. */}
{slotBeforeCustomCss}
{/* Event-specific Custom CSS */}
<CustomCssCard
localTheme={localTheme}
customCss={customCss}
onCustomCssChange={handleCustomCssChange}
/>
{/* Actions */}
{!hideActions && (
<div className="flex items-center justify-end gap-3">
<Button
variant="outline"
leftIcon={<RotateCcw className="w-4 h-4" />}
onClick={handleReset}
>
{t('branding.resetToDefault')}
</Button>
<Button
variant="primary"
leftIcon={<Palette className="w-4 h-4" />}
onClick={handleApply}
disabled={isApplying}
>
{isApplying ? t('common.applying', 'Applying...') : t('branding.applyTheme')}
</Button>
</div>
)}
</div>
);
};
@@ -0,0 +1,161 @@
import React from 'react';
import {
Palette,
Type,
Grid3X3,
Layers,
Play,
Clock,
LayoutGrid,
Layout,
Columns,
Film
} from 'lucide-react';
import { ThemeConfig, GalleryLayoutType, GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { useTranslation } from 'react-i18next';
interface ThemeDisplayProps {
theme: ThemeConfig | string;
presetName?: string;
className?: string;
showDetails?: boolean;
}
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
grid: <Grid3X3 className="w-4 h-4" />,
masonry: <Layers className="w-4 h-4" />,
carousel: <Play className="w-4 h-4" />,
timeline: <Clock className="w-4 h-4" />,
mosaic: <LayoutGrid className="w-4 h-4" />,
'gallery-premium': <Columns className="w-4 h-4" />,
'gallery-story': <Film className="w-4 h-4" />
};
export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
theme,
presetName,
className = '',
showDetails = true
}) => {
const { t } = useTranslation();
// Parse theme if it's a string
let themeConfig: ThemeConfig | null = null;
let themeName = t('branding.theme');
if (typeof theme === 'string') {
try {
if (theme.startsWith('{')) {
themeConfig = JSON.parse(theme);
} else {
// Legacy theme name - find matching preset
const preset = Object.entries(GALLERY_THEME_PRESETS).find(([key]) => key === theme);
if (preset) {
themeConfig = preset[1].config;
themeName = preset[1].name;
}
}
} catch (e) {
console.error('Failed to parse theme:', e);
}
} else {
themeConfig = theme;
}
// If we have a preset name, use its display name
if (presetName && GALLERY_THEME_PRESETS[presetName]) {
themeName = GALLERY_THEME_PRESETS[presetName].name;
}
if (!themeConfig) {
return (
<div className={`text-sm text-neutral-500 ${className}`}>
{t('events.noThemeSet')}
</div>
);
}
const galleryLayout = themeConfig.galleryLayout || 'grid';
return (
<div className={`space-y-3 ${className}`}>
{/* Theme Name & Layout */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Layout className="w-4 h-4 text-neutral-500 dark:text-neutral-300" />
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-100">{themeName}</span>
</div>
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-200">
{layoutIcons[galleryLayout]}
<span className="capitalize">{t(`branding.layoutDescriptions.${galleryLayout}`)}</span>
</div>
</div>
{showDetails && (
<>
{/* Color Palette — show all 8 tokens of the active theme.
Each swatch only renders if its token is set so legacy themes
(pre-8-token migration) still render their original 4 swatches. */}
<div className="flex items-center gap-2">
<Palette className="w-4 h-4 text-neutral-500 dark:text-neutral-300" />
<span className="text-sm text-neutral-600 dark:text-neutral-200">{t('branding.colors')}:</span>
<div className="flex gap-1">
{[
{ value: themeConfig.backgroundColor, title: t('branding.backgroundColor', 'Background') },
{ value: themeConfig.surfaceColor, title: t('branding.surfaceColor', 'Surface') },
{ value: themeConfig.elevatedColor, title: t('branding.elevatedColor', 'Elevated') },
{ value: themeConfig.surfaceBorderColor, title: t('branding.borderColor', 'Border') },
{ value: themeConfig.textColor, title: t('branding.textColor', 'Text') },
{ value: themeConfig.mutedTextColor, title: t('branding.mutedTextColor', 'Muted text') },
{ value: themeConfig.accentColor, title: t('branding.accentColor', 'Accent') },
{ value: themeConfig.accentDarkColor || themeConfig.primaryColor, title: t('branding.accentDarkColor', 'Accent (filled)') },
].filter((s) => !!s.value).map((s, i) => (
<div
key={i}
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
style={{ backgroundColor: s.value }}
title={s.title}
/>
))}
</div>
</div>
{/* Typography */}
{themeConfig.fontFamily && (
<div className="flex items-center gap-2">
<Type className="w-4 h-4 text-neutral-500 dark:text-neutral-300" />
<span className="text-sm text-neutral-600 dark:text-neutral-200">{t('branding.bodyFont')}:</span>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-100" style={{ fontFamily: themeConfig.fontFamily }}>
{themeConfig.fontFamily}
</span>
</div>
)}
{/* Layout Settings */}
{themeConfig.gallerySettings && (
<div className="text-sm text-neutral-600 dark:text-neutral-200">
{themeConfig.gallerySettings.spacing && (
<span className="inline-flex items-center gap-1 mr-3">
<span>{t('branding.photoSpacing')}:</span>
<span className="font-medium capitalize text-neutral-700 dark:text-neutral-100">
{t(`branding.spacing.${themeConfig.gallerySettings.spacing}`)}
</span>
</span>
)}
{themeConfig.gallerySettings.photoAnimation && themeConfig.gallerySettings.photoAnimation !== 'none' && (
<span className="inline-flex items-center gap-1">
<span>{t('branding.photoAnimation')}:</span>
<span className="font-medium capitalize text-neutral-700 dark:text-neutral-100">
{t(`branding.animation.${themeConfig.gallerySettings.photoAnimation}`)}
</span>
</span>
)}
</div>
)}
</>
)}
</div>
);
};
ThemeDisplay.displayName = 'ThemeDisplay';
@@ -0,0 +1,238 @@
import React, { useState, useEffect } from 'react';
import { X, Save, RotateCcw, Grid3X3, Layers, Play, Clock, LayoutGrid, Check, Columns, Film } from 'lucide-react';
import { Button } from '../common';
import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
import { GalleryPreview } from './GalleryPreview';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
import { cssTemplatesService, type EnabledTemplate } from '../../services/cssTemplates.service';
import { useTranslation } from 'react-i18next';
interface ThemeEditorModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (theme: ThemeConfig, presetName: string, cssTemplateId: number | null) => void;
currentTheme: ThemeConfig | string;
currentCssTemplateId?: number | null;
eventName: string;
}
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
grid: <Grid3X3 className="w-4 h-4" />,
masonry: <Layers className="w-4 h-4" />,
carousel: <Play className="w-4 h-4" />,
timeline: <Clock className="w-4 h-4" />,
mosaic: <LayoutGrid className="w-4 h-4" />,
'gallery-premium': <Columns className="w-4 h-4" />,
'gallery-story': <Film className="w-4 h-4" />
};
export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
isOpen,
onClose,
onSave,
currentTheme,
currentCssTemplateId,
eventName
}) => {
const { t } = useTranslation();
const [theme, setTheme] = useState<ThemeConfig>(GALLERY_THEME_PRESETS.default.config);
const [presetName, setPresetName] = useState<string>('default');
const [previewLayout, setPreviewLayout] = useState<GalleryLayoutType | undefined>(undefined);
const [cssTemplates, setCssTemplates] = useState<EnabledTemplate[]>([]);
const [cssTemplateId, setCssTemplateId] = useState<number | null>(currentCssTemplateId ?? null);
// Fetch CSS templates when modal opens
useEffect(() => {
if (isOpen) {
cssTemplatesService.getEnabledTemplates()
.then(setCssTemplates)
.catch(err => console.error('Failed to load CSS templates:', err));
}
}, [isOpen]);
// Update cssTemplateId when prop changes
useEffect(() => {
setCssTemplateId(currentCssTemplateId ?? null);
}, [currentCssTemplateId]);
useEffect(() => {
if (currentTheme) {
if (typeof currentTheme === 'string') {
try {
if (currentTheme.startsWith('{')) {
const parsedTheme = JSON.parse(currentTheme);
setTheme(parsedTheme);
// Try to find matching preset
const matchingPreset = Object.entries(GALLERY_THEME_PRESETS).find(
([_, preset]) => JSON.stringify(preset.config) === JSON.stringify(parsedTheme)
);
setPresetName(matchingPreset ? matchingPreset[0] : 'custom');
} else {
// Legacy theme name
const preset = GALLERY_THEME_PRESETS[currentTheme];
if (preset) {
setTheme(preset.config);
setPresetName(currentTheme);
}
}
} catch (e) {
console.error('Failed to parse theme:', e);
setTheme(GALLERY_THEME_PRESETS.default.config);
setPresetName('default');
}
} else {
setTheme(currentTheme);
setPresetName('custom');
}
}
}, [currentTheme]);
const handleThemeChange = (newTheme: ThemeConfig) => {
setTheme(newTheme);
};
const handlePresetChange = (newPresetName: string) => {
setPresetName(newPresetName);
if (newPresetName !== 'custom') {
const preset = GALLERY_THEME_PRESETS[newPresetName];
if (preset) {
setTheme(preset.config);
}
}
};
const handleSave = () => {
onSave(theme, presetName, cssTemplateId);
onClose();
};
const handleReset = () => {
const defaultPreset = GALLERY_THEME_PRESETS.default;
setTheme(defaultPreset.config);
setPresetName('default');
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
{t('events.galleryTheme')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-300 mt-1">
{t('events.customizingThemeFor', { event: eventName })}
</p>
</div>
<button
onClick={onClose}
className="text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 h-full">
{/* Left side - Theme Customizer */}
<div className="p-6 overflow-y-auto border-r border-neutral-200 dark:border-neutral-700">
<ThemeCustomizerEnhanced
value={theme}
onChange={handleThemeChange}
presetName={presetName}
onPresetChange={handlePresetChange}
isPreviewMode={true}
showGalleryLayouts={true}
hideActions={true}
cssTemplates={cssTemplates}
cssTemplateId={cssTemplateId}
onCssTemplateChange={setCssTemplateId}
/>
</div>
{/* Right side - Gallery Preview */}
<div className="p-6 bg-neutral-50 dark:bg-neutral-800 overflow-y-auto">
<div className="space-y-4">
{/* Grid Style Selector */}
<div>
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-200 mb-3">
{t('branding.previewLayout')}
</h3>
<div className="grid grid-cols-3 gap-2">
{(Object.keys(layoutIcons) as GalleryLayoutType[]).map((layout) => (
<button
key={layout}
onClick={() => setPreviewLayout(layout)}
className={`relative p-3 rounded-lg border-2 transition-all ${
(previewLayout || theme.galleryLayout || 'grid') === layout
? 'tile-selected'
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 bg-white dark:bg-neutral-900'
}`}
>
<div className="flex flex-col items-center gap-1">
<div className="text-neutral-700 dark:text-neutral-200">
{layoutIcons[layout]}
</div>
<span className="text-xs capitalize">
{layout}
{(layout === 'gallery-premium' || layout === 'gallery-story') && (
<span className="ml-0.5 text-amber-600">(Beta)</span>
)}
</span>
</div>
{(previewLayout || theme.galleryLayout || 'grid') === layout && (
<Check className="absolute top-1 right-1 w-3 h-3 text-accent" />
)}
</button>
))}
</div>
</div>
{/* Gallery Preview */}
<div>
<h3 className="text-sm font-medium text-neutral-700 mb-3">
{t('branding.livePreview')}
</h3>
<GalleryPreview
theme={theme}
layoutType={previewLayout}
className="shadow-lg"
/>
</div>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-neutral-200 flex items-center justify-between">
<Button
variant="outline"
leftIcon={<RotateCcw className="w-4 h-4" />}
onClick={handleReset}
>
{t('branding.resetToDefault')}
</Button>
<div className="flex gap-3">
<Button variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
leftIcon={<Save className="w-4 h-4" />}
onClick={handleSave}
>
{t('branding.saveTheme')}
</Button>
</div>
</div>
</div>
</div>
);
};
ThemeEditorModal.displayName = 'ThemeEditorModal';
@@ -0,0 +1,312 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, ExternalLink, Copy, CheckCircle, ChevronDown, ChevronRight, ArrowUpCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { api } from '../../config/api';
import { Button, Card } from '../common';
import { MarkdownContent } from '../common/MarkdownContent';
import { githubReleaseUrl } from '../../utils/githubReleaseUrl';
/**
* Update-available modal (#567).
*
* Opened from the sidebar "vX.Y.Z available" chip. Shows:
* - Aggregated release notes for every version between current and
* latest in the user's channel (one collapsible section each).
* - Copy-paste upgrade command tailored to the detected environment
* (Docker compose, native git, standalone).
* - "Dismiss this version" — writes to localStorage so the chip
* doesn't reappear until an even newer version is published.
*/
interface ReleaseEntry {
version: string;
tag: string;
name: string;
body: string;
publishedAt: string | null;
htmlUrl: string | null;
}
interface ChangelogResponse {
enabled: boolean;
current: string;
channel: string;
releases: ReleaseEntry[];
}
interface InstructionStep {
description: string;
command?: string;
url?: string;
}
interface InstructionsResponse {
updateAvailable: boolean;
currentVersion: string;
targetVersion?: string;
environment?: { type: string; description?: string };
instructions?: {
title: string;
description?: string;
steps: InstructionStep[];
notes?: string[];
};
releaseNotesUrl?: string;
}
interface UpdateAvailableModalProps {
currentVersion: string;
latestVersion: string;
onClose: () => void;
onDismiss: (version: string) => void;
}
const fetchChangelog = async (): Promise<ChangelogResponse> => {
const { data } = await api.get<ChangelogResponse>('/admin/system/updates/changelog');
return data;
};
const fetchInstructions = async (): Promise<InstructionsResponse> => {
const { data } = await api.get<InstructionsResponse>('/admin/system/updates/instructions');
return data;
};
const formatDate = (iso: string | null): string => {
if (!iso) return '';
try {
return new Date(iso).toLocaleDateString();
} catch {
return iso;
}
};
export const UpdateAvailableModal: React.FC<UpdateAvailableModalProps> = ({
currentVersion,
latestVersion,
onClose,
onDismiss,
}) => {
const { t } = useTranslation();
const [expanded, setExpanded] = useState<Set<string>>(new Set([latestVersion]));
const [copiedKey, setCopiedKey] = useState<string | null>(null);
const { data: changelog, isLoading: changelogLoading, isError: changelogError } = useQuery({
queryKey: ['update-changelog'],
queryFn: fetchChangelog,
staleTime: 60 * 60 * 1000,
});
const { data: instructions, isLoading: instructionsLoading } = useQuery({
queryKey: ['update-instructions'],
queryFn: fetchInstructions,
staleTime: 60 * 60 * 1000,
});
const toggle = (version: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(version)) next.delete(version);
else next.add(version);
return next;
});
};
const copy = async (text: string, key: string) => {
try {
await navigator.clipboard.writeText(text);
setCopiedKey(key);
setTimeout(() => setCopiedKey((current) => (current === key ? null : current)), 2000);
} catch {
toast.error(t('admin.updates.copyFailed', 'Could not copy to clipboard'));
}
};
return (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
onClick={onClose}
>
<Card
padding="none"
className="w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col"
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-start justify-between p-5 border-b border-neutral-200 dark:border-neutral-700">
<div className="flex items-start gap-3">
<ArrowUpCircle className="w-6 h-6 text-blue-600 mt-0.5 flex-shrink-0" />
<div>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('admin.updates.modalTitle', 'Update available')}
</h2>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-0.5">
{t('admin.updates.modalSubtitle', 'v{{current}} → v{{latest}}', {
current: currentVersion,
latest: latestVersion,
})}
</p>
</div>
</div>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200"
aria-label={t('common.close', 'Close')}
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body — scrollable */}
<div className="flex-1 overflow-y-auto p-5 space-y-5">
{/* Upgrade instructions */}
<section>
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
{t('admin.updates.howToUpgrade', 'How to upgrade')}
</h3>
{instructionsLoading && (
<p className="text-sm text-neutral-500">{t('common.loading', 'Loading…')}</p>
)}
{!instructionsLoading && instructions?.instructions && (
<div className="space-y-3">
{instructions.environment?.description && (
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{t('admin.updates.detectedEnv', 'Detected environment: {{env}}', {
env: instructions.environment.description,
})}
</p>
)}
{instructions.instructions.steps.map((step, idx) => {
const key = `step-${idx}`;
return (
<div key={key}>
<p className="text-sm text-neutral-700 dark:text-neutral-300 mb-1">
{idx + 1}. {step.description}
</p>
{step.command && (
<div className="relative">
<pre className="text-xs bg-neutral-900 text-neutral-100 rounded p-3 overflow-x-auto">
<code>{step.command}</code>
</pre>
<button
onClick={() => copy(step.command!, key)}
className="absolute top-2 right-2 p-1.5 rounded hover:bg-neutral-700/50 text-neutral-300"
aria-label={t('admin.updates.copyCommand', 'Copy command')}
>
{copiedKey === key
? <CheckCircle className="w-4 h-4 text-green-400" />
: <Copy className="w-4 h-4" />}
</button>
</div>
)}
</div>
);
})}
{instructions.instructions.notes && instructions.instructions.notes.length > 0 && (
<ul className="text-xs text-neutral-500 dark:text-neutral-400 list-disc list-inside space-y-1">
{instructions.instructions.notes.map((note, idx) => (
<li key={idx}>{note}</li>
))}
</ul>
)}
</div>
)}
</section>
{/* Aggregated changelog */}
<section>
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
{t('admin.updates.releaseNotes', 'Release notes')}
</h3>
{changelogLoading && (
<p className="text-sm text-neutral-500">{t('common.loading', 'Loading…')}</p>
)}
{changelogError && (
<p className="text-sm text-red-600">
{t('admin.updates.changelogError', 'Could not load release notes. Check the release pages directly on GitHub.')}
</p>
)}
{changelog?.releases.length === 0 && !changelogLoading && (
<p className="text-sm text-neutral-500">
{t('admin.updates.noReleases', 'No release notes available.')}
</p>
)}
{changelog && changelog.releases.length > 0 && (
<div className="space-y-2">
{changelog.releases.map((release) => {
const isOpen = expanded.has(release.version);
return (
<div
key={release.version}
className="border border-neutral-200 dark:border-neutral-700 rounded"
>
<button
onClick={() => toggle(release.version)}
className="w-full flex items-center justify-between p-3 hover:bg-neutral-50 dark:hover:bg-neutral-800"
>
<div className="flex items-center gap-2 text-left">
{isOpen
? <ChevronDown className="w-4 h-4 text-neutral-500" />
: <ChevronRight className="w-4 h-4 text-neutral-500" />}
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{release.name}
</span>
{release.publishedAt && (
<span className="text-xs text-neutral-500">
{formatDate(release.publishedAt)}
</span>
)}
</div>
<a
href={release.htmlUrl || githubReleaseUrl(release.version)}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-xs text-blue-600 hover:underline flex items-center gap-1"
>
{t('admin.updates.viewOnGitHub', 'View on GitHub')}
<ExternalLink className="w-3 h-3" />
</a>
</button>
{isOpen && release.body && (
<div className="px-4 pb-4 pt-1 border-t border-neutral-100 dark:border-neutral-800">
<MarkdownContent
source={release.body}
className="text-sm prose prose-sm dark:prose-invert max-w-none"
/>
</div>
)}
{isOpen && !release.body && (
<div className="px-4 pb-4 pt-1 text-sm text-neutral-500 italic">
{t('admin.updates.noNotes', 'No release notes provided.')}
</div>
)}
</div>
);
})}
</div>
)}
</section>
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-3 p-4 border-t border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900/50">
<Button
variant="ghost"
size="sm"
onClick={() => {
onDismiss(latestVersion);
onClose();
}}
>
{t('admin.updates.dismissUntilNext', 'Dismiss until next version')}
</Button>
<Button variant="primary" size="sm" onClick={onClose}>
{t('common.close', 'Close')}
</Button>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,357 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
X,
ExternalLink,
Copy,
Check,
AlertTriangle,
Server,
Terminal,
CheckCircle2,
Circle
} from 'lucide-react';
import { api } from '../../config/api';
interface UpdateStep {
description: string;
command: string;
note?: string;
optional?: boolean;
}
interface PreCheck {
id: string;
text: string;
required: boolean;
}
interface UpdateInstructions {
environmentName: string;
preChecks: PreCheck[];
steps: UpdateStep[];
postChecks: string[];
warnings: string[];
}
interface Environment {
type: 'docker' | 'git' | 'standalone';
isDocker: boolean;
isGit: boolean;
hasDockerCompose: boolean;
platform: string;
nodeVersion: string;
appVersion: string;
}
interface UpdateInstructionsResponse {
enabled?: boolean;
updateAvailable: boolean;
currentVersion: string;
targetVersion?: string;
channel?: string;
environment?: Environment;
instructions?: UpdateInstructions;
releaseNotesUrl?: string;
message?: string;
}
async function fetchUpdateInstructions(): Promise<UpdateInstructionsResponse> {
const response = await api.get<UpdateInstructionsResponse>('/admin/system/updates/instructions');
return response.data;
}
interface UpdateInstructionsDialogProps {
isOpen: boolean;
onClose: () => void;
targetVersion?: string;
}
export const UpdateInstructionsDialog: React.FC<UpdateInstructionsDialogProps> = ({
isOpen,
onClose,
targetVersion
}) => {
const { t } = useTranslation();
const [checkedItems, setCheckedItems] = useState<Set<string>>(new Set());
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
const { data, isLoading, error } = useQuery({
queryKey: ['update-instructions'],
queryFn: fetchUpdateInstructions,
enabled: isOpen,
staleTime: 5 * 60 * 1000 // 5 minutes
});
if (!isOpen) return null;
const handleCheckItem = (id: string) => {
const newChecked = new Set(checkedItems);
if (newChecked.has(id)) {
newChecked.delete(id);
} else {
newChecked.add(id);
}
setCheckedItems(newChecked);
};
const copyToClipboard = async (command: string, id: string) => {
try {
await navigator.clipboard.writeText(command);
setCopiedCommand(id);
setTimeout(() => setCopiedCommand(null), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const copyAllCommands = async () => {
if (!data?.instructions?.steps) return;
const allCommands = data.instructions.steps
.filter(step => !step.command.startsWith('#'))
.map(step => step.command)
.join('\n');
try {
await navigator.clipboard.writeText(allCommands);
setCopiedCommand('all');
setTimeout(() => setCopiedCommand(null), 2000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const requiredChecks = data?.instructions?.preChecks.filter(c => c.required) || [];
const allRequiredChecked = requiredChecks.every(check => checkedItems.has(check.id));
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
{/* Backdrop */}
<div
className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75"
onClick={onClose}
/>
{/* Dialog */}
<div className="inline-block w-full max-w-2xl my-8 overflow-hidden text-left align-middle transition-all transform bg-white dark:bg-gray-800 rounded-lg shadow-xl">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{t('admin.updates.updateDialog.title', 'Update PicPeak')}
{data?.targetVersion && (
<span className="ml-2 text-blue-600 dark:text-blue-400">
v{data.targetVersion}
</span>
)}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">
{isLoading && (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
)}
{error && (
<div className="flex items-center p-4 bg-red-50 dark:bg-red-900/30 rounded-lg">
<AlertTriangle className="w-5 h-5 text-red-500 mr-3" />
<p className="text-red-700 dark:text-red-300">
{t('admin.updates.updateDialog.error', 'Failed to load update instructions')}
</p>
</div>
)}
{data && !data.updateAvailable && (
<div className="flex items-center p-4 bg-green-50 dark:bg-green-900/30 rounded-lg">
<CheckCircle2 className="w-5 h-5 text-green-500 mr-3" />
<p className="text-green-700 dark:text-green-300">
{t('admin.updates.upToDate', "You're up to date")} (v{data.currentVersion})
</p>
</div>
)}
{data?.instructions && (
<div className="space-y-6">
{/* Environment Info */}
<div className="flex items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<Server className="w-5 h-5 text-gray-500 dark:text-gray-400 mr-3" />
<span className="text-sm text-gray-600 dark:text-gray-300">
{t('admin.updates.updateDialog.detectedEnv', 'Detected Environment')}:{' '}
<strong>{data.instructions.environmentName}</strong>
</span>
</div>
{/* Warnings */}
{data.instructions.warnings.length > 0 && (
<div className="space-y-2">
{data.instructions.warnings.map((warning, idx) => (
<div key={idx} className="flex items-start p-3 bg-amber-50 dark:bg-amber-900/30 rounded-lg">
<AlertTriangle className="w-5 h-5 text-amber-500 mr-3 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-700 dark:text-amber-300">{warning}</p>
</div>
))}
</div>
)}
{/* Pre-flight Checklist */}
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center">
<AlertTriangle className="w-4 h-4 text-amber-500 mr-2" />
{t('admin.updates.updateDialog.beforeUpdating', 'Before updating:')}
</h4>
<div className="space-y-2">
{data.instructions.preChecks.map((check) => (
<label
key={check.id}
className="flex items-center p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
>
<input
type="checkbox"
checked={checkedItems.has(check.id)}
onChange={() => handleCheckItem(check.id)}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="ml-3 text-sm text-gray-700 dark:text-gray-300">
{check.text}
{check.required && (
<span className="text-red-500 ml-1">*</span>
)}
</span>
</label>
))}
</div>
</div>
{/* Divider */}
<hr className="border-gray-200 dark:border-gray-700" />
{/* Update Commands */}
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center">
<Terminal className="w-4 h-4 text-blue-500 mr-2" />
{t('admin.updates.updateDialog.updateCommands', 'Update Commands:')}
</h4>
<div className="space-y-4">
{data.instructions.steps.map((step, idx) => (
<div key={idx} className={`${step.optional ? 'opacity-75' : ''}`}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-600 dark:text-gray-400">
{idx + 1}. {step.description}
{step.optional && (
<span className="ml-2 text-xs text-gray-400">
({t('common.optional', 'optional')})
</span>
)}
</span>
</div>
<div className="flex items-center bg-gray-900 dark:bg-gray-950 rounded-lg overflow-hidden">
<code className="flex-1 px-4 py-3 text-sm text-green-400 font-mono overflow-x-auto">
{step.command}
</code>
<button
onClick={() => copyToClipboard(step.command, `step-${idx}`)}
className="px-3 py-3 text-gray-400 hover:text-white border-l border-gray-700"
title={t('common.copy', 'Copy')}
>
{copiedCommand === `step-${idx}` ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
</div>
{step.note && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{step.note}
</p>
)}
</div>
))}
</div>
</div>
{/* Divider */}
<hr className="border-gray-200 dark:border-gray-700" />
{/* Post-update Checks */}
<div>
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center">
<CheckCircle2 className="w-4 h-4 text-green-500 mr-2" />
{t('admin.updates.updateDialog.afterUpdating', 'After updating:')}
</h4>
<ul className="space-y-2">
{data.instructions.postChecks.map((check, idx) => (
<li key={idx} className="flex items-center text-sm text-gray-600 dark:text-gray-400">
<Circle className="w-2 h-2 mr-3 flex-shrink-0" />
{check}
</li>
))}
</ul>
</div>
{/* Release Notes Link */}
{data.releaseNotesUrl && (
<a
href={data.releaseNotesUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
<ExternalLink className="w-4 h-4 mr-2" />
{t('admin.updates.viewReleaseNotes', 'View Release Notes')}
</a>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div className="text-xs text-gray-500 dark:text-gray-400">
{!allRequiredChecked && data?.instructions && (
<span className="text-amber-600 dark:text-amber-400">
{t('admin.updates.updateDialog.completeChecklist', 'Complete the checklist before updating')}
</span>
)}
</div>
<div className="flex items-center space-x-3">
{data?.instructions && (
<button
onClick={copyAllCommands}
className="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
{copiedCommand === 'all' ? (
<>
<Check className="w-4 h-4 mr-2 text-green-500" />
{t('common.copied', 'Copied!')}
</>
) : (
<>
<Copy className="w-4 h-4 mr-2" />
{t('admin.updates.updateDialog.copyAllCommands', 'Copy All Commands')}
</>
)}
</button>
)}
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
>
{t('common.close', 'Close')}
</button>
</div>
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,133 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { ArrowUpCircle, X, ExternalLink, Wrench } from 'lucide-react';
import { api } from '../../config/api';
import { UpdateInstructionsDialog } from './UpdateInstructionsDialog';
interface UpdateInfo {
enabled: boolean;
current: string;
channel: 'stable' | 'beta';
latest: {
stable: string;
beta: string;
forChannel: string;
};
updateAvailable: boolean;
newerBetaAvailable?: boolean;
lastChecked: string;
error?: string;
message?: string;
/** Top highlights of the target version (pre-update teaser). */
latestHighlights?: string[];
}
async function fetchUpdateInfo(): Promise<UpdateInfo> {
const response = await api.get<UpdateInfo>('/admin/system/updates');
return response.data;
}
interface UpdateNotificationProps {
onDismiss?: () => void;
}
export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismiss }) => {
const { t } = useTranslation();
const [dismissed, setDismissed] = useState(false);
const [showInstructions, setShowInstructions] = useState(false);
const { data: updateInfo } = useQuery({
queryKey: ['update-check'],
queryFn: fetchUpdateInfo,
staleTime: 60 * 60 * 1000, // 1 hour
retry: false,
refetchOnWindowFocus: false
});
// Don't render if no update available, not enabled, or dismissed
if (!updateInfo?.enabled || !updateInfo?.updateAvailable || dismissed) {
return null;
}
const handleDismiss = () => {
setDismissed(true);
onDismiss?.();
};
const channelLabel = updateInfo.channel === 'beta'
? t('admin.updates.channelBeta', 'Beta')
: t('admin.updates.channelStable', 'Stable');
return (
<div className="bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-500 p-4 mb-4 rounded-r-lg">
<div className="flex items-start justify-between">
<div className="flex items-start">
<ArrowUpCircle className="w-5 h-5 text-blue-500 mt-0.5 mr-3 flex-shrink-0" />
<div>
<h4 className="text-sm font-semibold text-blue-800 dark:text-blue-200">
{t('admin.updates.available', 'Update Available')}
</h4>
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
{t('admin.updates.newVersion', 'Version {{version}} is available', {
version: updateInfo.latest.forChannel
})}
<span className="text-blue-500 dark:text-blue-400 ml-2">
({t('admin.updates.currentVersion', 'Current: {{version}}', {
version: updateInfo.current
})})
</span>
</p>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
{t('admin.updates.channel', 'Channel: {{channel}}', {
channel: channelLabel
})}
</p>
{Array.isArray(updateInfo.latestHighlights) && updateInfo.latestHighlights.length > 0 && (
<div className="mt-2">
<p className="text-xs font-medium text-blue-700 dark:text-blue-300">
{t('admin.updates.newFeatures', 'New features include:')}
</p>
<ul className="text-xs text-blue-700 dark:text-blue-300 mt-0.5 list-disc list-inside">
{updateInfo.latestHighlights.slice(0, 4).map((h, i) => <li key={i}>{h}</li>)}
</ul>
</div>
)}
<div className="flex items-center gap-3 mt-2">
<button
onClick={() => setShowInstructions(true)}
className="inline-flex items-center text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-md transition-colors"
>
<Wrench className="w-3 h-3 mr-1.5" />
{t('admin.updates.updateNow', 'Update Now')}
</button>
<a
href="https://github.com/PicPeak/picpeak/releases"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
{t('admin.updates.viewReleaseNotes', 'View Release Notes')}
<ExternalLink className="w-3 h-3 ml-1" />
</a>
</div>
</div>
</div>
<button
onClick={handleDismiss}
className="text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 p-1"
aria-label={t('common.close', 'Close')}
>
<X className="w-4 h-4" />
</button>
</div>
{/* Update Instructions Dialog */}
<UpdateInstructionsDialog
isOpen={showInstructions}
onClose={() => setShowInstructions(false)}
targetVersion={updateInfo?.latest?.forChannel}
/>
</div>
);
};
@@ -0,0 +1,227 @@
/**
* VAT-codes manager — the single home for VAT codes + the rate→code /
* treatment→code maps (Settings → Accounting). Relocated from the
* Chart-of-accounts page so all VAT config lives in one place.
*
* NOTE: ledgerService.updateSettings is a PARTIAL merge, so this component saves
* ONLY the two map keys — the Chart-of-accounts page saves only its account
* keys, and the two never overwrite each other.
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Plus, Pencil, Trash2 } from 'lucide-react';
import { Button, Card, CardContent, Input, Loading } from '../common';
import {
ledgerService, type LedgerAccount, type VatCode, type VatDirection, type LedgerSettings,
} from '../../services/ledger.service';
import { useMutationWithToast } from '../../hooks';
const labelCls = 'block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1';
const selectCls = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm';
const TAX_TREATMENTS = ['domestic', 'reverse_charge_service', 'foreign_vat_non_reclaimable', 'import_goods'];
// Mirror backend ledgerService.rateKey so the map keys we write match the
// lookup at export time (`outputVatMap[rateKey(inv.vat_rate)]`). 8.10 → '8.1',
// 0 → '0', 19 → '19'. Keeping these in sync is what lets a user retype their
// codes to local rates and have the revenue-rate rows follow automatically.
const rateKey = (rate: number | string): string => {
const n = Number(rate);
if (!Number.isFinite(n)) return '0';
return String(Number(n.toFixed(2)));
};
const VatModal: React.FC<{ vat?: VatCode; accounts: LedgerAccount[]; onClose: () => void; onDone: () => void }> = ({ vat, accounts, onClose, onDone }) => {
const { t } = useTranslation();
const isEdit = !!vat;
const [code, setCode] = useState(vat?.code ?? '');
const [name, setName] = useState(vat?.name ?? '');
const [rate, setRate] = useState<string>(vat ? String(vat.rate) : '8.1');
const [direction, setDirection] = useState<VatDirection>(vat?.direction ?? 'input');
const [accountId, setAccountId] = useState<number | ''>(vat?.account_id ?? '');
const save = useMutationWithToast({
mutationFn: () => {
const payload = { code, name, rate: Number(rate) || 0, direction, accountId: accountId === '' ? null : Number(accountId) };
return isEdit ? ledgerService.updateVatCode(vat!.id, payload) : ledgerService.createVatCode(payload);
},
successMessage: t('common.saved', 'Saved.'),
onSuccess: () => onDone(),
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
});
return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4">
<div className="mt-20 w-full max-w-sm rounded-xl bg-white dark:bg-neutral-900 shadow-xl">
<div className="flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700 px-5 py-3">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{isEdit ? t('ledger.vat.editTitle', 'Edit VAT code') : t('ledger.vat.addTitle', 'Add VAT code')}</h2>
<button onClick={onClose} className="text-neutral-400 hover:text-neutral-600"><X className="w-5 h-5" /></button>
</div>
<div className="px-5 py-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div><label className={labelCls}>{t('ledger.vat.code', 'Code')}</label><Input value={code} onChange={(e) => setCode(e.target.value)} placeholder="VST81" /></div>
<div><label className={labelCls}>{t('ledger.vat.rate', 'Rate %')}</label><Input value={rate} onChange={(e) => setRate(e.target.value)} inputMode="decimal" /></div>
</div>
<div><label className={labelCls}>{t('ledger.vat.name', 'Name')}</label><Input value={name} onChange={(e) => setName(e.target.value)} /></div>
<div><label className={labelCls}>{t('ledger.vat.direction', 'Direction')}</label>
<select value={direction} onChange={(e) => setDirection(e.target.value as VatDirection)} className={selectCls}>
<option value="input">{t('ledger.vatDirection.input', 'Input (Vorsteuer)')}</option>
<option value="output">{t('ledger.vatDirection.output', 'Output (Umsatzsteuer)')}</option>
</select>
</div>
<div><label className={labelCls}>{t('ledger.vat.account', 'VAT account')}</label>
<select value={accountId} onChange={(e) => setAccountId(e.target.value ? Number(e.target.value) : '')} className={selectCls}>
<option value="">{t('ledger.vat.noAccount', '— none —')}</option>
{accounts.map((a) => <option key={a.id} value={a.id}>{a.number} · {a.name}</option>)}
</select>
</div>
</div>
<div className="flex justify-end gap-2 border-t border-neutral-200 dark:border-neutral-700 px-5 py-3">
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button onClick={() => save.mutate()} disabled={save.isPending || !code || !name}>{save.isPending ? t('common.saving', 'Saving…') : t('common.save', 'Save')}</Button>
</div>
</div>
</div>
);
};
export const VatCodesManager: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const [vatModal, setVatModal] = useState<{ vat?: VatCode } | null>(null);
const { data: accounts } = useQuery({ queryKey: ['ledger-accounts'], queryFn: () => ledgerService.listAccounts() });
const { data: vatCodes, isLoading: lv } = useQuery({ queryKey: ['ledger-vat-codes'], queryFn: () => ledgerService.listVatCodes() });
const { data: mappings, isLoading: lm } = useQuery({ queryKey: ['ledger-mappings'], queryFn: () => ledgerService.getMappings() });
// Local copy of ONLY the VAT maps (the account keys stay on the CoA page).
const [maps, setMaps] = useState<Pick<LedgerSettings, 'ledger_vat_map' | 'ledger_output_vat_map'>>({});
useEffect(() => {
if (mappings?.settings) {
setMaps({
ledger_vat_map: mappings.settings.ledger_vat_map,
ledger_output_vat_map: mappings.settings.ledger_output_vat_map,
});
}
}, [mappings?.settings]);
const inputVat = useMemo(() => (vatCodes ?? []).filter((v) => v.direction === 'input'), [vatCodes]);
const outputVat = useMemo(() => (vatCodes ?? []).filter((v) => v.direction === 'output'), [vatCodes]);
// Revenue-rate rows are DATA-DRIVEN: the distinct rates of the output codes
// (first-seen order), not a hardcoded Swiss list. Retype a code to a local
// rate (e.g. 19) and its row appears here automatically; remove the last code
// at a rate and that row drops. Seeds (8.1/2.6/3.8/0) just produce the same
// four rows they did before.
const outputRates = useMemo(() => {
const seen = new Set<string>();
const rates: string[] = [];
for (const v of outputVat) {
const k = rateKey(v.rate);
if (!seen.has(k)) { seen.add(k); rates.push(k); }
}
return rates;
}, [outputVat]);
const refetch = () => { qc.invalidateQueries({ queryKey: ['ledger-vat-codes'] }); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); };
const delVat = useMutationWithToast({
mutationFn: (id: number) => ledgerService.deleteVatCode(id),
successMessage: t('common.deleted', 'Deleted.'),
onSuccess: () => refetch(),
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
});
// PARTIAL save — only the two map keys, never the account keys.
const saveMaps = useMutationWithToast({
mutationFn: () => ledgerService.updateSettings({
ledger_vat_map: maps.ledger_vat_map || {},
ledger_output_vat_map: maps.ledger_output_vat_map || {},
}),
successMessage: t('ledger.settingsSaved', 'Mappings saved.'),
invalidateKeys: [['ledger-mappings']],
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
});
const setVatMap = (tt: string, code: string) => setMaps((s) => ({ ...s, ledger_vat_map: { ...(s.ledger_vat_map || {}), [tt]: code } }));
const setOutputVatMap = (rate: string, code: string) => setMaps((s) => ({ ...s, ledger_output_vat_map: { ...(s.ledger_output_vat_map || {}), [rate]: code } }));
if (lv || lm) return <Loading />;
return (
<div className="space-y-6">
{/* VAT codes table */}
<Card><CardContent className="p-5">
<div className="flex items-center justify-between mb-3">
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{t('ledger.vatCodes.title', 'VAT codes')}</h2>
<Button size="sm" onClick={() => setVatModal({})}><Plus className="w-4 h-4 mr-1" /> {t('ledger.vat.addTitle', 'Add VAT code')}</Button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-neutral-700">
<tr>
<th className="py-1.5 pr-3 font-medium">{t('ledger.vat.code', 'Code')}</th>
<th className="py-1.5 pr-3 font-medium">{t('ledger.vat.name', 'Name')}</th>
<th className="py-1.5 pr-3 font-medium text-right">{t('ledger.vat.rate', 'Rate %')}</th>
<th className="py-1.5 pr-3 font-medium">{t('ledger.vat.direction', 'Direction')}</th>
<th className="py-1.5 pr-3 font-medium text-right">{t('common.actions', 'Actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-100 dark:divide-neutral-800">
{(vatCodes ?? []).map((v) => (
<tr key={v.id} className={v.active ? '' : 'opacity-50'}>
<td className="py-1.5 pr-3 font-medium text-neutral-900 dark:text-neutral-100">{v.code}</td>
<td className="py-1.5 pr-3 text-neutral-800 dark:text-neutral-200">{v.name}</td>
<td className="py-1.5 pr-3 text-right tabular-nums text-neutral-700 dark:text-neutral-300">{Number(v.rate).toFixed(1)}</td>
<td className="py-1.5 pr-3 text-neutral-500 dark:text-neutral-400">{t(`ledger.vatDirection.${v.direction}`, v.direction)}</td>
<td className="py-1.5 pr-3">
<div className="flex items-center justify-end gap-1">
<button onClick={() => setVatModal({ vat: v })} className="p-1 text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200"><Pencil className="w-4 h-4" /></button>
<button onClick={() => { if (window.confirm(t('ledger.vat.confirmDelete', 'Delete this VAT code?') as string)) delVat.mutate(v.id); }} className="p-1 text-neutral-400 hover:text-red-600"><Trash2 className="w-4 h-4" /></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent></Card>
{/* Rate→code + treatment→code maps */}
<Card><CardContent className="p-5">
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('ledger.outputVatMap.title', 'VAT code by revenue rate')}</h3>
{outputRates.length === 0 ? (
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('ledger.outputVatMap.empty', 'Add output VAT codes above to configure a code per revenue rate.')}</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{outputRates.map((rate) => (
<div key={rate}>
<label className={labelCls}>{rate}%</label>
<select value={maps.ledger_output_vat_map?.[rate] ?? ''} onChange={(e) => setOutputVatMap(rate, e.target.value)} className={selectCls}>
<option value="">{t('ledger.defaults.none', '— none —')}</option>
{outputVat.filter((v) => rateKey(v.rate) === rate).map((v) => <option key={v.id} value={v.code}>{v.code}</option>)}
</select>
</div>
))}
</div>
)}
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mt-5 mb-2">{t('ledger.vatMap.title', 'VAT code by tax treatment (costs)')}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{TAX_TREATMENTS.map((tt) => (
<div key={tt}>
<label className={labelCls}>{t(`accounting.taxTreatment.${tt}`, tt)}</label>
<select value={maps.ledger_vat_map?.[tt] ?? ''} onChange={(e) => setVatMap(tt, e.target.value)} className={selectCls}>
<option value="">{t('ledger.defaults.none', '— none —')}</option>
{inputVat.map((v) => <option key={v.id} value={v.code}>{v.code} · {v.name}</option>)}
</select>
</div>
))}
</div>
<div className="mt-4 flex justify-end">
<Button onClick={() => saveMaps.mutate()} disabled={saveMaps.isPending}>{saveMaps.isPending ? t('common.saving', 'Saving…') : t('ledger.saveDefaults', 'Save mappings')}</Button>
</div>
</CardContent></Card>
{vatModal && <VatModal vat={vatModal.vat} accounts={accounts ?? []} onClose={() => setVatModal(null)} onDone={() => { setVatModal(null); refetch(); }} />}
</div>
);
};
@@ -0,0 +1,80 @@
/**
* VAT-rate picker for the invoice/quote editors. A dropdown whose ONLY options
* are the configured OUTPUT VAT codes (Settings → Accounting) — there is no
* free-text custom rate; to use a different rate, add a VAT code in Accounting.
* Controlled by `(rate, code)`: selecting a code emits its rate + code string
* (snapshotted on the document for the accounting export). Reads the un-gated
* /admin/vat-codes endpoint so it works even when the accounting feature is off.
*
* Legacy preservation: when editing a document whose stored rate/code isn't an
* accounting code anymore (an old invoice, or a deleted code), that value is
* shown as a read-only "(not configured)" option so it stays selected and is
* never silently changed — issued invoices are immutable. The admin can still
* switch it to a current code.
*/
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { vatCodesService, type VatCodeOption } from '../../services/vatCodes.service';
const LEGACY = '__legacy__';
const selectCls =
'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500';
interface Props {
rate: number;
code: string | null;
onChange: (rate: number, code: string | null) => void;
label?: string;
disabled?: boolean;
}
export const VatRateSelect: React.FC<Props> = ({ rate, code, onChange, label, disabled }) => {
const { t } = useTranslation();
const { data: codes = [] } = useQuery({
queryKey: ['vat-codes', 'output'],
queryFn: () => vatCodesService.listOutput(),
staleTime: 5 * 60 * 1000,
});
// Selected option: prefer the snapshotted code; else a code whose rate matches
// (legacy rows / no code stored) — BUT only when that rate is unambiguous. If
// two configured codes share the rate (e.g. two 8.1% codes), rate-matching
// could silently swap one for the other on the next save, so fall through to
// the legacy "(not configured)" option and make the admin pick explicitly
// (PR #636 review #4).
const matched: VatCodeOption | undefined =
(code ? codes.find((c) => c.code === code) : undefined)
|| (!code && codes.filter((c) => Number(c.rate) === Number(rate)).length === 1
? codes.find((c) => Number(c.rate) === Number(rate)) : undefined);
const showLegacy = !matched;
return (
<div>
{label && (
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">{label}</label>
)}
<select
className={selectCls}
disabled={disabled}
value={matched ? String(matched.id) : LEGACY}
onChange={(e) => {
if (e.target.value === LEGACY) { onChange(rate, code); return; } // keep the legacy value
const c = codes.find((x) => String(x.id) === e.target.value);
if (c) onChange(Number(c.rate), c.code);
}}
>
{showLegacy && (
<option value={LEGACY}>
{t('ledger.vat.legacyRate', '{{rate}}% (not configured)', { rate: Number(rate || 0).toFixed(1) })}
</option>
)}
{codes.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name} ({Number(c.rate).toFixed(1)}%)
</option>
))}
</select>
</div>
);
};
@@ -0,0 +1,149 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Info, ArrowUpCircle } from 'lucide-react';
import { api } from '../../config/api';
import { githubReleaseUrl as releaseUrl } from '../../utils/githubReleaseUrl';
import {
setDismissedVersion,
shouldShowUpdateChip,
} from '../../utils/updateDismissal';
import { UpdateAvailableModal } from './UpdateAvailableModal';
import packageJson from '../../../package.json';
// Frontend version from package.json
const FRONTEND_VERSION = packageJson.version;
interface SystemVersion {
backend: string;
frontend: string;
node: string;
environment: string;
channel?: 'stable' | 'beta';
}
interface UpdateInfo {
enabled: boolean;
updateAvailable: boolean;
current?: string;
latest?: {
forChannel: string;
};
}
async function fetchSystemVersion(): Promise<SystemVersion> {
const response = await api.get<SystemVersion>('/admin/system/version');
return response.data;
}
async function fetchUpdateInfo(): Promise<UpdateInfo> {
const response = await api.get<UpdateInfo>('/admin/system/updates');
return response.data;
}
export const VersionInfo: React.FC = () => {
const { t } = useTranslation();
const [modalOpen, setModalOpen] = useState(false);
// Re-render trigger so dismissing in the modal immediately hides the
// chip without waiting for the next route change. State value is
// irrelevant; we just bump it.
const [dismissBump, setDismissBump] = useState(0);
const { data: versionInfo } = useQuery({
queryKey: ['system-version'],
queryFn: fetchSystemVersion,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const { data: updateInfo } = useQuery({
queryKey: ['update-check'],
queryFn: fetchUpdateInfo,
staleTime: 60 * 60 * 1000, // 1 hour
retry: false
});
const channelBadge = versionInfo?.channel === 'beta' ? (
<span className="ml-1 px-1.5 py-0.5 text-xs bg-amber-100 text-amber-700 rounded">
{t('admin.updates.beta', 'BETA')}
</span>
) : null;
const latestVersion = updateInfo?.latest?.forChannel;
const currentVersion = updateInfo?.current || versionInfo?.backend || FRONTEND_VERSION;
// `dismissBump` referenced here so React re-runs the dismissal check
// immediately after the modal calls handleDismiss; the value itself
// is unused. Voiding it keeps lint happy without an eslint-disable.
void dismissBump;
const showUpdateChip = updateInfo?.enabled
&& updateInfo?.updateAvailable
&& !!latestVersion
&& shouldShowUpdateChip(latestVersion);
const handleDismiss = (version: string) => {
setDismissedVersion(version);
setDismissBump((n) => n + 1);
};
return (
<>
<div className="px-4 py-3 border-t border-neutral-200">
<div className="flex items-center gap-2 text-xs text-neutral-600">
<Info className="w-3 h-3" />
<span className="font-medium">{t('admin.version')}</span>
{channelBadge}
</div>
<div className="mt-1 space-y-0.5 text-xs text-neutral-500">
<div>
Frontend:{' '}
<a
href={releaseUrl(FRONTEND_VERSION)}
target="_blank"
rel="noopener noreferrer"
className="text-neutral-500 hover:text-neutral-700 hover:underline"
title={t('admin.viewReleaseNotes', 'View release notes on GitHub')}
>
v{FRONTEND_VERSION}
</a>
</div>
{versionInfo && (
<div>
Backend:{' '}
<a
href={releaseUrl(versionInfo.backend)}
target="_blank"
rel="noopener noreferrer"
className="text-neutral-500 hover:text-neutral-700 hover:underline"
title={t('admin.viewReleaseNotes', 'View release notes on GitHub')}
>
v{versionInfo.backend}
</a>
</div>
)}
</div>
{showUpdateChip && (
<button
type="button"
onClick={() => setModalOpen(true)}
className="mt-2 flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
title={t('admin.updates.viewDetails', 'View release notes and upgrade instructions')}
>
<ArrowUpCircle className="w-3 h-3" />
<span>
{t('admin.updates.updateAvailableShort', 'v{{version}} available', {
version: latestVersion
})}
</span>
</button>
)}
</div>
{modalOpen && latestVersion && (
<UpdateAvailableModal
currentVersion={currentVersion}
latestVersion={latestVersion}
onClose={() => setModalOpen(false)}
onDismiss={handleDismiss}
/>
)}
</>
);
};
@@ -0,0 +1,78 @@
/**
* <WatermarkSourcePicker>
*
* Visible picker for the slideshow watermark logo. Instead of a blind dropdown
* the admin sees each branding asset (light logo, dark-mode logo, favicon) and
* the event's own logo, and clicks the one to overlay. Previews render on a
* transparency checkerboard so both light and dark marks are visible.
*
* URLs come from the public settings (branding assets) + an optional per-event
* logo. A source with no configured logo still selects, but shows a "not set"
* placeholder so the admin knows to upload one.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { buildResourceUrl } from '../../utils/url';
import type { SlideshowWatermarkSource } from '../../services/slideshow.service';
export interface WatermarkSourcePickerProps {
value: SlideshowWatermarkSource;
onChange: (s: SlideshowWatermarkSource) => void;
/** Per-event hero logo, previewed for the 'event' source when available. */
eventLogoUrl?: string | null;
}
// Classic transparency checkerboard so white and dark logos both show up.
const CHECKER: React.CSSProperties = {
backgroundImage:
'linear-gradient(45deg, #c8c8c8 25%, transparent 25%), linear-gradient(-45deg, #c8c8c8 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #c8c8c8 75%), linear-gradient(-45deg, transparent 75%, #c8c8c8 75%)',
backgroundSize: '12px 12px',
backgroundPosition: '0 0, 0 6px, 6px -6px, -6px 0',
backgroundColor: '#f0f0f0',
};
export const WatermarkSourcePicker: React.FC<WatermarkSourcePickerProps> = ({ value, onChange, eventLogoUrl }) => {
const { t } = useTranslation();
const { data: ps } = usePublicSettings();
const options: Array<{ key: SlideshowWatermarkSource; label: string; url?: string | null }> = [
{ key: 'logo', label: t('slideshow.watermarkSource.logo', 'Light logo'), url: ps?.branding_logo_url },
{ key: 'logo_dark', label: t('slideshow.watermarkSource.logo_dark', 'Dark-mode logo'), url: ps?.branding_logo_url_dark },
{ key: 'favicon', label: t('slideshow.watermarkSource.favicon', 'Favicon'), url: ps?.branding_favicon_url },
{ key: 'event', label: t('slideshow.watermarkSource.event', 'Event logo'), url: eventLogoUrl },
];
return (
<div className="flex flex-wrap gap-2">
{options.map((opt) => {
const selected = value === opt.key;
const resolved = opt.url ? buildResourceUrl(opt.url) : null;
return (
<button
key={opt.key}
type="button"
onClick={() => onChange(opt.key)}
title={opt.label}
className={`w-24 rounded-lg border-2 overflow-hidden transition-all text-center ${
selected
? 'tile-selected'
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
}`}
>
<div className="h-14 flex items-center justify-center" style={CHECKER}>
{resolved ? (
<img src={resolved} alt="" className="max-h-12 max-w-[80%] object-contain" draggable={false} />
) : (
<span className="text-[10px] text-neutral-500">{t('slideshow.watermarkSource.notSet', 'Not set')}</span>
)}
</div>
<div className="text-xs text-neutral-700 dark:text-neutral-300 py-1 px-1 truncate">{opt.label}</div>
</button>
);
})}
</div>
);
};
export default WatermarkSourcePicker;
@@ -0,0 +1,72 @@
import React from 'react';
import { HelpCircle } from 'lucide-react';
import DOMPurify from 'dompurify';
interface WelcomeMessageEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
}
export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
value,
onChange,
placeholder,
rows = 6
}) => {
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value);
};
// Convert newlines to <br> tags for preview with XSS sanitization
const getPreviewHtml = () => {
// First sanitize the input to remove any malicious content
const sanitized = DOMPurify.sanitize(value, {
ALLOWED_TAGS: [], // Strip all HTML tags, only allow text
ALLOWED_ATTR: [],
KEEP_CONTENT: true
});
// Then convert newlines to <br> tags
return sanitized
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.join('<br />');
};
return (
<div className="space-y-2">
<div className="relative">
<textarea
value={value}
onChange={handleChange}
placeholder={placeholder}
rows={rows}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 placeholder-neutral-400 dark:placeholder-neutral-500 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark transition-colors resize-none font-mono text-sm"
/>
<div className="absolute top-2 right-2 text-neutral-400" title="Line breaks will be preserved in emails">
<HelpCircle className="w-4 h-4" aria-hidden="true" />
</div>
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
Tip: Press Enter to create a new line. Each line will appear as a separate paragraph in emails.
</div>
{value && (
<div className="mt-4">
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Preview:</p>
<div className="p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700">
<div
className="text-sm text-neutral-700 dark:text-neutral-300 whitespace-pre-wrap"
dangerouslySetInnerHTML={{ __html: getPreviewHtml() }}
/>
</div>
</div>
)}
</div>
);
};
WelcomeMessageEditor.displayName = 'WelcomeMessageEditor';
@@ -0,0 +1,131 @@
/**
* After-update "What's New" — a dismissible green bar that expands into a
* modal. Driven by GET /admin/system/updates/whatsnew, which returns the
* curated highlights for every version this instance moved through since it
* last acknowledged one. Dismiss (X or "Got it") advances the per-instance
* marker via POST .../seen, so it stops showing for everyone.
*
* Bullets are written once in the release CI (GitHub Models) and read from
* the GitHub release notes — there's no AI at runtime. Releases without a
* curated block fall back to their changelog "Features".
*/
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Sparkles, X, ExternalLink, ChevronRight } from 'lucide-react';
import { adminService } from '../../services/admin.service';
import { useModal } from '../../hooks';
export const WhatsNewBanner: React.FC = () => {
const { t } = useTranslation();
const qc = useQueryClient();
const detailsModal = useModal();
const [hidden, setHidden] = useState(false);
const { data } = useQuery({
queryKey: ['whatsnew'],
queryFn: () => adminService.getWhatsNew(),
staleTime: 5 * 60 * 1000,
});
const seen = useMutation({
mutationFn: () => adminService.markWhatsNewSeen(),
onSuccess: () => {
setHidden(true);
detailsModal.close();
qc.invalidateQueries({ queryKey: ['whatsnew'] });
},
});
if (hidden || !data?.hasNews || !data.versions?.length) return null;
// Inline teaser on the bar: the first few bullets across all new versions.
const teaser = data.versions.flatMap((v) => v.bullets).slice(0, 3);
return (
<>
<div className="bg-green-50 dark:bg-green-900/30 border-l-4 border-green-500 p-4 mb-4 rounded-r-lg">
<div className="flex items-start justify-between">
<div className="flex items-start">
<Sparkles className="w-5 h-5 text-green-600 mt-0.5 mr-3 flex-shrink-0" />
<div>
<h4 className="text-sm font-semibold text-green-800 dark:text-green-200">
{t('admin.whatsnew.title', "What's new in {{version}}", { version: data.toVersion })}
</h4>
<ul className="text-sm text-green-700 dark:text-green-300 mt-1 list-disc list-inside">
{teaser.map((b, i) => <li key={i}>{b}</li>)}
</ul>
<div className="mt-2">
<button
onClick={detailsModal.open}
className="inline-flex items-center text-xs font-medium text-white bg-green-600 hover:bg-green-700 px-3 py-1.5 rounded-md transition-colors"
>
{t('admin.whatsnew.viewAll', "What's new")}
<ChevronRight className="w-3 h-3 ml-1" />
</button>
</div>
</div>
</div>
<button
onClick={() => seen.mutate()}
className="text-green-500 hover:text-green-700 dark:hover:text-green-300 p-1"
aria-label={t('common.close', 'Close')}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{detailsModal.isOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={detailsModal.close}
>
<div
className="bg-white dark:bg-neutral-800 rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] overflow-auto p-6"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2 text-neutral-900 dark:text-neutral-100">
<Sparkles className="w-5 h-5 text-green-600" />
{t('admin.whatsnew.modalTitle', "What's new")}
</h3>
<button onClick={detailsModal.close} className="p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
{data.versions.map((v) => (
<div key={v.version}>
<div className="flex items-center justify-between gap-3">
<h4 className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{v.name || `v${v.version}`}</h4>
<a
href={v.htmlUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 dark:text-blue-400 inline-flex items-center whitespace-nowrap"
>
{t('admin.whatsnew.fullChangelog', 'Full changelog')}
<ExternalLink className="w-3 h-3 ml-1" />
</a>
</div>
<ul className="mt-1 list-disc list-inside text-sm text-neutral-700 dark:text-neutral-300">
{v.bullets.map((b, i) => <li key={i}>{b}</li>)}
</ul>
</div>
))}
</div>
<div className="mt-6 flex justify-end">
<button
onClick={() => seen.mutate()}
className="text-sm font-medium text-white bg-green-600 hover:bg-green-700 px-4 py-2 rounded-md transition-colors"
>
{t('admin.whatsnew.gotIt', 'Got it')}
</button>
</div>
</div>
</div>
)}
</>
);
};
@@ -0,0 +1,383 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
Plus,
Trash2,
Shield,
AlertTriangle,
XCircle,
Edit2,
Save,
X,
Search
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Card, Button, Input, Loading } from '../common';
import { feedbackService } from '../../services/feedback.service';
import { useMutationWithToast } from '../../hooks';
interface WordFilter {
id: number;
word: string;
severity: 'low' | 'moderate' | 'high' | 'block';
is_active: boolean;
created_at: string;
updated_at: string;
}
export const WordFilterManager: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [newWord, setNewWord] = useState('');
const [newSeverity, setNewSeverity] = useState<'low' | 'moderate' | 'high' | 'block'>('moderate');
const [searchTerm, setSearchTerm] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editWord, setEditWord] = useState('');
const [editSeverity, setEditSeverity] = useState<'low' | 'moderate' | 'high' | 'block'>('moderate');
// Fetch word filters
const { data: filters = [], isLoading } = useQuery({
queryKey: ['word-filters'],
queryFn: () => feedbackService.getWordFilters()
});
// Add word filter mutation
const addMutation = useMutation({
mutationFn: (data: { word: string; severity: string }) =>
feedbackService.addWordFilter(data.word, data.severity),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['word-filters'] });
toast.success(t('settings.moderation.filterAdded', 'Word filter added successfully'));
setNewWord('');
setNewSeverity('moderate');
},
onError: (error: any) => {
if (error.response?.status === 409) {
toast.error(t('settings.moderation.filterExists', 'This word filter already exists'));
} else {
toast.error(t('settings.moderation.addError', 'Failed to add word filter'));
}
}
});
// Update word filter mutation
const updateMutation = useMutationWithToast({
mutationFn: ({ id, updates }: { id: number; updates: Partial<WordFilter> }) =>
feedbackService.updateWordFilter(id, updates),
invalidateKeys: [['word-filters']],
successMessage: t('settings.moderation.filterUpdated', 'Word filter updated successfully'),
onSuccess: () => {
setEditingId(null);
},
errorMessage: () => t('settings.moderation.updateError', 'Failed to update word filter')
});
// Delete word filter mutation
const deleteMutation = useMutationWithToast({
mutationFn: (id: number) => feedbackService.deleteWordFilter(id),
invalidateKeys: [['word-filters']],
successMessage: t('settings.moderation.filterDeleted', 'Word filter deleted successfully'),
errorMessage: () => t('settings.moderation.deleteError', 'Failed to delete word filter')
});
const handleAdd = () => {
if (!newWord.trim()) {
toast.error(t('settings.moderation.wordRequired', 'Please enter a word to filter'));
return;
}
addMutation.mutate({ word: newWord.trim(), severity: newSeverity });
};
const handleEdit = (filter: WordFilter) => {
setEditingId(filter.id);
setEditWord(filter.word);
setEditSeverity(filter.severity);
};
const handleSaveEdit = () => {
if (!editWord.trim()) {
toast.error(t('settings.moderation.wordRequired', 'Please enter a word to filter'));
return;
}
if (editingId) {
updateMutation.mutate({
id: editingId,
updates: { word: editWord.trim(), severity: editSeverity }
});
}
};
const handleCancelEdit = () => {
setEditingId(null);
setEditWord('');
setEditSeverity('moderate');
};
const handleToggleActive = (filter: WordFilter) => {
updateMutation.mutate({
id: filter.id,
updates: { is_active: !filter.is_active }
});
};
const handleDelete = (id: number) => {
if (confirm(t('settings.moderation.confirmDelete', 'Are you sure you want to delete this word filter?'))) {
deleteMutation.mutate(id);
}
};
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'low':
return <Shield className="w-4 h-4 text-blue-500" />;
case 'moderate':
return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
case 'high':
return <XCircle className="w-4 h-4 text-orange-500" />;
case 'block':
return <XCircle className="w-4 h-4 text-red-600" />;
default:
return <Shield className="w-4 h-4 text-gray-500" />;
}
};
const getSeverityBadgeClass = (severity: string) => {
switch (severity) {
case 'low':
return 'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300';
case 'moderate':
return 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300';
case 'high':
return 'bg-orange-100 dark:bg-orange-900/40 text-orange-800 dark:text-orange-300';
case 'block':
return 'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300';
default:
return 'bg-neutral-100 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-300';
}
};
const filteredFilters = filters.filter((filter: WordFilter) =>
filter.word.toLowerCase().includes(searchTerm.toLowerCase())
);
if (isLoading) {
return (
<Card>
<div className="p-6">
<Loading text={t('settings.moderation.loading', 'Loading word filters...')} />
</div>
</Card>
);
}
return (
<>
<Card>
<div className="p-6">
<div className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
{t('settings.moderation.wordFilters', 'Word Filters')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t('settings.moderation.description', 'Manage words that should be filtered or blocked in comments')}
</p>
</div>
{/* Add new filter */}
<div className="mb-6 p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 dark:text-neutral-100 mb-3">
{t('settings.moderation.addFilter', 'Add New Filter')}
</h3>
<div className="flex gap-3">
<Input
type="text"
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
placeholder={t('settings.moderation.enterWord', 'Enter word to filter')}
className="flex-1"
onKeyPress={(e) => e.key === 'Enter' && handleAdd()}
/>
<select
value={newSeverity}
onChange={(e) => setNewSeverity(e.target.value as any)}
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="low">{t('settings.moderation.severityLow', 'Low')}</option>
<option value="moderate">{t('settings.moderation.severityModerate', 'Moderate')}</option>
<option value="high">{t('settings.moderation.severityHigh', 'High')}</option>
<option value="block">{t('settings.moderation.severityBlock', 'Block')}</option>
</select>
<Button
variant="primary"
leftIcon={<Plus className="w-4 h-4" />}
onClick={handleAdd}
isLoading={addMutation.isPending}
>
{t('common.add', 'Add')}
</Button>
</div>
</div>
{/* Search */}
<div className="mb-4">
<Input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('settings.moderation.searchFilters', 'Search filters...')}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
/>
</div>
{/* Filters list */}
<div className="space-y-2">
{filteredFilters.length === 0 ? (
<div className="text-center py-8 text-neutral-500 dark:text-neutral-400">
{searchTerm ?
t('settings.moderation.noMatchingFilters', 'No matching filters found') :
t('settings.moderation.noFilters', 'No word filters configured yet')
}
</div>
) : (
filteredFilters.map((filter: WordFilter) => (
<div
key={filter.id}
className={`flex items-center justify-between p-3 rounded-lg border ${
filter.is_active ? 'border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800' : 'border-neutral-100 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900 opacity-60'
}`}
>
{editingId === filter.id ? (
<>
<div className="flex items-center gap-3 flex-1">
<Input
type="text"
value={editWord}
onChange={(e) => setEditWord(e.target.value)}
className="flex-1 max-w-xs"
/>
<select
value={editSeverity}
onChange={(e) => setEditSeverity(e.target.value as any)}
className="px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="low">{t('settings.moderation.severityLow', 'Low')}</option>
<option value="moderate">{t('settings.moderation.severityModerate', 'Moderate')}</option>
<option value="high">{t('settings.moderation.severityHigh', 'High')}</option>
<option value="block">{t('settings.moderation.severityBlock', 'Block')}</option>
</select>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
leftIcon={<Save className="w-4 h-4" />}
onClick={handleSaveEdit}
isLoading={updateMutation.isPending}
>
{t('common.save', 'Save')}
</Button>
<Button
size="sm"
variant="ghost"
leftIcon={<X className="w-4 h-4" />}
onClick={handleCancelEdit}
>
{t('common.cancel', 'Cancel')}
</Button>
</div>
</>
) : (
<>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={filter.is_active}
onChange={() => handleToggleActive(filter)}
className="w-4 h-4 text-accent rounded focus:ring-primary-500"
/>
<span className="font-medium text-neutral-900 dark:text-neutral-100">{filter.word}</span>
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getSeverityBadgeClass(filter.severity)}`}>
{getSeverityIcon(filter.severity)}
{filter.severity}
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
leftIcon={<Edit2 className="w-4 h-4" />}
onClick={() => handleEdit(filter)}
>
{t('common.edit', 'Edit')}
</Button>
<Button
size="sm"
variant="ghost"
leftIcon={<Trash2 className="w-4 h-4" />}
onClick={() => handleDelete(filter.id)}
isLoading={deleteMutation.isPending}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
{t('common.delete', 'Delete')}
</Button>
</div>
</>
)}
</div>
))
)}
</div>
</div>
</Card>
{/* Severity explanation */}
<Card>
<div className="p-6">
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3">
{t('settings.moderation.severityLevels', 'Severity Levels')}
</h3>
<div className="space-y-2 text-sm">
<div className="flex items-start gap-3">
{getSeverityIcon('low')}
<div>
<span className="font-medium text-neutral-900 dark:text-neutral-100">{t('settings.moderation.severityLow', 'Low')}: </span>
<span className="text-neutral-600 dark:text-neutral-400">
{t('settings.moderation.lowDescription', 'Word is flagged for review but not automatically blocked')}
</span>
</div>
</div>
<div className="flex items-start gap-3">
{getSeverityIcon('moderate')}
<div>
<span className="font-medium text-neutral-900 dark:text-neutral-100">{t('settings.moderation.severityModerate', 'Moderate')}: </span>
<span className="text-neutral-600 dark:text-neutral-400">
{t('settings.moderation.moderateDescription', 'Comment requires manual approval before being visible')}
</span>
</div>
</div>
<div className="flex items-start gap-3">
{getSeverityIcon('high')}
<div>
<span className="font-medium text-neutral-900 dark:text-neutral-100">{t('settings.moderation.severityHigh', 'High')}: </span>
<span className="text-neutral-600 dark:text-neutral-400">
{t('settings.moderation.highDescription', 'Comment is automatically hidden and requires admin review')}
</span>
</div>
</div>
<div className="flex items-start gap-3">
{getSeverityIcon('block')}
<div>
<span className="font-medium text-neutral-900 dark:text-neutral-100">{t('settings.moderation.severityBlock', 'Block')}: </span>
<span className="text-neutral-600 dark:text-neutral-400">
{t('settings.moderation.blockDescription', 'Comment is rejected immediately and cannot be submitted')}
</span>
</div>
</div>
</div>
</div>
</Card>
</>
);
};
@@ -0,0 +1,115 @@
/**
* Coverage for the Grid / List layout toggle on the admin event
* Photos tab. The toggle swaps the rendered layout (grid tiles vs.
* list rows) and persists the choice to localStorage via
* utils/photoViewPrefs, so it survives a remount. These tests pin both
* behaviours so a refactor can't silently drop the list view or its
* persistence.
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactElement } from 'react';
import { AdminPhotoGrid } from '../AdminPhotoGrid';
import type { AdminPhoto } from '../../../services/photos.service';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (_key: string, fallback?: any) =>
typeof fallback === 'string' ? fallback : _key,
i18n: { language: 'en' }
})
};
});
// AdminAuthenticatedImage fetches an authenticated blob; stub it to a
// plain img so the grid renders without a network layer.
vi.mock('../AdminAuthenticatedImage', () => ({
AdminAuthenticatedImage: ({ alt }: { alt: string }) => <img alt={alt} />
}));
vi.mock('../../../services/photos.service', () => ({
photosService: {
formatBytes: (n: number) => `${n} B`
}
}));
const renderWithQueryClient = (ui: ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
const photos: AdminPhoto[] = [
{
id: 1, filename: 'a.jpg', path: '/a.jpg', url: '/a.jpg', thumbnail_url: '/t/a.jpg',
type: 'photo', category_id: null, category_name: null, category_slug: null,
size: 1234, uploaded_at: '2026-01-01T00:00:00Z'
},
{
id: 2, filename: 'b.jpg', path: '/b.jpg', url: '/b.jpg', thumbnail_url: '/t/b.jpg',
type: 'photo', category_id: null, category_name: null, category_slug: null,
size: 5678, uploaded_at: '2026-01-02T00:00:00Z'
}
];
const renderGrid = () =>
renderWithQueryClient(
<AdminPhotoGrid
photos={photos}
eventId={42}
onPhotoClick={vi.fn()}
onPhotosDeleted={vi.fn()}
/>
);
describe('AdminPhotoGrid layout toggle', () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it('renders grid tiles by default', () => {
renderGrid();
expect(screen.getByTestId('admin-photo-tile-1')).toBeInTheDocument();
expect(screen.queryByTestId('admin-photo-row-1')).not.toBeInTheDocument();
});
it('does not write to localStorage on mount (only on user toggle)', () => {
renderGrid();
// Opening the tab must not persist the value it just read.
expect(localStorage.getItem('picpeak.adminPhotos.view')).toBeNull();
});
it('switches to list rows when the List toggle is clicked', async () => {
const user = userEvent.setup();
renderGrid();
await user.click(screen.getByRole('radio', { name: /list view/i }));
expect(screen.getByTestId('admin-photo-row-1')).toBeInTheDocument();
expect(screen.getByTestId('admin-photo-row-2')).toBeInTheDocument();
expect(screen.queryByTestId('admin-photo-tile-1')).not.toBeInTheDocument();
});
it('persists the chosen layout across a remount', async () => {
const user = userEvent.setup();
const { unmount } = renderGrid();
await user.click(screen.getByRole('radio', { name: /list view/i }));
expect(localStorage.getItem('picpeak.adminPhotos.view')).toBe('list');
unmount();
renderGrid();
expect(screen.getByTestId('admin-photo-row-1')).toBeInTheDocument();
expect(screen.queryByTestId('admin-photo-tile-1')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,152 @@
/**
* Coverage for the upload failure report — the "which files failed" list.
*
* Before this, a partial upload only showed a count ("some files failed"),
* and the backend's per-file `errors[]` were dropped entirely. These tests
* pin that every failure stage is named with its reason:
* - rejected: from the upload response's `errors: [{filename, error}]`
* - transfer: from a whole-chunk POST failure (the `catch` path)
* - processing: from useUploadProgress's `failedPhotos`
* plus the settle contract (hasFailures true/false) the modal relies on to
* decide whether to auto-close, and dismissal.
*/
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactElement } from 'react';
import { PhotoUpload } from '../PhotoUpload';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (_key: string, fallback?: any) => (typeof fallback === 'string' ? fallback : _key),
}),
};
});
vi.mock('react-toastify', () => ({
toast: { warning: vi.fn(), info: vi.fn(), error: vi.fn(), success: vi.fn() },
}));
const postMock = vi.fn();
vi.mock('../../../config/api', () => ({ api: { post: (...a: any[]) => postMock(...a), get: vi.fn() } }));
// Mutable processing aggregate, swapped per test. Returned only once photos
// are queued (uploadIds non-empty), mirroring the real hook flipping from
// "nothing to track" to "complete" — the transition that fires the settle
// effect.
const clean = () => ({
total: 0, pending: 0, processing: 0, complete: 0, failed: 0,
failedPhotos: [] as { id: number; filename: string; error: string | null }[],
isComplete: false, isReady: true,
});
const hoisted = vi.hoisted(() => ({ aggregate: null as any }));
vi.mock('../../../hooks/useUploadProgress', () => ({
useUploadProgress: (ids: string[]) => ({
snapshots: {},
error: null,
aggregate: ids && ids.length > 0
? hoisted.aggregate
: { total: 0, pending: 0, processing: 0, complete: 0, failed: 0, failedPhotos: [], isComplete: false, isReady: true },
}),
}));
vi.mock('../../../services/categories.service', () => ({
categoriesService: { getEventCategories: vi.fn().mockResolvedValue([]) },
}));
vi.mock('../../../services/settings.service', () => ({
settingsService: { getAllSettings: vi.fn().mockResolvedValue({}) },
}));
const renderWithClient = (ui: ReactElement) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
async function uploadFile(container: HTMLElement, user: ReturnType<typeof userEvent.setup>) {
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(fileInput, new File([new Uint8Array([1, 2, 3])], 'good-photo.png', { type: 'image/png' }));
await user.click(screen.getByRole('button', { name: /common\.upload/ }));
}
describe('PhotoUpload failure report', () => {
beforeEach(() => {
postMock.mockReset();
hoisted.aggregate = clean();
});
afterEach(() => vi.clearAllMocks());
it('names rejected + processing failures and settles with hasFailures', async () => {
postMock.mockResolvedValue({
data: { successCount: 1, count: 1, upload_id: 'u1', errors: [{ filename: 'too-big.png', error: 'File too large' }] },
});
hoisted.aggregate = {
total: 2, pending: 0, processing: 0, complete: 1, failed: 1,
failedPhotos: [{ id: 5, filename: 'corrupt.jpg', error: 'Unsupported format' }],
isComplete: true, isReady: true,
};
const onUploadSettled = vi.fn();
const user = userEvent.setup();
const { container } = renderWithClient(<PhotoUpload eventId={1} onUploadSettled={onUploadSettled} />);
await uploadFile(container, user);
const report = await screen.findByTestId('upload-failure-report');
expect(within(report).getByText('too-big.png')).toBeInTheDocument();
expect(within(report).getByText(/File too large/)).toBeInTheDocument();
expect(within(report).getByText('Rejected')).toBeInTheDocument();
expect(within(report).getByText('corrupt.jpg')).toBeInTheDocument();
expect(within(report).getByText('Processing failed')).toBeInTheDocument();
// Contract with the modal: the real component fires onUploadSettled and,
// because something failed, asks the host NOT to auto-close.
await waitFor(() => expect(onUploadSettled).toHaveBeenCalledWith({ hasFailures: true }));
});
it('reports a whole-chunk transfer failure by name', async () => {
postMock.mockRejectedValue({ response: { data: { error: 'Network error' } } });
const user = userEvent.setup();
const { container } = renderWithClient(<PhotoUpload eventId={1} />);
await uploadFile(container, user);
const report = await screen.findByTestId('upload-failure-report');
expect(within(report).getByText('good-photo.png')).toBeInTheDocument();
expect(within(report).getByText(/Network error/)).toBeInTheDocument();
expect(within(report).getByText('Transfer failed')).toBeInTheDocument();
});
it('settles clean when nothing fails, so the modal can auto-close', async () => {
postMock.mockResolvedValue({ data: { successCount: 1, count: 1, upload_id: 'u1', errors: [] } });
hoisted.aggregate = {
total: 1, pending: 0, processing: 0, complete: 1, failed: 0,
failedPhotos: [], isComplete: true, isReady: true,
};
const onUploadSettled = vi.fn();
const user = userEvent.setup();
const { container } = renderWithClient(<PhotoUpload eventId={1} onUploadSettled={onUploadSettled} />);
await uploadFile(container, user);
await waitFor(() => expect(onUploadSettled).toHaveBeenCalledWith({ hasFailures: false }));
expect(screen.queryByTestId('upload-failure-report')).not.toBeInTheDocument();
});
it('can be dismissed', async () => {
postMock.mockResolvedValue({
data: { successCount: 0, count: 0, errors: [{ filename: 'too-big.png', error: 'File too large' }] },
});
const user = userEvent.setup();
const { container } = renderWithClient(<PhotoUpload eventId={1} />);
await uploadFile(container, user);
const report = await screen.findByTestId('upload-failure-report');
await user.click(within(report).getByRole('button', { name: /Dismiss/i }));
expect(screen.queryByTestId('upload-failure-report')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,48 @@
/**
* The upload modal must keep itself open when an upload partially fails, so
* the failure report stays visible; a clean upload still auto-closes. We stub
* PhotoUpload with buttons that fire its onUploadSettled callback both ways.
*/
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { PhotoUploadModal } from '../PhotoUploadModal';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({ t: (_k: string, fb?: any) => (typeof fb === 'string' ? fb : _k) }),
};
});
// Stub PhotoUpload: expose buttons that settle clean vs. with failures.
vi.mock('../PhotoUpload', () => ({
PhotoUpload: ({ onUploadSettled }: any) => (
<div>
<button onClick={() => onUploadSettled?.({ hasFailures: false })}>settle-clean</button>
<button onClick={() => onUploadSettled?.({ hasFailures: true })}>settle-failed</button>
</div>
),
}));
describe('PhotoUploadModal auto-close behaviour', () => {
it('closes after a clean upload', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<PhotoUploadModal isOpen eventId={1} onClose={onClose} />);
await user.click(screen.getByText('settle-clean'));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('stays open when some files failed', async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<PhotoUploadModal isOpen eventId={1} onClose={onClose} />);
await user.click(screen.getByText('settle-failed'));
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,82 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactElement } from 'react';
import { ThemeCustomizerEnhanced } from '../ThemeCustomizerEnhanced';
import type { ThemeConfig } from '../../../types/theme.types';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key
})
};
});
// Component uses useQuery for admin-settings + fonts; tests don't exercise
// those data paths, so just give them a client that won't retry on the
// (intentionally absent) network.
const renderWithQueryClient = (ui: ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
describe('ThemeCustomizerEnhanced', () => {
const baseTheme: ThemeConfig = {
primaryColor: '#000000',
accentColor: '#ffffff',
backgroundColor: '#eeeeee',
textColor: '#111111',
galleryLayout: 'grid',
gallerySettings: {
spacing: 'normal'
}
};
it('invokes onApply when Apply Theme is clicked', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
const handleApply = vi.fn().mockResolvedValue(undefined);
renderWithQueryClient(
<ThemeCustomizerEnhanced
value={baseTheme}
onChange={handleChange}
presetName="default"
onApply={handleApply}
/>
);
const applyButton = screen.getByRole('button', { name: /branding\.applyTheme/i });
await user.click(applyButton);
expect(handleChange).toHaveBeenCalled();
expect(handleApply).toHaveBeenCalledTimes(1);
expect(handleApply).toHaveBeenCalledWith(
expect.objectContaining({ primaryColor: '#000000' }),
expect.objectContaining({ presetName: 'default' })
);
});
it('disables the Apply button while applying', () => {
const handleChange = vi.fn();
renderWithQueryClient(
<ThemeCustomizerEnhanced
value={baseTheme}
onChange={handleChange}
presetName="default"
isApplying={true}
/>
);
const applyButton = screen.getByRole('button', { name: /applying/i });
expect(applyButton).toBeDisabled();
});
});
+45
View File
@@ -0,0 +1,45 @@
export { AdminLayout } from './AdminLayout';
export { AdminSidebar } from './AdminSidebar';
export { AdminHeader } from './AdminHeader';
export { PasswordChangeModal } from './PasswordChangeModal';
export { AdminAuthWrapper } from './AdminAuthWrapper';
export { PhotoUpload } from './PhotoUpload';
export { CategoryManager } from './CategoryManager';
export { EventCategoryManager } from './EventCategoryManager';
export { CMSEditor } from './CMSEditor';
export { WelcomeMessageEditor } from './WelcomeMessageEditor';
export { BulkArchiveModal } from './BulkArchiveModal';
export { BulkDeleteModal } from './BulkDeleteModal';
export { MaintenanceBanner } from './MaintenanceBanner';
export { EmailPreviewModal } from './EmailPreviewModal';
export { AdminPhotoGrid } from './AdminPhotoGrid';
export { AdminPhotoViewer } from './AdminPhotoViewer';
export { PhotoFilters } from './PhotoFilters';
export { PasswordResetModal } from './PasswordResetModal';
export { PublishGalleryDialog } from './PublishGalleryDialog';
export { DuplicateEventDialog } from './DuplicateEventDialog';
export { ExportPreviewModal } from './ExportPreviewModal';
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
export { ThemeDisplay } from './ThemeDisplay';
export { ThemeEditorModal } from './ThemeEditorModal';
export { HeroPhotoSelector } from './HeroPhotoSelector';
export { FocalPointPicker } from './FocalPointPicker';
export { PhotoUploadModal } from './PhotoUploadModal';
export { GalleryPreview } from './GalleryPreview';
export { BackupDashboard } from './BackupDashboard';
export { BackupConfiguration } from './BackupConfiguration';
export { BackupHistory } from './BackupHistory';
export { RestoreWizard } from './RestoreWizard';
export { FeedbackSettings } from './FeedbackSettings';
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
export { WordFilterManager } from './WordFilterManager';
export { EventRenameDialog } from './EventRenameDialog';
export { PhotoFilterPanel } from './PhotoFilterPanel';
export { PhotoExportMenu } from './PhotoExportMenu';
export { CssTemplateEditor } from './CssTemplateEditor';
export { AdminGuestsList } from './AdminGuestsList';
export { AdminGuestDetail } from './AdminGuestDetail';
export { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
export { GuestInviteDialog } from './GuestInviteDialog';
@@ -0,0 +1,305 @@
import React from 'react';
import { Palette, RotateCcw, Info } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Card } from '../../common';
import { ThemeConfig } from '../../../types/theme.types';
import { ColorPickerRow } from './ColorPickerRow';
interface ColorCustomizationCardProps {
localTheme: ThemeConfig;
handleChange: (key: keyof ThemeConfig, newValue: any) => void;
handleColorModeSelect: (mode: 'light' | 'dark' | 'auto') => void;
forcedColorActive: boolean;
isBrandingContext: boolean;
hideGalleryColors: boolean;
forceColorMode?: 'dark' | 'light' | null;
onForceColorModeChange?: (mode: 'dark' | 'light' | null) => void;
onSyncFromBranding?: () => void;
}
export const ColorCustomizationCard: React.FC<ColorCustomizationCardProps> = ({
localTheme,
handleChange,
handleColorModeSelect,
forcedColorActive,
isBrandingContext,
hideGalleryColors,
forceColorMode,
onForceColorModeChange,
onSyncFromBranding
}) => {
const { t } = useTranslation();
return (
<Card className="p-6">
<div className="flex items-center justify-between gap-2 mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<Palette className="w-5 h-5" />
{t('branding.colors')}
</h3>
{/* "Sync from Branding" — caller-supplied so the customizer
doesn't have to know how to resolve the Branding theme.
Used in event create/edit to reset palette to site colours. */}
{onSyncFromBranding && (
<Button
type="button"
variant="outline"
size="sm"
leftIcon={<RotateCcw className="w-4 h-4" />}
onClick={onSyncFromBranding}
>
{t('branding.syncFromBranding', 'Sync from Branding')}
</Button>
)}
</div>
{/* Color Mode Selector */}
<div className="mb-6">
{forcedColorActive && (
<div className="mb-3 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 text-xs text-amber-800 dark:text-amber-300">
{isBrandingContext
? t('branding.forcedModeBrandingHint', 'Light/dark is locked site-wide by the Force control below — the per-theme mode picker is hidden because it would have no effect.')
: t('branding.forcedModeGalleryNote', 'A site-wide color lock is active, so this gallery follows the locked light/dark mode. Color and light/dark options are hidden here and cant be overridden per gallery.')}
</div>
)}
{!forcedColorActive && (<>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.colorMode', 'Color Mode')}
</label>
<div className="flex gap-2">
{(['light', 'dark', 'auto'] as const).map((mode) => (
<button
type="button"
key={mode}
onClick={() => handleColorModeSelect(mode)}
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
(localTheme.colorMode || 'light') === mode
? 'border-accent-dark bg-accent-dark text-white'
: 'border-neutral-300 dark:border-neutral-600 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}
>
{mode === 'light' ? t('branding.colorModeLight', 'Light') :
mode === 'dark' ? t('branding.colorModeDark', 'Dark') :
t('branding.colorModeAuto', 'Auto')}
</button>
))}
</div>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{t('branding.colorModeHelp', 'Auto follows the visitor\'s system preference.')}
</p>
</>)}
{/*
* Force color mode (instance-wide). Lives next to the per-theme
* Color Mode picker so the admin can find both controls in one
* place. The data flows through props from BrandingPage which
* persists it to branding settings; only renders when the
* onForceColorModeChange handler is provided (i.e. only on the
* Branding admin page, not in event-level theme editors).
*/}
{onForceColorModeChange && (
<div className="mt-5 pt-5 border-t border-neutral-200 dark:border-neutral-700">
<h4 className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('branding.forceColorMode', 'Force color mode')}
</h4>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-3">
{t(
'branding.forceColorModeHelp',
'Lock the entire admin and public site to dark or light. The user-facing dark/light toggle is hidden whenever a lock is active. Per-event themes that try to override the colour mode are also forced to follow.'
)}
</p>
<div className="flex flex-wrap gap-2">
{([
{ value: null, label: t('branding.forceColorModeNone', 'No force (user choice)') },
{ value: 'dark', label: t('branding.forceColorModeDark', 'Force dark') },
{ value: 'light', label: t('branding.forceColorModeLight', 'Force light') },
] as const).map(({ value, label }) => {
const active = (forceColorMode ?? null) === value;
return (
<button
type="button"
key={String(value)}
onClick={() => onForceColorModeChange(value)}
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
active
? 'border-accent-dark bg-accent-dark text-white'
: 'border-neutral-300 dark:border-neutral-600 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}
>
{label}
</button>
);
})}
</div>
</div>
)}
</div>
{/*
* 8-token CI palette pickers, grouped by role.
* Each token writes directly to the same field name on ThemeConfig
* (kebab → camel mapping happens via handleChange's first arg).
* Translation keys fall back to inline strings — German/English
* coverage only (per user language profile); other locales will
* show the fallback until reviewed by a native speaker.
*/}
{/*
* 8-token CI palette pickers, grouped by role. Each picker label
* carries an Info icon whose `title` attribute renders the
* descriptive help text on hover (or long-press on touch). Keeping
* the help out of the static layout means every picker row is the
* same height so the four Surfaces and the two Accent rows align
* cleanly side-by-side.
*/}
{!hideGalleryColors && (
<div className="space-y-6">
{/* Surfaces */}
<div>
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3 flex items-center gap-1.5">
{t('branding.colorGroupSurfaces', 'Surfaces')}
<span
className="info-tooltip text-neutral-400 dark:text-neutral-500"
data-tooltip={t(
'branding.colorGroupSurfacesHelp',
'The neutral layers behind your content. Background sits furthest back; Surface and Elevated stack on top.'
)}
tabIndex={0}
>
<Info className="w-3.5 h-3.5" />
</span>
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
key: 'backgroundColor',
label: t('branding.backgroundColor', 'Background'),
help: t('branding.backgroundColorHelp', 'The page itself — body background of every gallery, admin page and CMS page.'),
fallback: '#fafafa',
},
{
key: 'surfaceColor',
label: t('branding.surfaceColor', 'Surface'),
help: t('branding.surfaceColorHelp', 'Cards, sidebar, header bar and navigation. The first layer above Background.'),
fallback: '#ffffff',
},
{
key: 'elevatedColor',
label: t('branding.elevatedColor', 'Elevated'),
help: t('branding.elevatedColorHelp', 'Panels that float above cards: image placeholders, hover/active rows, modal headers, code blocks.'),
fallback: '#f5f5f5',
},
{
key: 'surfaceBorderColor',
label: t('branding.borderColor', 'Border'),
help: t('branding.borderColorHelp', 'Dividers, table grid lines, card outlines, input borders.'),
fallback: '#e5e5e5',
},
].map(({ key, label, help, fallback }) => (
<ColorPickerRow
key={key}
label={label}
help={help}
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
fallback={fallback}
onChange={(v) => handleChange(key as keyof ThemeConfig, v)}
/>
))}
</div>
</div>
{/* Text */}
<div>
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3 flex items-center gap-1.5">
{t('branding.colorGroupText', 'Text')}
<span
className="info-tooltip text-neutral-400 dark:text-neutral-500"
data-tooltip={t(
'branding.colorGroupTextHelp',
'Foreground text colours. Primary is for everything readers focus on; Secondary is for supporting copy.'
)}
tabIndex={0}
>
<Info className="w-3.5 h-3.5" />
</span>
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
key: 'textColor',
label: t('branding.textColor', 'Primary text'),
help: t('branding.textColorHelp', 'Headlines, body copy, table cells, form input values, navigation labels — the main text colour.'),
fallback: '#171717',
},
{
key: 'mutedTextColor',
label: t('branding.mutedTextColor', 'Secondary text'),
help: t('branding.mutedTextColorHelp', 'Captions, helper text under inputs, table column headers, footer links, dates and metadata.'),
fallback: '#737373',
},
].map(({ key, label, help, fallback }) => (
<ColorPickerRow
key={key}
label={label}
help={help}
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
fallback={fallback}
onChange={(v) => handleChange(key as keyof ThemeConfig, v)}
/>
))}
</div>
</div>
{/* Accent */}
<div>
<h4 className="text-sm font-semibold text-neutral-700 dark:text-neutral-300 uppercase tracking-wide mb-3 flex items-center gap-1.5">
{t('branding.colorGroupAccent', 'Accent')}
<span
className="info-tooltip text-neutral-400 dark:text-neutral-500"
data-tooltip={t(
'branding.colorGroupAccentHelp',
'Brand colours that highlight interactive elements. Use a strong colour pair — Accent is for outlines/text, Accent Dark is for filled buttons.'
)}
tabIndex={0}
>
<Info className="w-3.5 h-3.5" />
</span>
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
key: 'accentColor',
label: t('branding.accentColor', 'Accent'),
help: t(
'branding.accentColorHelp',
'Links, icons, focus rings, hover states on primary buttons, active sidebar item underline. Should read clearly on both Background and Surface.'
),
fallback: '#22c55e',
},
{
key: 'accentDarkColor',
label: t('branding.accentDarkColor', 'Accent (filled)'),
help: t(
'branding.accentDarkColorHelp',
'Filled CTA buttons, active sidebar item background, badges and tags. Needs enough contrast for white text to be readable on top.'
),
fallback: '#5C8762',
},
].map(({ key, label, help, fallback }) => (
<ColorPickerRow
key={key}
label={label}
help={help}
value={(localTheme as Record<string, string | undefined>)[key] || fallback}
fallback={fallback}
onChange={(v) => handleChange(key as keyof ThemeConfig, v)}
/>
))}
</div>
{/* primaryColor is kept in sync with accentDarkColor inside
handleChange() — no dedicated picker. */}
</div>
</div>
)}
</Card>
);
};

Some files were not shown because too many files have changed in this diff Show More