fix(types): resolve the TypeScript build:check backlog

74 errors -> 1. No suppressions: zero `any`, `as unknown as`, `@ts-ignore` or
non-null `!` added, and tsconfig is untouched. Each error was triaged as
"the type is wrong" vs "the code is wrong" and fixed on that side.

Live bugs the checker was pointing at:

- admin.service.ts TS1117 duplicate key: admin_password_reset was defined
  twice and the later one won at runtime. Removed it so the earlier entry
  wins, which matches the actual emitter in userManagementService.js and
  carries the email fallback.
- PhotoGridWithLayouts dropped allowReactions from its prop type, so the
  Premium layout's reactions never activated even though GalleryView passes
  it and GalleryPremiumLayout reads it.
- SlideshowPage's poll never copied `order` into next/prev, so live
  play-order changes never reached a running kiosk.
- CustomerLayout compared branding_force_color_mode against 'auto', which is
  never persisted (only 'dark'|'light'|null), so the customer portal always
  picked the light logo even in OS dark mode.
- EmailConfigPage rendered lang.flag, but SUPPORTED_LANGUAGES exposes Flag, a
  component -- so nothing rendered. And editing a language with no translation
  yet spread undefined, storing a partial object missing required fields.
- publicQuotes.js projected only 6 line-item fields, omitting
  parentLineItemId/parentPosition/detailsText, so the migration-119 sub-item
  hierarchy and details text could never render on the customer-facing quote
  page -- the frontend code for it was unreachable. It reads from the same
  quoteService.getQuoteById the admin route uses, where those fields are
  present; adminQuotes.js projects all three. Fixed the projection rather
  than adding fields to the frontend type, which would have compiled while
  leaving the feature broken.
- DuplicateEventDialog's helper text was silently dropped: LocalizedDateInput
  had no helperText prop. Added, mirroring Input.tsx incl. aria-describedby.
- ThemeEditorModal/EventThemeSection still passed isPreviewMode, a prop
  822be9a9 deliberately removed but missed at these two call sites.
- GalleryPage's hero-photo injection was dead: /gallery/:slug/info does not
  return hero_photo_id (only /photos does) and GalleryView already does it
  correctly. Removed the dead block rather than adding a field the API
  never sends.

Stale types corrected against the backend route that produces each payload:
GalleryInfo (allow_downloads, allow_user_uploads), GalleryData.event
(download_zip_ready), UpdateEventData (client_access_enabled, client_password,
regenerate_client_token), InvoiceSummary (replacesInvoiceId), ExportOptions
(mark_source, plus a snake_case ExportFilter matching the actual wire format),
customer.service contracts, AdminUser timestamps widened to string|null,
formatMoney currency widened to match its own (currency || 'CHF') guard,
faceCropStyle dimensions widened to match its !photoWidth guard, DEFAULT_FLAGS
faces, logo_position 'sidepanel', and the hand-rolled t() props replaced with
i18next's TFunction in four files.

Unused symbols were checked before deletion; UpdateInstructionsDialog's
targetVersion prop was completed rather than deleted (declared and passed but
never rendered -- now the fallback before the query resolves).

Left unfixed, deliberately: GalleryStoryLayout's handleOpenFeedback (TS6133).
It is the only caller of setSelectedPhotoForFeedback and is itself never
called, so StoryFeedbackSheet can never open on the Story theme. Wiring it
needs a new affordance on StoryPhotoCard (no sibling layout exposes one to
copy) and deleting it would orphan the sheet -- a product decision, not a
type fix. Note PhotoLightbox on the same layout already handles feedback,
so the sheet may simply be superseded.

Refs testplan REPORT.md #22 (Part 1.3.04).
This commit is contained in:
Paul Nothaft
2026-09-01 17:09:24 +02:00
parent 5dbb43549c
commit 6e5755de02
45 changed files with 153 additions and 87 deletions
+7
View File
@@ -74,6 +74,13 @@ function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosTe
unitPriceMinor: li.unit_price_minor,
discountPercent: li.discount_percent == null ? 0 : Number(li.discount_percent),
lineTotalMinor: li.line_total_minor,
// Hierarchy + details (migration 119), same shape adminQuotes.js
// projects. Omitting them here meant the customer-facing page could
// never thread sub-items or show details text, even though the data
// is on the rows getQuoteById already returns.
parentLineItemId: li.parent_line_item_id || null,
parentPosition: li.parent_position == null ? null : Number(li.parent_position),
detailsText: li.details_text || null,
})),
recipient: customer ? {
displayName: customer.display_name || [customer.first_name, customer.last_name].filter(Boolean).join(' '),
@@ -27,7 +27,7 @@ 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';
import type { Event as AdminEvent } from '../../types';
interface SelectedEvent {
id: number;
@@ -26,7 +26,11 @@ 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)[] = [
// Narrowed to the `ledger_account_*` keys so `patch[k] = settings[k]` below
// typechecks: they all share the value type `string | undefined`, whereas
// `keyof LedgerSettings` also spans the Record-valued VAT maps.
type LedgerAccountSettingKey = Extract<keyof LedgerSettings, `ledger_account_${string}`>;
const SETTING_ACCOUNT_KEYS: LedgerAccountSettingKey[] = [
'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',
@@ -14,7 +14,7 @@
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 { Briefcase, UserCog, FileText, Receipt, Wrench, Clock, ScrollText, Calendar, FolderKanban } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
@@ -1,5 +1,5 @@
import React, { useState, useCallback } from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import HardBreak from '@tiptap/extension-hard-break';
@@ -10,7 +10,10 @@ interface GalleryPreviewBranding {
logo_url?: string;
logo_url_dark?: string;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
logo_position?: 'left' | 'center' | 'right';
// Mirrors BrandingSettings.logo_position. 'sidepanel' has no distinct
// rendering in this small preview — it falls through to the left-aligned
// branch below.
logo_position?: 'left' | 'center' | 'right' | 'sidepanel';
}
interface GalleryPreviewProps {
@@ -6,11 +6,9 @@ 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: '',
@@ -145,7 +145,6 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
onChange={handleThemeChange}
presetName={presetName}
onPresetChange={handlePresetChange}
isPreviewMode={true}
showGalleryLayouts={true}
hideActions={true}
cssTemplates={cssTemplates}
@@ -139,9 +139,11 @@ export const UpdateInstructionsDialog: React.FC<UpdateInstructionsDialogProps> =
<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 && (
{/* The server response is authoritative; the prop covers the
window before the query resolves. */}
{(data?.targetVersion || targetVersion) && (
<span className="ml-2 text-blue-600 dark:text-blue-400">
v{data.targetVersion}
v{data?.targetVersion || targetVersion}
</span>
)}
</h3>
@@ -22,6 +22,8 @@ interface LocalizedDateInputProps {
value: string;
onChange: (iso: string) => void;
error?: string;
/** Hint rendered below the field, hidden while an error is showing. */
helperText?: string;
/** Forwarded to the native picker so min/max date constraints work. */
min?: string;
max?: string;
@@ -33,6 +35,7 @@ export const LocalizedDateInput: React.FC<LocalizedDateInputProps> = ({
value,
onChange,
error,
helperText,
min,
max,
disabled,
@@ -154,7 +157,9 @@ export const LocalizedDateInput: React.FC<LocalizedDateInputProps> = ({
}}
className={clsx('input pr-10', error && 'border-red-500 focus-visible:ring-red-500')}
aria-invalid={error ? 'true' : 'false'}
aria-describedby={error ? `${inputId}-error` : undefined}
aria-describedby={
error ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined
}
/>
<button
type="button"
@@ -189,6 +194,11 @@ export const LocalizedDateInput: React.FC<LocalizedDateInputProps> = ({
{error}
</p>
)}
{helperText && !error && (
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-neutral-500 dark:text-neutral-400">
{helperText}
</p>
)}
</div>
);
};
@@ -502,9 +502,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresP
promo_position: settingsData.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer',
// Promo alignment (#482). Defaults to 'center' to match the
// gallery footer; see GalleryLayout.
promo_alignment: ['left', 'center', 'right'].includes(settingsData.branding_promo_alignment)
? settingsData.branding_promo_alignment
: 'center',
promo_alignment: settingsData.branding_promo_alignment || 'center',
});
}
}, [settingsData]);
@@ -57,6 +57,7 @@ interface PhotoGridWithLayoutsProps {
allowFavorites?: boolean;
allowRatings?: boolean;
allowComments?: boolean;
allowReactions?: boolean;
requireNameEmail?: boolean;
};
onFeedbackChange?: () => void;
+2 -2
View File
@@ -25,8 +25,8 @@ export interface FaceBox {
*/
export function faceCropStyle(
cover: FaceBox | null | undefined,
photoWidth: number | undefined,
photoHeight: number | undefined,
photoWidth: number | null | undefined,
photoHeight: number | null | undefined,
size: number,
): React.CSSProperties | null {
if (!cover || !photoWidth || !photoHeight) return null;
@@ -1,5 +1,5 @@
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { Search, Heart, Menu, LogOut } from 'lucide-react';
import { Search, Heart, LogOut } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
@@ -70,6 +70,8 @@ export const DEFAULT_FLAGS: FeatureFlags = {
// Workflow / automation engine — opt-in; gates the Workflows admin area
// and the engine runtime (triggers/actions/gates).
workflows: false,
// #1074 — off by default is the whole "zero behaviour change" guarantee.
faces: false,
};
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import { useCallback, useState } from 'react';
import {
FeedbackLimitReachedModal,
} from '../components/gallery/FeedbackLimitReachedModal';
+3 -4
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { useParams, useSearchParams, useNavigate, Link } from 'react-router-dom';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { AlertCircle, Lock } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -12,7 +12,6 @@ import { buildResourceUrl } from '../utils/url';
export const ClientAccessPage: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { isAuthenticated, isClient, clientLogin, isLoading: authLoading } = useGalleryAuth();
const { t } = useTranslation();
@@ -88,7 +87,7 @@ export const ClientAccessPage: React.FC = () => {
<div className="p-8 text-center">
<img
src={buildResourceUrl(brandLogo)}
alt={settingsData.branding_company_name || 'Company Logo'}
alt={settingsData?.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
</div>
@@ -115,7 +114,7 @@ export const ClientAccessPage: React.FC = () => {
<div className="p-8 text-center">
<img
src={buildResourceUrl(brandLogo)}
alt={settingsData.branding_company_name || 'Company Logo'}
alt={settingsData?.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
</div>
+2 -8
View File
@@ -168,14 +168,8 @@ export const GalleryPage: React.FC = () => {
themeToApply = settingsData.theme_config;
}
// Inject hero photo ID into theme gallery settings
if (themeToApply && galleryInfo.hero_photo_id) {
if (themeToApply.gallerySettings) {
themeToApply.gallerySettings.heroImageId = galleryInfo.hero_photo_id;
} else {
themeToApply.gallerySettings = { heroImageId: galleryInfo.hero_photo_id };
}
}
// The hero photo ID is injected into gallerySettings by GalleryView,
// which reads it off the /photos response. /info doesn't carry it.
// Apply theme. Force color mode is enforced inside ThemeContext.applyTheme
// (it subscribes to public settings) so callers don't have to wrap the
+2 -1
View File
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Key, Mail, Lock, Eye, EyeOff, AlertCircle, ArrowLeft, ArrowRight, Copy, Check, ExternalLink, Bug, Lightbulb, Star, Coffee } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
@@ -27,7 +28,7 @@ const SETUP_DOCS_URL =
const COMMUNITY_LINKS: {
key: string;
href: string;
icon: React.ComponentType<{ className?: string }>;
icon: LucideIcon;
}[] = [
{ key: 'bug', href: 'https://github.com/PicPeak/picpeak/issues/new?template=bug_report.md', icon: Bug },
{ key: 'feature', href: 'https://github.com/PicPeak/picpeak/issues/new?template=feature_request.md', icon: Lightbulb },
+4 -2
View File
@@ -66,11 +66,13 @@ interface FormData {
allow_favorites: boolean;
allow_reactions: boolean;
allow_color_labels: boolean;
keybind_mode: 'colors' | 'lightroom';
// Optional, mirroring the shared FeedbackSettings contract — the
// <FeedbackSettings> editor's onChange emits that shape.
keybind_mode?: 'colors' | 'lightroom';
require_name_email: boolean;
moderate_comments: boolean;
show_feedback_to_guests: boolean;
identity_mode: 'simple' | 'guest' | 'shared';
identity_mode?: 'simple' | 'guest' | 'shared';
enable_rate_limiting: boolean;
rate_limit_window_minutes?: number;
rate_limit_max_requests?: number;
@@ -16,7 +16,6 @@ import { toast } from 'react-toastify';
import {
ArrowLeft, Mail, MapPin, Phone, Building2, Save, Trash2, AlertTriangle,
CheckCircle2, X, FileText, Calendar, KeyRound, ToggleLeft, Settings as SettingsIcon,
Clock,
} from 'lucide-react';
import { Button, Card, CountrySelect, Input, Loading } from '../../components/common';
+5 -2
View File
@@ -395,7 +395,10 @@ export const EmailConfigPage: React.FC = () => {
translations: {
...prev.translations,
[editingLang]: {
...prev.translations?.[editingLang],
// Seed the empty translation when this language has none yet,
// otherwise the first edit stores a partial object missing the
// other required fields.
...(prev.translations?.[editingLang] || { subject: '', body_html: '', body_text: '' }),
[field]: value,
},
},
@@ -1015,7 +1018,7 @@ export const EmailConfigPage: React.FC = () => {
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm bg-white dark:bg-neutral-800 border border-blue-300 dark:border-blue-700 rounded-md hover:bg-blue-50 dark:hover:bg-blue-900/30 text-blue-700 dark:text-blue-300"
>
<Copy className="w-3.5 h-3.5" />
{t('email.copyFrom')} {lang.flag} {lang.name}
{t('email.copyFrom')} <lang.Flag/> {lang.name}
</button>
))}
</div>
@@ -717,7 +717,7 @@ export const EventDetailsPage: React.FC = () => {
{showPasswordReset && (
<PasswordResetModal
eventName={event.event_name}
eventDate={event.event_date}
eventDate={event.event_date ?? undefined}
eventType={event.event_type}
onConfirm={async (sendEmail, password) => {
const result = await eventsService.resetPassword(event.id, sendEmail, password);
@@ -36,15 +36,6 @@ const LEDGER_FORMATS: ExportFormat[] = ['generic', 'banana', 'banana_ie', 'bexio
type PeriodPreset = 'thisYear' | 'lastYear' | 'thisQuarter' | 'lastQuarter' | 'custom';
function isoDate(d: Date): string {
// YYYY-MM-DD in local time. Tax reports are user-facing — a Swiss
// admin asking for "this quarter" means their local Q, not UTC.
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function periodForPreset(preset: PeriodPreset, today = new Date()): { from: string; to: string } {
const y = today.getFullYear();
if (preset === 'thisYear') return { from: `${y}-01-01`, to: `${y}-12-31` };
@@ -92,7 +92,6 @@ export const EventThemeSection: React.FC<EventThemeSectionProps> = ({
setThemeChanged(true);
toast.success(t('toast.brandingPaletteSynced', 'Palette synced from Branding.'));
}}
isPreviewMode={true}
showGalleryLayouts={true}
hideActions={true}
cssTemplates={cssTemplates}
@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import type { TFunction } from 'i18next';
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { X, Plus, FileText } from 'lucide-react';
@@ -26,22 +27,22 @@ const CONFIG: Record<DocType, { label: string; newRoute: string; hasExisting: bo
interface DocRow { id: number; number: string; status: string }
type SelCustomer = { id: number; email: string; label: string };
type SelCustomer = { id: number; email: string; label: string; isPassive: boolean };
export const DocumentActionModal: React.FC<{
docType: DocType;
senderEmail: string;
onCompose: (init: { to: string; subject: string; html: string }) => void;
onClose: () => void;
t: (k: string, d?: string) => string;
t: TFunction;
}> = ({ docType, senderEmail, onCompose, onClose, t }) => {
const navigate = useNavigate();
const cfg = CONFIG[docType];
const [customer, setCustomer] = useState<SelCustomer | null>(null);
const [resolving, setResolving] = useState(true);
const pick = (c: { id: number; email: string; displayName?: string | null; companyName?: string | null }) =>
setCustomer({ id: c.id, email: c.email, label: c.companyName || c.displayName || c.email });
const pick = (c: { id: number; email: string; displayName?: string | null; companyName?: string | null; isPassive?: boolean }) =>
setCustomer({ id: c.id, email: c.email, label: c.companyName || c.displayName || c.email, isPassive: Boolean(c.isPassive) });
// Resolve the customer from the message's sender address (first match).
useEffect(() => {
@@ -116,6 +117,7 @@ export const DocumentActionModal: React.FC<{
<CustomerPicker
value={customer?.id ?? null}
label={customer?.label || ''}
isPassive={customer?.isPassive ?? false}
onSelect={pick}
onCreate={pick}
onClear={() => setCustomer(null)}
@@ -131,7 +133,7 @@ export const DocumentActionModal: React.FC<{
{customer && (
<>
<Button variant="primary" onClick={createNew} leftIcon={<Plus className="w-4 h-4" />} className="w-full justify-center">
{t('messages.createNewDoc', 'Create new {{label}}', { label: t(`messages.doc.${docType}`, cfg.label) } as any)}
{t('messages.createNewDoc', 'Create new {{label}}', { label: t(`messages.doc.${docType}`, cfg.label) })}
</Button>
{cfg.hasExisting && (
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import type { TFunction } from 'i18next';
import { useMutation } from '@tanstack/react-query';
import DOMPurify from 'dompurify';
import { X, Send as SendIcon } from 'lucide-react';
@@ -28,7 +29,7 @@ export const MessageComposer: React.FC<{
accountKey?: string;
onClose: () => void;
onSent: () => void;
t: (k: string, d?: string) => string;
t: TFunction;
}> = ({ init, title, accountKey, onClose, onSent, t }) => {
const [to, setTo] = useState(init.to);
const [cc, setCc] = useState(init.cc || '');
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
@@ -422,7 +423,7 @@ const MessageList: React.FC<{
search: string;
selection: Selection;
onSelect: (s: Selection) => void;
t: (k: string, d?: string) => string;
t: TFunction;
}> = ({ folder, queue, received, loading, search, selection, onSelect, t }) => {
if (folder.src === 'empty') {
return (
@@ -515,7 +516,7 @@ const ReadingPane: React.FC<{
onCompose: (init: ComposerInit, title?: string) => void;
onOpenDoc: (docType: DocType, senderEmail: string) => void;
onItemAction: (action: 'archive' | 'delete' | 'restore') => void;
t: (k: string, d?: string) => string;
t: TFunction;
}> = ({ selection, account, identities, flags, folderState, onViewDoc, onOpenAccounting, onCompose, onOpenDoc, onItemAction, t }) => {
const detailQuery = useQuery({
queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null],
@@ -584,7 +585,7 @@ const ReadingPane: React.FC<{
);
};
const QueueDetail: React.FC<{ d: import('../../../services/email.service').EmailQueueDetail; fromAddr?: string | null; t: (k: string, d?: string) => string }> = ({ d, fromAddr, t }) => (
const QueueDetail: React.FC<{ d: import('../../../services/email.service').EmailQueueDetail; fromAddr?: string | null; t: TFunction }> = ({ d, fromAddr, t }) => (
<>
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100" style={{ textWrap: 'balance' } as React.CSSProperties}>
{friendlyType(d.emailType)}
@@ -636,7 +637,7 @@ const ReceivedDetail: React.FC<{
mailboxAddr?: string | null;
onViewDoc: (id: number) => void;
onOpenAccounting: () => void;
t: (k: string, d?: string) => string;
t: TFunction;
}> = ({ item, mailboxAddr, onViewDoc, onOpenAccounting, t }) => {
const detail = useQuery({
queryKey: ['messages', 'received', 'item', item.id],
@@ -703,7 +704,7 @@ const Toolbar: React.FC<{
onReply?: () => void;
onDoc?: (docType: DocType) => void;
onItemAction: (action: 'archive' | 'delete' | 'restore') => void;
t: (k: string, d?: string) => string;
t: TFunction;
}> = ({ isAcct, flags, folderState, onReply, onDoc, onItemAction, t }) => {
const Tb: React.FC<{ icon: LucideIcon; label: string; accent?: boolean; onClick?: () => void }> = ({ icon: Icon, label, accent, onClick }) => {
const enabled = !!onClick;
@@ -753,7 +754,7 @@ const Toolbar: React.FC<{
};
// ─────────────────────────────────────────────────────────────── pdf modal ──
const PdfModal: React.FC<{ docId: number; onClose: () => void; t: (k: string, d?: string) => string }> = ({ docId, onClose, t }) => {
const PdfModal: React.FC<{ docId: number; onClose: () => void; t: TFunction }> = ({ docId, onClose, t }) => {
const [page, setPage] = useState(1);
const [url, setUrl] = useState<string | null>(null);
const [err, setErr] = useState(false);
@@ -5,13 +5,12 @@
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Eye, Send, Copy, ArrowRightCircle, Edit2, Receipt, CheckCircle2, ScrollText, XCircle } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common';
import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
import { quotesService } from '../../../services/quotes.service';
import { billsService } from '../../../services/bills.service';
import { formatMoney } from '../../../components/admin/LineItemsTable';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
@@ -34,7 +33,6 @@ export const QuoteDetailPage: React.FC = () => {
enabled: !!id,
});
// Reciprocal lookup — pulls every invoice whose source_quote_id
if (isLoading || !data) return <Loading />;
const q = data.quote;
@@ -27,7 +27,6 @@ import { useMutationWithToast } from '../../../hooks';
// engine that lacks it.
const IANA_TIMEZONES: string[] = (() => {
try {
// @ts-expect-error supportedValuesOf is ES2022, not yet in all TS lib defs
return Intl.supportedValuesOf('timeZone') as string[];
} catch {
return ['UTC', 'Europe/Vaduz', 'Europe/Zurich', 'Europe/Berlin', 'Europe/Vienna', 'Europe/Paris', 'Europe/London'];
@@ -9,13 +9,14 @@
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import type { LucideIcon } from 'lucide-react';
interface CustomerComingSoonPageProps {
titleKey: string;
titleFallback: string;
bodyKey: string;
bodyFallback: string;
icon: React.ComponentType<{ className?: string }>;
icon: LucideIcon;
}
export const CustomerComingSoonPage: React.FC<CustomerComingSoonPageProps> = ({
@@ -29,6 +29,7 @@ import {
User as UserIcon,
X,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useCustomerAuth } from '../../contexts/CustomerAuthContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
@@ -37,7 +38,7 @@ interface NavItem {
to: string;
labelKey: string;
fallback: string;
icon: React.ComponentType<{ className?: string }>;
icon: LucideIcon;
/**
* Optional gate entry only renders when the matching feature is
* effective for this customer (i.e. global toggle ON and per-customer
@@ -65,12 +66,13 @@ export const CustomerLayout: React.FC = () => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
// Theme-aware logo: the customer surface follows branding_force_color_mode
// ('auto' → OS preference). Symmetric fallback so a single uploaded logo
// serves both modes.
// Theme-aware logo: the customer surface follows branding_force_color_mode.
// Only 'dark' and 'light' are persisted; null/absent means "follow the OS
// preference" — same resolution order as usePublicDarkMode. Symmetric
// fallback so a single uploaded logo serves both modes.
const forceMode = settingsData?.branding_force_color_mode;
const customerIsDark = forceMode === 'dark'
|| (forceMode === 'auto' && typeof window !== 'undefined'
|| (forceMode !== 'light' && typeof window !== 'undefined'
&& window.matchMedia?.('(prefers-color-scheme: dark)').matches);
const lightLogo = settingsData?.branding_logo_url?.trim();
const darkLogo = settingsData?.branding_logo_url_dark?.trim();
+3 -1
View File
@@ -32,7 +32,9 @@ export const PreviewPage: React.FC = () => {
const [brandingSettings, setBrandingSettings] = useState<any>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date');
// Mirrors PhotoFilterBar's sort union. 'capture_date' falls through to the
// upload-date branch below — the mock photos carry no EXIF capture date.
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
const mockPhotos = useMemo(() => generateMockPhotos(12), []);
const mockEvent = {
@@ -233,6 +233,7 @@ export function SlideshowPage() {
transition: state.transition,
transition_ms: state.transition_ms,
colorfilter: state.colorfilter,
order: state.order,
fit: state.fit,
watermark: state.watermark,
qr: state.qr,
@@ -242,6 +243,7 @@ export function SlideshowPage() {
transition: prev.transition,
transition_ms: prev.transition_ms,
colorfilter: prev.colorfilter,
order: prev.order,
fit: prev.fit,
watermark: prev.watermark,
qr: prev.qr,
+2 -1
View File
@@ -1,3 +1,4 @@
import type { TFunction } from 'i18next';
import { api } from '../config/api';
export type InboundStatus = 'unsorted' | 'categorized' | 'declined' | 'duplicate';
@@ -282,7 +283,7 @@ const SEED_CATEGORY_KEYS: Record<string, string> = {
'Weiterbildung': 'training',
'Sonstiges': 'other',
};
export function categoryLabel(cat: ExpenseCategory, t: (k: string, d?: string) => string): string {
export function categoryLabel(cat: ExpenseCategory, t: TFunction): string {
if (cat?.is_seed && SEED_CATEGORY_KEYS[cat.name]) return t(`accounting.category.${SEED_CATEGORY_KEYS[cat.name]}`, cat.name);
return cat?.name ?? '';
}
-1
View File
@@ -578,7 +578,6 @@ export const adminService = {
'customer_created_passive': `Passive customer created: ${md.email || ''}`,
'admin_user_activated': `Admin user activated: ${md.username || ''}`,
'admin_user_deleted': `Admin user deleted: ${md.username || ''}`,
'admin_password_reset': `Admin password reset: ${md.username || ''}`,
// Misc / legacy.
'bulk_archive_completed': `Bulk archive completed: ${md.count || 0} events archived`,
'email_queue_flushed': 'Email queue flushed',
+3
View File
@@ -96,6 +96,9 @@ export interface InvoiceSummary {
* (migration 128). Carries status 'scheduled' but never auto-sends
* (manual) shown with a "Draft" badge in the list. */
isMonthlyDraft?: boolean;
/** Storno wiring (migration 114). On a reissued invoice, the id of the
* original cancelled invoice drives the "Reissue" badge. */
replacesInvoiceId?: number | null;
}
/**
+6 -6
View File
@@ -97,12 +97,12 @@ export const customerService = {
// ---- auth ----
async login(email: string, password: string, recaptchaToken?: string | null): Promise<{
customer: CustomerProfile;
features: { calendar: boolean; quotes: boolean; bills: boolean };
features: { calendar: boolean; quotes: boolean; bills: boolean; contracts: boolean };
branding: { showLogo: boolean; showCompanyName: boolean };
}> {
const response = await api.post<{
customer: CustomerProfile;
features?: { calendar: boolean; quotes: boolean; bills: boolean };
features?: { calendar: boolean; quotes: boolean; bills: boolean; contracts: boolean };
branding?: { showLogo: boolean; showCompanyName: boolean };
}>(
'/customer/auth/login',
@@ -112,7 +112,7 @@ export const customerService = {
// upgraded yet — defaults match CustomerAuthContext's DEFAULT_*.
return {
customer: response.data.customer,
features: response.data.features || { calendar: false, quotes: false, bills: false },
features: response.data.features || { calendar: false, quotes: false, bills: false, contracts: false },
branding: response.data.branding || { showLogo: true, showCompanyName: true },
};
},
@@ -145,18 +145,18 @@ export const customerService = {
*/
async session(): Promise<{
customer: CustomerProfile;
features: { calendar: boolean; quotes: boolean; bills: boolean };
features: { calendar: boolean; quotes: boolean; bills: boolean; contracts: boolean };
branding: { showLogo: boolean; showCompanyName: boolean };
} | null> {
try {
const response = await api.get<{
customer: CustomerProfile;
features?: { calendar: boolean; quotes: boolean; bills: boolean };
features?: { calendar: boolean; quotes: boolean; bills: boolean; contracts: boolean };
branding?: { showLogo: boolean; showCompanyName: boolean };
}>('/customer/auth/session');
return {
customer: response.data.customer,
features: response.data.features || { calendar: false, quotes: false, bills: false },
features: response.data.features || { calendar: false, quotes: false, bills: false, contracts: false },
branding: response.data.branding || { showLogo: true, showCompanyName: true },
};
} catch (error: any) {
+6
View File
@@ -61,6 +61,12 @@ interface UpdateEventData {
admin_email?: string;
require_password?: boolean;
password?: string;
// Client (photographer's customer) access to the gallery. The plaintext
// PIN is hashed server-side; `regenerate_client_token` mints a fresh
// share token. Validated in adminEvents/crud.js on the update route.
client_access_enabled?: boolean;
client_password?: string;
regenerate_client_token?: boolean;
welcome_message?: string;
color_theme?: string;
expires_at?: string;
+4 -3
View File
@@ -1,3 +1,4 @@
import type { AxiosResponse } from 'axios';
import { api } from '../config/api';
import type {
GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier,
@@ -148,7 +149,7 @@ export const galleryService = {
slug: string,
photoId: number,
): Promise<{ blob: Blob; serverFilename: string | null }> {
const readResponse = (response: { data: Blob; headers: Record<string, string> }) => {
const readResponse = (response: AxiosResponse<Blob>) => {
const headerName =
response.headers['content-disposition'] || response.headers['Content-Disposition'];
return {
@@ -158,7 +159,7 @@ export const galleryService = {
};
try {
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
const response = await api.get<Blob>(`/gallery/${slug}/download/${photoId}`, {
responseType: 'blob',
});
return readResponse(response);
@@ -167,7 +168,7 @@ export const galleryService = {
// the original is missing and only a derivative remains). The
// view endpoint doesn't emit a download-oriented Content-Disposition,
// so serverFilename will be null and the caller's name wins.
const response = await api.get(`/gallery/${slug}/photo/${photoId}`, {
const response = await api.get<Blob>(`/gallery/${slug}/photo/${photoId}`, {
responseType: 'blob',
});
return readResponse(response);
+25 -1
View File
@@ -415,9 +415,31 @@ export interface FilteredPhotosResponse {
summary: FilterSummary;
}
/**
* Wire shape of the export `filter` block. The export endpoint feeds it
* straight into the backend's PhotoFilterBuilder, which reads snake_case
* keys so this is deliberately NOT FeedbackFilters (camelCase, used by
* the in-app filter UI). Callers convert between the two.
*/
export interface ExportFilter {
min_rating?: number | null;
max_rating?: number | null;
has_likes?: boolean;
min_likes?: number;
has_favorites?: boolean;
min_favorites?: number;
has_comments?: boolean;
color_labels?: string[];
my_color_labels?: string[];
category_id?: number;
logic?: 'AND' | 'OR';
sort?: 'rating' | 'likes' | 'favorites' | 'date' | 'filename';
order?: 'asc' | 'desc';
}
export interface ExportOptions {
photo_ids?: number[];
filter?: FeedbackFilters;
filter?: ExportFilter;
format: 'txt' | 'csv' | 'xmp' | 'json';
options?: {
filename_format?: 'original' | 'picpeak';
@@ -427,6 +449,8 @@ export interface ExportOptions {
include_label?: boolean;
include_description?: boolean;
include_keywords?: boolean;
/** Whose marks the export reads: the guests' ('client') or the admin's own ('mine'). */
mark_source?: 'client' | 'mine';
};
}
+4
View File
@@ -382,6 +382,10 @@ export interface PublicQuoteView {
unitPriceMinor: number;
discountPercent: number;
lineTotalMinor: number;
/** Hierarchy + details (migration 119). NULL parent = top-level item. */
parentLineItemId: number | null;
parentPosition: number | null;
detailsText: string | null;
}>;
/** Terms of Service block driven by the global `crm_quotes_tos_*`
* settings. When `required` is true, the public page must show a
+2 -1
View File
@@ -1,4 +1,5 @@
import { api } from '../config/api';
import type { SlideshowGlobalDefaults } from './slideshow.service';
export interface BrandingSettings {
company_name: string;
@@ -213,7 +214,7 @@ export const settingsService = {
},
// Update global Live Slideshow defaults (watermark)
async updateSlideshowDefaults(settings: Record<string, unknown>): Promise<void> {
async updateSlideshowDefaults(settings: SlideshowGlobalDefaults): Promise<void> {
await api.put('/admin/settings/slideshow', settings);
},
+6 -2
View File
@@ -108,6 +108,8 @@ export interface GalleryInfo {
requires_password?: boolean;
color_theme?: string;
default_photo_sort?: string;
allow_downloads?: boolean;
allow_user_uploads?: boolean;
// Resolved server-side (#894): false only when the admin hid the logo
// on this gallery's password page.
login_logo_visible?: boolean;
@@ -278,6 +280,8 @@ export interface GalleryData {
upload_category_id?: number | null;
hero_photo_id?: number | null;
allow_downloads?: boolean;
/** True when a pre-built download zip is on disk, so "download all" can skip the build. */
download_zip_ready?: boolean;
disable_right_click?: boolean;
watermark_downloads?: boolean;
watermark_text?: string;
@@ -355,8 +359,8 @@ export interface AdminUser {
isActive?: boolean;
lastLogin?: string | null;
lastLoginIp?: string | null;
createdAt?: string;
updatedAt?: string;
createdAt?: string | null;
updatedAt?: string | null;
createdByUsername?: string;
}
+5 -2
View File
@@ -63,7 +63,7 @@ export interface FormatMoneyOptions {
fractionDigits?: number;
}
function buildFormatter(currency: string, opts?: FormatMoneyOptions): Intl.NumberFormat {
function buildFormatter(currency: string | null | undefined, opts?: FormatMoneyOptions): Intl.NumberFormat {
const locale = opts?.locale || languageToLocale(i18next.language);
const numFormatOpts: Intl.NumberFormatOptions = {
style: 'currency',
@@ -81,10 +81,13 @@ function buildFormatter(currency: string, opts?: FormatMoneyOptions): Intl.Numbe
* currency string. Pass `*_amount_minor` columns through
* {@link formatMoneyMinor} instead passing minor units here yields
* a 100× over-display.
*
* `currency` accepts null/undefined several API rows carry a nullable
* currency column and falls back to CHF, same as buildFormatter.
*/
export function formatMoney(
amount: number,
currency: string,
currency: string | null | undefined,
opts?: FormatMoneyOptions,
): string {
const safe = Number.isFinite(amount) ? amount : 0;