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:
@@ -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;
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
FeedbackLimitReachedModal,
|
||||
} from '../components/gallery/FeedbackLimitReachedModal';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 },
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? '';
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user