From 6e5755de02bc6ee5561e1bde4cc3ff6210b81233 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 1 Sep 2026 17:09:24 +0200 Subject: [PATCH] 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). --- backend/src/routes/publicQuotes.js | 7 +++++ .../components/admin/AssignedEventsDialog.tsx | 2 +- .../admin/ChartOfAccountsManager.tsx | 6 ++++- .../src/components/admin/ClientsLayout.tsx | 2 +- .../components/admin/EmailTemplateEditor.tsx | 2 +- .../src/components/admin/GalleryPreview.tsx | 5 +++- .../admin/MandatoryPasswordChangeModal.tsx | 2 -- .../src/components/admin/ThemeEditorModal.tsx | 1 - .../admin/UpdateInstructionsDialog.tsx | 6 +++-- .../components/common/LocalizedDateInput.tsx | 12 ++++++++- .../src/components/gallery/GalleryView.tsx | 4 +-- .../gallery/PhotoGridWithLayouts.tsx | 1 + frontend/src/components/gallery/faceCrop.ts | 4 +-- .../gallery/layouts/GalleryStoryLayout.tsx | 2 +- frontend/src/contexts/FeatureFlagsContext.tsx | 2 ++ frontend/src/hooks/useFeedbackLimitModal.tsx | 2 +- frontend/src/pages/ClientAccessPage.tsx | 7 +++-- frontend/src/pages/GalleryPage.tsx | 10 ++----- frontend/src/pages/SetupPage.tsx | 3 ++- frontend/src/pages/admin/CreateEventPage.tsx | 6 +++-- .../src/pages/admin/CustomerDetailPage.tsx | 1 - frontend/src/pages/admin/EmailConfigPage.tsx | 7 +++-- frontend/src/pages/admin/EventDetailsPage.tsx | 2 +- .../src/pages/admin/clients/TaxReportPage.tsx | 9 ------- .../admin/event-details/EventThemeSection.tsx | 1 - .../admin/messages/DocumentActionModal.tsx | 12 +++++---- .../pages/admin/messages/MessageComposer.tsx | 3 ++- .../src/pages/admin/messages/MessagesPage.tsx | 13 +++++----- .../pages/admin/quotes/QuoteDetailPage.tsx | 4 +-- .../settings/SettingsBusinessProfilePage.tsx | 1 - .../pages/customer/CustomerComingSoonPage.tsx | 3 ++- .../src/pages/customer/CustomerLayout.tsx | 12 +++++---- frontend/src/pages/gallery/PreviewPage.tsx | 4 ++- frontend/src/pages/gallery/SlideshowPage.tsx | 2 ++ frontend/src/services/accounting.service.ts | 3 ++- frontend/src/services/admin.service.ts | 1 - frontend/src/services/bills.service.ts | 3 +++ frontend/src/services/customer.service.ts | 12 ++++----- frontend/src/services/events.service.ts | 6 +++++ frontend/src/services/gallery.service.ts | 7 ++--- frontend/src/services/photos.service.ts | 26 ++++++++++++++++++- frontend/src/services/quotes.service.ts | 4 +++ frontend/src/services/settings.service.ts | 3 ++- frontend/src/types/index.ts | 8 ++++-- frontend/src/utils/money.ts | 7 +++-- 45 files changed, 153 insertions(+), 87 deletions(-) diff --git a/backend/src/routes/publicQuotes.js b/backend/src/routes/publicQuotes.js index 0deb3b7b..98afd397 100644 --- a/backend/src/routes/publicQuotes.js +++ b/backend/src/routes/publicQuotes.js @@ -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(' '), diff --git a/frontend/src/components/admin/AssignedEventsDialog.tsx b/frontend/src/components/admin/AssignedEventsDialog.tsx index 3cac211b..4bbb14c6 100644 --- a/frontend/src/components/admin/AssignedEventsDialog.tsx +++ b/frontend/src/components/admin/AssignedEventsDialog.tsx @@ -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; diff --git a/frontend/src/components/admin/ChartOfAccountsManager.tsx b/frontend/src/components/admin/ChartOfAccountsManager.tsx index d011a492..2e2cf4a6 100644 --- a/frontend/src/components/admin/ChartOfAccountsManager.tsx +++ b/frontend/src/components/admin/ChartOfAccountsManager.tsx @@ -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; +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', diff --git a/frontend/src/components/admin/ClientsLayout.tsx b/frontend/src/components/admin/ClientsLayout.tsx index f09115cd..e258a58d 100644 --- a/frontend/src/components/admin/ClientsLayout.tsx +++ b/frontend/src/components/admin/ClientsLayout.tsx @@ -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'; diff --git a/frontend/src/components/admin/EmailTemplateEditor.tsx b/frontend/src/components/admin/EmailTemplateEditor.tsx index 1fb1e566..45936628 100644 --- a/frontend/src/components/admin/EmailTemplateEditor.tsx +++ b/frontend/src/components/admin/EmailTemplateEditor.tsx @@ -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'; diff --git a/frontend/src/components/admin/GalleryPreview.tsx b/frontend/src/components/admin/GalleryPreview.tsx index 728094f0..22995c63 100644 --- a/frontend/src/components/admin/GalleryPreview.tsx +++ b/frontend/src/components/admin/GalleryPreview.tsx @@ -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 { diff --git a/frontend/src/components/admin/MandatoryPasswordChangeModal.tsx b/frontend/src/components/admin/MandatoryPasswordChangeModal.tsx index e24e6763..7dac3cb4 100644 --- a/frontend/src/components/admin/MandatoryPasswordChangeModal.tsx +++ b/frontend/src/components/admin/MandatoryPasswordChangeModal.tsx @@ -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: '', diff --git a/frontend/src/components/admin/ThemeEditorModal.tsx b/frontend/src/components/admin/ThemeEditorModal.tsx index b995dcba..4b39da28 100644 --- a/frontend/src/components/admin/ThemeEditorModal.tsx +++ b/frontend/src/components/admin/ThemeEditorModal.tsx @@ -145,7 +145,6 @@ export const ThemeEditorModal: React.FC = ({ onChange={handleThemeChange} presetName={presetName} onPresetChange={handlePresetChange} - isPreviewMode={true} showGalleryLayouts={true} hideActions={true} cssTemplates={cssTemplates} diff --git a/frontend/src/components/admin/UpdateInstructionsDialog.tsx b/frontend/src/components/admin/UpdateInstructionsDialog.tsx index 932c58bc..819e8846 100644 --- a/frontend/src/components/admin/UpdateInstructionsDialog.tsx +++ b/frontend/src/components/admin/UpdateInstructionsDialog.tsx @@ -139,9 +139,11 @@ export const UpdateInstructionsDialog: React.FC =

{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) && ( - v{data.targetVersion} + v{data?.targetVersion || targetVersion} )}

diff --git a/frontend/src/components/common/LocalizedDateInput.tsx b/frontend/src/components/common/LocalizedDateInput.tsx index 23d37318..af72f99e 100644 --- a/frontend/src/components/common/LocalizedDateInput.tsx +++ b/frontend/src/components/common/LocalizedDateInput.tsx @@ -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 = ({ value, onChange, error, + helperText, min, max, disabled, @@ -154,7 +157,9 @@ export const LocalizedDateInput: React.FC = ({ }} 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 + } />
); }; diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 06757d03..11687194 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -502,9 +502,7 @@ export const GalleryView: React.FC = ({ 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]); diff --git a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx index 9af80795..62a95481 100644 --- a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx +++ b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx @@ -57,6 +57,7 @@ interface PhotoGridWithLayoutsProps { allowFavorites?: boolean; allowRatings?: boolean; allowComments?: boolean; + allowReactions?: boolean; requireNameEmail?: boolean; }; onFeedbackChange?: () => void; diff --git a/frontend/src/components/gallery/faceCrop.ts b/frontend/src/components/gallery/faceCrop.ts index cd7210c0..761d64ae 100644 --- a/frontend/src/components/gallery/faceCrop.ts +++ b/frontend/src/components/gallery/faceCrop.ts @@ -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; diff --git a/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx b/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx index fac6d0f1..a4a073f3 100644 --- a/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx +++ b/frontend/src/components/gallery/layouts/GalleryStoryLayout.tsx @@ -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'; diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx index 4283f970..f0ee9265 100644 --- a/frontend/src/contexts/FeatureFlagsContext.tsx +++ b/frontend/src/contexts/FeatureFlagsContext.tsx @@ -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; diff --git a/frontend/src/hooks/useFeedbackLimitModal.tsx b/frontend/src/hooks/useFeedbackLimitModal.tsx index f6f4291c..f33f6f2a 100644 --- a/frontend/src/hooks/useFeedbackLimitModal.tsx +++ b/frontend/src/hooks/useFeedbackLimitModal.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import { useCallback, useState } from 'react'; import { FeedbackLimitReachedModal, } from '../components/gallery/FeedbackLimitReachedModal'; diff --git a/frontend/src/pages/ClientAccessPage.tsx b/frontend/src/pages/ClientAccessPage.tsx index fa258221..210f3dc7 100644 --- a/frontend/src/pages/ClientAccessPage.tsx +++ b/frontend/src/pages/ClientAccessPage.tsx @@ -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 = () => {
{settingsData.branding_company_name
@@ -115,7 +114,7 @@ export const ClientAccessPage: React.FC = () => {
{settingsData.branding_company_name
diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index 0aa28ce3..843fbd42 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -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 diff --git a/frontend/src/pages/SetupPage.tsx b/frontend/src/pages/SetupPage.tsx index c8fef4db..c5c35eea 100644 --- a/frontend/src/pages/SetupPage.tsx +++ b/frontend/src/pages/SetupPage.tsx @@ -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 }, diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index b0a46cc3..dad7714c 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -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 + // 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; diff --git a/frontend/src/pages/admin/CustomerDetailPage.tsx b/frontend/src/pages/admin/CustomerDetailPage.tsx index 2501e27a..49d6d74f 100644 --- a/frontend/src/pages/admin/CustomerDetailPage.tsx +++ b/frontend/src/pages/admin/CustomerDetailPage.tsx @@ -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'; diff --git a/frontend/src/pages/admin/EmailConfigPage.tsx b/frontend/src/pages/admin/EmailConfigPage.tsx index 88ba8b7c..6f8ca837 100644 --- a/frontend/src/pages/admin/EmailConfigPage.tsx +++ b/frontend/src/pages/admin/EmailConfigPage.tsx @@ -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" > - {t('email.copyFrom')} {lang.flag} {lang.name} + {t('email.copyFrom')} {lang.name} ))} diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index d9c58e40..2bce00f8 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -717,7 +717,7 @@ export const EventDetailsPage: React.FC = () => { {showPasswordReset && ( { const result = await eventsService.resetPassword(event.id, sendEmail, password); diff --git a/frontend/src/pages/admin/clients/TaxReportPage.tsx b/frontend/src/pages/admin/clients/TaxReportPage.tsx index dbc80d57..c351163e 100644 --- a/frontend/src/pages/admin/clients/TaxReportPage.tsx +++ b/frontend/src/pages/admin/clients/TaxReportPage.tsx @@ -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` }; diff --git a/frontend/src/pages/admin/event-details/EventThemeSection.tsx b/frontend/src/pages/admin/event-details/EventThemeSection.tsx index c37d11af..7fe47c5b 100644 --- a/frontend/src/pages/admin/event-details/EventThemeSection.tsx +++ b/frontend/src/pages/admin/event-details/EventThemeSection.tsx @@ -92,7 +92,6 @@ export const EventThemeSection: React.FC = ({ setThemeChanged(true); toast.success(t('toast.brandingPaletteSynced', 'Palette synced from Branding.')); }} - isPreviewMode={true} showGalleryLayouts={true} hideActions={true} cssTemplates={cssTemplates} diff --git a/frontend/src/pages/admin/messages/DocumentActionModal.tsx b/frontend/src/pages/admin/messages/DocumentActionModal.tsx index 3a9f4df3..d6315b5f 100644 --- a/frontend/src/pages/admin/messages/DocumentActionModal.tsx +++ b/frontend/src/pages/admin/messages/DocumentActionModal.tsx @@ -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 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(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<{ setCustomer(null)} @@ -131,7 +133,7 @@ export const DocumentActionModal: React.FC<{ {customer && ( <> {cfg.hasExisting && ( diff --git a/frontend/src/pages/admin/messages/MessageComposer.tsx b/frontend/src/pages/admin/messages/MessageComposer.tsx index c5cc7f0a..6811b831 100644 --- a/frontend/src/pages/admin/messages/MessageComposer.tsx +++ b/frontend/src/pages/admin/messages/MessageComposer.tsx @@ -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 || ''); diff --git a/frontend/src/pages/admin/messages/MessagesPage.tsx b/frontend/src/pages/admin/messages/MessagesPage.tsx index 62bc5f25..bfc0de27 100644 --- a/frontend/src/pages/admin/messages/MessagesPage.tsx +++ b/frontend/src/pages/admin/messages/MessagesPage.tsx @@ -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 }) => ( <>

{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(null); const [err, setErr] = useState(false); diff --git a/frontend/src/pages/admin/quotes/QuoteDetailPage.tsx b/frontend/src/pages/admin/quotes/QuoteDetailPage.tsx index fe1f4c37..42f55e41 100644 --- a/frontend/src/pages/admin/quotes/QuoteDetailPage.tsx +++ b/frontend/src/pages/admin/quotes/QuoteDetailPage.tsx @@ -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 ; const q = data.quote; diff --git a/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx b/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx index 2296207b..3edf3734 100644 --- a/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx +++ b/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx @@ -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']; diff --git a/frontend/src/pages/customer/CustomerComingSoonPage.tsx b/frontend/src/pages/customer/CustomerComingSoonPage.tsx index a29e641f..44f01955 100644 --- a/frontend/src/pages/customer/CustomerComingSoonPage.tsx +++ b/frontend/src/pages/customer/CustomerComingSoonPage.tsx @@ -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 = ({ diff --git a/frontend/src/pages/customer/CustomerLayout.tsx b/frontend/src/pages/customer/CustomerLayout.tsx index 536b8d60..f9bdc0f4 100644 --- a/frontend/src/pages/customer/CustomerLayout.tsx +++ b/frontend/src/pages/customer/CustomerLayout.tsx @@ -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(); diff --git a/frontend/src/pages/gallery/PreviewPage.tsx b/frontend/src/pages/gallery/PreviewPage.tsx index 127b18b7..79547460 100644 --- a/frontend/src/pages/gallery/PreviewPage.tsx +++ b/frontend/src/pages/gallery/PreviewPage.tsx @@ -32,7 +32,9 @@ export const PreviewPage: React.FC = () => { const [brandingSettings, setBrandingSettings] = useState(null); const [selectedCategoryId, setSelectedCategoryId] = useState(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 = { diff --git a/frontend/src/pages/gallery/SlideshowPage.tsx b/frontend/src/pages/gallery/SlideshowPage.tsx index 70e4d135..56478bed 100644 --- a/frontend/src/pages/gallery/SlideshowPage.tsx +++ b/frontend/src/pages/gallery/SlideshowPage.tsx @@ -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, diff --git a/frontend/src/services/accounting.service.ts b/frontend/src/services/accounting.service.ts index c1fbb1ab..499db61f 100644 --- a/frontend/src/services/accounting.service.ts +++ b/frontend/src/services/accounting.service.ts @@ -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 = { '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 ?? ''; } diff --git a/frontend/src/services/admin.service.ts b/frontend/src/services/admin.service.ts index cb969e20..646a8947 100644 --- a/frontend/src/services/admin.service.ts +++ b/frontend/src/services/admin.service.ts @@ -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', diff --git a/frontend/src/services/bills.service.ts b/frontend/src/services/bills.service.ts index 4b2b9dc1..ef878c4d 100644 --- a/frontend/src/services/bills.service.ts +++ b/frontend/src/services/bills.service.ts @@ -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; } /** diff --git a/frontend/src/services/customer.service.ts b/frontend/src/services/customer.service.ts index 17857018..58a06759 100644 --- a/frontend/src/services/customer.service.ts +++ b/frontend/src/services/customer.service.ts @@ -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) { diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts index f22ddce6..12fdaf52 100644 --- a/frontend/src/services/events.service.ts +++ b/frontend/src/services/events.service.ts @@ -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; diff --git a/frontend/src/services/gallery.service.ts b/frontend/src/services/gallery.service.ts index c8c9a5fd..57f72875 100644 --- a/frontend/src/services/gallery.service.ts +++ b/frontend/src/services/gallery.service.ts @@ -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 }) => { + const readResponse = (response: AxiosResponse) => { 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(`/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(`/gallery/${slug}/photo/${photoId}`, { responseType: 'blob', }); return readResponse(response); diff --git a/frontend/src/services/photos.service.ts b/frontend/src/services/photos.service.ts index 05c6db80..75451188 100644 --- a/frontend/src/services/photos.service.ts +++ b/frontend/src/services/photos.service.ts @@ -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'; }; } diff --git a/frontend/src/services/quotes.service.ts b/frontend/src/services/quotes.service.ts index b6ef9cc1..8d0badd1 100644 --- a/frontend/src/services/quotes.service.ts +++ b/frontend/src/services/quotes.service.ts @@ -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 diff --git a/frontend/src/services/settings.service.ts b/frontend/src/services/settings.service.ts index afa5d961..f7de882c 100644 --- a/frontend/src/services/settings.service.ts +++ b/frontend/src/services/settings.service.ts @@ -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): Promise { + async updateSlideshowDefaults(settings: SlideshowGlobalDefaults): Promise { await api.put('/admin/settings/slideshow', settings); }, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 238a76ec..fefc33c5 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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; } diff --git a/frontend/src/utils/money.ts b/frontend/src/utils/money.ts index 2d897982..5d534ce7 100644 --- a/frontend/src/utils/money.ts +++ b/frontend/src/utils/money.ts @@ -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;