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,
|
unitPriceMinor: li.unit_price_minor,
|
||||||
discountPercent: li.discount_percent == null ? 0 : Number(li.discount_percent),
|
discountPercent: li.discount_percent == null ? 0 : Number(li.discount_percent),
|
||||||
lineTotalMinor: li.line_total_minor,
|
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 ? {
|
recipient: customer ? {
|
||||||
displayName: customer.display_name || [customer.first_name, customer.last_name].filter(Boolean).join(' '),
|
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 { Button } from '../common';
|
||||||
import { customerAdminService } from '../../services/customerAdmin.service';
|
import { customerAdminService } from '../../services/customerAdmin.service';
|
||||||
import { eventsService } from '../../services/events.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 {
|
interface SelectedEvent {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ import { useMutationWithToast } from '../../hooks';
|
|||||||
const ACCOUNT_TYPES: AccountType[] = ['asset', 'liability', 'equity', 'revenue', 'expense'];
|
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 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 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_debitoren', 'ledger_account_kreditoren', 'ledger_account_bank', 'ledger_account_cash',
|
||||||
'ledger_account_default_revenue', 'ledger_account_default_expense',
|
'ledger_account_default_revenue', 'ledger_account_default_expense',
|
||||||
'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue',
|
'ledger_account_mileage', 'ledger_account_per_diem', 'ledger_account_rebilled_revenue',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 type { LucideIcon } from 'lucide-react';
|
||||||
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
|
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
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 StarterKit from '@tiptap/starter-kit';
|
||||||
import Link from '@tiptap/extension-link';
|
import Link from '@tiptap/extension-link';
|
||||||
import HardBreak from '@tiptap/extension-hard-break';
|
import HardBreak from '@tiptap/extension-hard-break';
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ interface GalleryPreviewBranding {
|
|||||||
logo_url?: string;
|
logo_url?: string;
|
||||||
logo_url_dark?: string;
|
logo_url_dark?: string;
|
||||||
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
|
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 {
|
interface GalleryPreviewProps {
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { Button, Input, Card } from '../common';
|
import { Button, Input, Card } from '../common';
|
||||||
import { adminService } from '../../services/admin.service';
|
import { adminService } from '../../services/admin.service';
|
||||||
import { useAdminAuth } from '../../contexts';
|
|
||||||
|
|
||||||
export const MandatoryPasswordChangeModal: React.FC = () => {
|
export const MandatoryPasswordChangeModal: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { updatePasswordChanged } = useAdminAuth();
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
|||||||
onChange={handleThemeChange}
|
onChange={handleThemeChange}
|
||||||
presetName={presetName}
|
presetName={presetName}
|
||||||
onPresetChange={handlePresetChange}
|
onPresetChange={handlePresetChange}
|
||||||
isPreviewMode={true}
|
|
||||||
showGalleryLayouts={true}
|
showGalleryLayouts={true}
|
||||||
hideActions={true}
|
hideActions={true}
|
||||||
cssTemplates={cssTemplates}
|
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">
|
<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">
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{t('admin.updates.updateDialog.title', 'Update PicPeak')}
|
{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">
|
<span className="ml-2 text-blue-600 dark:text-blue-400">
|
||||||
v{data.targetVersion}
|
v{data?.targetVersion || targetVersion}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ interface LocalizedDateInputProps {
|
|||||||
value: string;
|
value: string;
|
||||||
onChange: (iso: string) => void;
|
onChange: (iso: string) => void;
|
||||||
error?: string;
|
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. */
|
/** Forwarded to the native picker so min/max date constraints work. */
|
||||||
min?: string;
|
min?: string;
|
||||||
max?: string;
|
max?: string;
|
||||||
@@ -33,6 +35,7 @@ export const LocalizedDateInput: React.FC<LocalizedDateInputProps> = ({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
error,
|
error,
|
||||||
|
helperText,
|
||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
disabled,
|
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')}
|
className={clsx('input pr-10', error && 'border-red-500 focus-visible:ring-red-500')}
|
||||||
aria-invalid={error ? 'true' : 'false'}
|
aria-invalid={error ? 'true' : 'false'}
|
||||||
aria-describedby={error ? `${inputId}-error` : undefined}
|
aria-describedby={
|
||||||
|
error ? `${inputId}-error` : helperText ? `${inputId}-helper` : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -189,6 +194,11 @@ export const LocalizedDateInput: React.FC<LocalizedDateInputProps> = ({
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{helperText && !error && (
|
||||||
|
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{helperText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</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_position: settingsData.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer',
|
||||||
// Promo alignment (#482). Defaults to 'center' to match the
|
// Promo alignment (#482). Defaults to 'center' to match the
|
||||||
// gallery footer; see GalleryLayout.
|
// gallery footer; see GalleryLayout.
|
||||||
promo_alignment: ['left', 'center', 'right'].includes(settingsData.branding_promo_alignment)
|
promo_alignment: settingsData.branding_promo_alignment || 'center',
|
||||||
? settingsData.branding_promo_alignment
|
|
||||||
: 'center',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [settingsData]);
|
}, [settingsData]);
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ interface PhotoGridWithLayoutsProps {
|
|||||||
allowFavorites?: boolean;
|
allowFavorites?: boolean;
|
||||||
allowRatings?: boolean;
|
allowRatings?: boolean;
|
||||||
allowComments?: boolean;
|
allowComments?: boolean;
|
||||||
|
allowReactions?: boolean;
|
||||||
requireNameEmail?: boolean;
|
requireNameEmail?: boolean;
|
||||||
};
|
};
|
||||||
onFeedbackChange?: () => void;
|
onFeedbackChange?: () => void;
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export interface FaceBox {
|
|||||||
*/
|
*/
|
||||||
export function faceCropStyle(
|
export function faceCropStyle(
|
||||||
cover: FaceBox | null | undefined,
|
cover: FaceBox | null | undefined,
|
||||||
photoWidth: number | undefined,
|
photoWidth: number | null | undefined,
|
||||||
photoHeight: number | undefined,
|
photoHeight: number | null | undefined,
|
||||||
size: number,
|
size: number,
|
||||||
): React.CSSProperties | null {
|
): React.CSSProperties | null {
|
||||||
if (!cover || !photoWidth || !photoHeight) return null;
|
if (!cover || !photoWidth || !photoHeight) return null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ export const DEFAULT_FLAGS: FeatureFlags = {
|
|||||||
// Workflow / automation engine — opt-in; gates the Workflows admin area
|
// Workflow / automation engine — opt-in; gates the Workflows admin area
|
||||||
// and the engine runtime (triggers/actions/gates).
|
// and the engine runtime (triggers/actions/gates).
|
||||||
workflows: false,
|
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;
|
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 {
|
import {
|
||||||
FeedbackLimitReachedModal,
|
FeedbackLimitReachedModal,
|
||||||
} from '../components/gallery/FeedbackLimitReachedModal';
|
} from '../components/gallery/FeedbackLimitReachedModal';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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 { AlertCircle, Lock } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -12,7 +12,6 @@ import { buildResourceUrl } from '../utils/url';
|
|||||||
|
|
||||||
export const ClientAccessPage: React.FC = () => {
|
export const ClientAccessPage: React.FC = () => {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { isAuthenticated, isClient, clientLogin, isLoading: authLoading } = useGalleryAuth();
|
const { isAuthenticated, isClient, clientLogin, isLoading: authLoading } = useGalleryAuth();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -88,7 +87,7 @@ export const ClientAccessPage: React.FC = () => {
|
|||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<img
|
<img
|
||||||
src={buildResourceUrl(brandLogo)}
|
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"
|
className="h-16 w-auto object-contain mx-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +114,7 @@ export const ClientAccessPage: React.FC = () => {
|
|||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<img
|
<img
|
||||||
src={buildResourceUrl(brandLogo)}
|
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"
|
className="h-16 w-auto object-contain mx-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -168,14 +168,8 @@ export const GalleryPage: React.FC = () => {
|
|||||||
themeToApply = settingsData.theme_config;
|
themeToApply = settingsData.theme_config;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject hero photo ID into theme gallery settings
|
// The hero photo ID is injected into gallerySettings by GalleryView,
|
||||||
if (themeToApply && galleryInfo.hero_photo_id) {
|
// which reads it off the /photos response. /info doesn't carry it.
|
||||||
if (themeToApply.gallerySettings) {
|
|
||||||
themeToApply.gallerySettings.heroImageId = galleryInfo.hero_photo_id;
|
|
||||||
} else {
|
|
||||||
themeToApply.gallerySettings = { heroImageId: galleryInfo.hero_photo_id };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply theme. Force color mode is enforced inside ThemeContext.applyTheme
|
// Apply theme. Force color mode is enforced inside ThemeContext.applyTheme
|
||||||
// (it subscribes to public settings) so callers don't have to wrap the
|
// (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 { Navigate, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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 { 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 { toast } from 'react-toastify';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ const SETUP_DOCS_URL =
|
|||||||
const COMMUNITY_LINKS: {
|
const COMMUNITY_LINKS: {
|
||||||
key: string;
|
key: string;
|
||||||
href: 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: '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 },
|
{ 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_favorites: boolean;
|
||||||
allow_reactions: boolean;
|
allow_reactions: boolean;
|
||||||
allow_color_labels: 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;
|
require_name_email: boolean;
|
||||||
moderate_comments: boolean;
|
moderate_comments: boolean;
|
||||||
show_feedback_to_guests: boolean;
|
show_feedback_to_guests: boolean;
|
||||||
identity_mode: 'simple' | 'guest' | 'shared';
|
identity_mode?: 'simple' | 'guest' | 'shared';
|
||||||
enable_rate_limiting: boolean;
|
enable_rate_limiting: boolean;
|
||||||
rate_limit_window_minutes?: number;
|
rate_limit_window_minutes?: number;
|
||||||
rate_limit_max_requests?: number;
|
rate_limit_max_requests?: number;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { toast } from 'react-toastify';
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, Mail, MapPin, Phone, Building2, Save, Trash2, AlertTriangle,
|
ArrowLeft, Mail, MapPin, Phone, Building2, Save, Trash2, AlertTriangle,
|
||||||
CheckCircle2, X, FileText, Calendar, KeyRound, ToggleLeft, Settings as SettingsIcon,
|
CheckCircle2, X, FileText, Calendar, KeyRound, ToggleLeft, Settings as SettingsIcon,
|
||||||
Clock,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Button, Card, CountrySelect, Input, Loading } from '../../components/common';
|
import { Button, Card, CountrySelect, Input, Loading } from '../../components/common';
|
||||||
|
|||||||
@@ -395,7 +395,10 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
translations: {
|
translations: {
|
||||||
...prev.translations,
|
...prev.translations,
|
||||||
[editingLang]: {
|
[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,
|
[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"
|
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" />
|
<Copy className="w-3.5 h-3.5" />
|
||||||
{t('email.copyFrom')} {lang.flag} {lang.name}
|
{t('email.copyFrom')} <lang.Flag/> {lang.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -717,7 +717,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
{showPasswordReset && (
|
{showPasswordReset && (
|
||||||
<PasswordResetModal
|
<PasswordResetModal
|
||||||
eventName={event.event_name}
|
eventName={event.event_name}
|
||||||
eventDate={event.event_date}
|
eventDate={event.event_date ?? undefined}
|
||||||
eventType={event.event_type}
|
eventType={event.event_type}
|
||||||
onConfirm={async (sendEmail, password) => {
|
onConfirm={async (sendEmail, password) => {
|
||||||
const result = await eventsService.resetPassword(event.id, 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';
|
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 } {
|
function periodForPreset(preset: PeriodPreset, today = new Date()): { from: string; to: string } {
|
||||||
const y = today.getFullYear();
|
const y = today.getFullYear();
|
||||||
if (preset === 'thisYear') return { from: `${y}-01-01`, to: `${y}-12-31` };
|
if (preset === 'thisYear') return { from: `${y}-01-01`, to: `${y}-12-31` };
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ export const EventThemeSection: React.FC<EventThemeSectionProps> = ({
|
|||||||
setThemeChanged(true);
|
setThemeChanged(true);
|
||||||
toast.success(t('toast.brandingPaletteSynced', 'Palette synced from Branding.'));
|
toast.success(t('toast.brandingPaletteSynced', 'Palette synced from Branding.'));
|
||||||
}}
|
}}
|
||||||
isPreviewMode={true}
|
|
||||||
showGalleryLayouts={true}
|
showGalleryLayouts={true}
|
||||||
hideActions={true}
|
hideActions={true}
|
||||||
cssTemplates={cssTemplates}
|
cssTemplates={cssTemplates}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { X, Plus, FileText } from 'lucide-react';
|
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 }
|
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<{
|
export const DocumentActionModal: React.FC<{
|
||||||
docType: DocType;
|
docType: DocType;
|
||||||
senderEmail: string;
|
senderEmail: string;
|
||||||
onCompose: (init: { to: string; subject: string; html: string }) => void;
|
onCompose: (init: { to: string; subject: string; html: string }) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
t: (k: string, d?: string) => string;
|
t: TFunction;
|
||||||
}> = ({ docType, senderEmail, onCompose, onClose, t }) => {
|
}> = ({ docType, senderEmail, onCompose, onClose, t }) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const cfg = CONFIG[docType];
|
const cfg = CONFIG[docType];
|
||||||
const [customer, setCustomer] = useState<SelCustomer | null>(null);
|
const [customer, setCustomer] = useState<SelCustomer | null>(null);
|
||||||
const [resolving, setResolving] = useState(true);
|
const [resolving, setResolving] = useState(true);
|
||||||
|
|
||||||
const pick = (c: { id: number; email: string; displayName?: string | null; companyName?: string | null }) =>
|
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 });
|
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).
|
// Resolve the customer from the message's sender address (first match).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -116,6 +117,7 @@ export const DocumentActionModal: React.FC<{
|
|||||||
<CustomerPicker
|
<CustomerPicker
|
||||||
value={customer?.id ?? null}
|
value={customer?.id ?? null}
|
||||||
label={customer?.label || ''}
|
label={customer?.label || ''}
|
||||||
|
isPassive={customer?.isPassive ?? false}
|
||||||
onSelect={pick}
|
onSelect={pick}
|
||||||
onCreate={pick}
|
onCreate={pick}
|
||||||
onClear={() => setCustomer(null)}
|
onClear={() => setCustomer(null)}
|
||||||
@@ -131,7 +133,7 @@ export const DocumentActionModal: React.FC<{
|
|||||||
{customer && (
|
{customer && (
|
||||||
<>
|
<>
|
||||||
<Button variant="primary" onClick={createNew} leftIcon={<Plus className="w-4 h-4" />} className="w-full justify-center">
|
<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>
|
</Button>
|
||||||
|
|
||||||
{cfg.hasExisting && (
|
{cfg.hasExisting && (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
import { X, Send as SendIcon } from 'lucide-react';
|
import { X, Send as SendIcon } from 'lucide-react';
|
||||||
@@ -28,7 +29,7 @@ export const MessageComposer: React.FC<{
|
|||||||
accountKey?: string;
|
accountKey?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSent: () => void;
|
onSent: () => void;
|
||||||
t: (k: string, d?: string) => string;
|
t: TFunction;
|
||||||
}> = ({ init, title, accountKey, onClose, onSent, t }) => {
|
}> = ({ init, title, accountKey, onClose, onSent, t }) => {
|
||||||
const [to, setTo] = useState(init.to);
|
const [to, setTo] = useState(init.to);
|
||||||
const [cc, setCc] = useState(init.cc || '');
|
const [cc, setCc] = useState(init.cc || '');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { TFunction } from 'i18next';
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
@@ -422,7 +423,7 @@ const MessageList: React.FC<{
|
|||||||
search: string;
|
search: string;
|
||||||
selection: Selection;
|
selection: Selection;
|
||||||
onSelect: (s: Selection) => void;
|
onSelect: (s: Selection) => void;
|
||||||
t: (k: string, d?: string) => string;
|
t: TFunction;
|
||||||
}> = ({ folder, queue, received, loading, search, selection, onSelect, t }) => {
|
}> = ({ folder, queue, received, loading, search, selection, onSelect, t }) => {
|
||||||
if (folder.src === 'empty') {
|
if (folder.src === 'empty') {
|
||||||
return (
|
return (
|
||||||
@@ -515,7 +516,7 @@ const ReadingPane: React.FC<{
|
|||||||
onCompose: (init: ComposerInit, title?: string) => void;
|
onCompose: (init: ComposerInit, title?: string) => void;
|
||||||
onOpenDoc: (docType: DocType, senderEmail: string) => void;
|
onOpenDoc: (docType: DocType, senderEmail: string) => void;
|
||||||
onItemAction: (action: 'archive' | 'delete' | 'restore') => 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 }) => {
|
}> = ({ selection, account, identities, flags, folderState, onViewDoc, onOpenAccounting, onCompose, onOpenDoc, onItemAction, t }) => {
|
||||||
const detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
queryKey: ['messages', 'queue', selection?.kind === 'queue' ? selection.id : null],
|
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}>
|
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100" style={{ textWrap: 'balance' } as React.CSSProperties}>
|
||||||
{friendlyType(d.emailType)}
|
{friendlyType(d.emailType)}
|
||||||
@@ -636,7 +637,7 @@ const ReceivedDetail: React.FC<{
|
|||||||
mailboxAddr?: string | null;
|
mailboxAddr?: string | null;
|
||||||
onViewDoc: (id: number) => void;
|
onViewDoc: (id: number) => void;
|
||||||
onOpenAccounting: () => void;
|
onOpenAccounting: () => void;
|
||||||
t: (k: string, d?: string) => string;
|
t: TFunction;
|
||||||
}> = ({ item, mailboxAddr, onViewDoc, onOpenAccounting, t }) => {
|
}> = ({ item, mailboxAddr, onViewDoc, onOpenAccounting, t }) => {
|
||||||
const detail = useQuery({
|
const detail = useQuery({
|
||||||
queryKey: ['messages', 'received', 'item', item.id],
|
queryKey: ['messages', 'received', 'item', item.id],
|
||||||
@@ -703,7 +704,7 @@ const Toolbar: React.FC<{
|
|||||||
onReply?: () => void;
|
onReply?: () => void;
|
||||||
onDoc?: (docType: DocType) => void;
|
onDoc?: (docType: DocType) => void;
|
||||||
onItemAction: (action: 'archive' | 'delete' | 'restore') => void;
|
onItemAction: (action: 'archive' | 'delete' | 'restore') => void;
|
||||||
t: (k: string, d?: string) => string;
|
t: TFunction;
|
||||||
}> = ({ isAcct, flags, folderState, onReply, onDoc, onItemAction, t }) => {
|
}> = ({ 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 Tb: React.FC<{ icon: LucideIcon; label: string; accent?: boolean; onClick?: () => void }> = ({ icon: Icon, label, accent, onClick }) => {
|
||||||
const enabled = !!onClick;
|
const enabled = !!onClick;
|
||||||
@@ -753,7 +754,7 @@ const Toolbar: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────── pdf modal ──
|
// ─────────────────────────────────────────────────────────────── 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 [page, setPage] = useState(1);
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
const [err, setErr] = useState(false);
|
const [err, setErr] = useState(false);
|
||||||
|
|||||||
@@ -5,13 +5,12 @@
|
|||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ArrowLeft, Eye, Send, Copy, ArrowRightCircle, Edit2, Receipt, CheckCircle2, ScrollText, XCircle } from 'lucide-react';
|
import { ArrowLeft, Eye, Send, Copy, ArrowRightCircle, Edit2, Receipt, CheckCircle2, ScrollText, XCircle } from 'lucide-react';
|
||||||
import { Button, Card, Loading } from '../../../components/common';
|
import { Button, Card, Loading } from '../../../components/common';
|
||||||
import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
|
import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
|
||||||
import { quotesService } from '../../../services/quotes.service';
|
import { quotesService } from '../../../services/quotes.service';
|
||||||
import { billsService } from '../../../services/bills.service';
|
|
||||||
import { formatMoney } from '../../../components/admin/LineItemsTable';
|
import { formatMoney } from '../../../components/admin/LineItemsTable';
|
||||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||||
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
||||||
@@ -34,7 +33,6 @@ export const QuoteDetailPage: React.FC = () => {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reciprocal lookup — pulls every invoice whose source_quote_id
|
|
||||||
if (isLoading || !data) return <Loading />;
|
if (isLoading || !data) return <Loading />;
|
||||||
const q = data.quote;
|
const q = data.quote;
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { useMutationWithToast } from '../../../hooks';
|
|||||||
// engine that lacks it.
|
// engine that lacks it.
|
||||||
const IANA_TIMEZONES: string[] = (() => {
|
const IANA_TIMEZONES: string[] = (() => {
|
||||||
try {
|
try {
|
||||||
// @ts-expect-error supportedValuesOf is ES2022, not yet in all TS lib defs
|
|
||||||
return Intl.supportedValuesOf('timeZone') as string[];
|
return Intl.supportedValuesOf('timeZone') as string[];
|
||||||
} catch {
|
} catch {
|
||||||
return ['UTC', 'Europe/Vaduz', 'Europe/Zurich', 'Europe/Berlin', 'Europe/Vienna', 'Europe/Paris', 'Europe/London'];
|
return ['UTC', 'Europe/Vaduz', 'Europe/Zurich', 'Europe/Berlin', 'Europe/Vienna', 'Europe/Paris', 'Europe/London'];
|
||||||
|
|||||||
@@ -9,13 +9,14 @@
|
|||||||
*/
|
*/
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
interface CustomerComingSoonPageProps {
|
interface CustomerComingSoonPageProps {
|
||||||
titleKey: string;
|
titleKey: string;
|
||||||
titleFallback: string;
|
titleFallback: string;
|
||||||
bodyKey: string;
|
bodyKey: string;
|
||||||
bodyFallback: string;
|
bodyFallback: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: LucideIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CustomerComingSoonPage: React.FC<CustomerComingSoonPageProps> = ({
|
export const CustomerComingSoonPage: React.FC<CustomerComingSoonPageProps> = ({
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { useCustomerAuth } from '../../contexts/CustomerAuthContext';
|
import { useCustomerAuth } from '../../contexts/CustomerAuthContext';
|
||||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||||
@@ -37,7 +38,7 @@ interface NavItem {
|
|||||||
to: string;
|
to: string;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
fallback: string;
|
fallback: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: LucideIcon;
|
||||||
/**
|
/**
|
||||||
* Optional gate — entry only renders when the matching feature is
|
* Optional gate — entry only renders when the matching feature is
|
||||||
* effective for this customer (i.e. global toggle ON and per-customer
|
* 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 [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
|
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
|
||||||
// Theme-aware logo: the customer surface follows branding_force_color_mode
|
// Theme-aware logo: the customer surface follows branding_force_color_mode.
|
||||||
// ('auto' → OS preference). Symmetric fallback so a single uploaded logo
|
// Only 'dark' and 'light' are persisted; null/absent means "follow the OS
|
||||||
// serves both modes.
|
// preference" — same resolution order as usePublicDarkMode. Symmetric
|
||||||
|
// fallback so a single uploaded logo serves both modes.
|
||||||
const forceMode = settingsData?.branding_force_color_mode;
|
const forceMode = settingsData?.branding_force_color_mode;
|
||||||
const customerIsDark = forceMode === 'dark'
|
const customerIsDark = forceMode === 'dark'
|
||||||
|| (forceMode === 'auto' && typeof window !== 'undefined'
|
|| (forceMode !== 'light' && typeof window !== 'undefined'
|
||||||
&& window.matchMedia?.('(prefers-color-scheme: dark)').matches);
|
&& window.matchMedia?.('(prefers-color-scheme: dark)').matches);
|
||||||
const lightLogo = settingsData?.branding_logo_url?.trim();
|
const lightLogo = settingsData?.branding_logo_url?.trim();
|
||||||
const darkLogo = settingsData?.branding_logo_url_dark?.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 [brandingSettings, setBrandingSettings] = useState<any>(null);
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
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 mockPhotos = useMemo(() => generateMockPhotos(12), []);
|
||||||
const mockEvent = {
|
const mockEvent = {
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ export function SlideshowPage() {
|
|||||||
transition: state.transition,
|
transition: state.transition,
|
||||||
transition_ms: state.transition_ms,
|
transition_ms: state.transition_ms,
|
||||||
colorfilter: state.colorfilter,
|
colorfilter: state.colorfilter,
|
||||||
|
order: state.order,
|
||||||
fit: state.fit,
|
fit: state.fit,
|
||||||
watermark: state.watermark,
|
watermark: state.watermark,
|
||||||
qr: state.qr,
|
qr: state.qr,
|
||||||
@@ -242,6 +243,7 @@ export function SlideshowPage() {
|
|||||||
transition: prev.transition,
|
transition: prev.transition,
|
||||||
transition_ms: prev.transition_ms,
|
transition_ms: prev.transition_ms,
|
||||||
colorfilter: prev.colorfilter,
|
colorfilter: prev.colorfilter,
|
||||||
|
order: prev.order,
|
||||||
fit: prev.fit,
|
fit: prev.fit,
|
||||||
watermark: prev.watermark,
|
watermark: prev.watermark,
|
||||||
qr: prev.qr,
|
qr: prev.qr,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { TFunction } from 'i18next';
|
||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
|
|
||||||
export type InboundStatus = 'unsorted' | 'categorized' | 'declined' | 'duplicate';
|
export type InboundStatus = 'unsorted' | 'categorized' | 'declined' | 'duplicate';
|
||||||
@@ -282,7 +283,7 @@ const SEED_CATEGORY_KEYS: Record<string, string> = {
|
|||||||
'Weiterbildung': 'training',
|
'Weiterbildung': 'training',
|
||||||
'Sonstiges': 'other',
|
'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);
|
if (cat?.is_seed && SEED_CATEGORY_KEYS[cat.name]) return t(`accounting.category.${SEED_CATEGORY_KEYS[cat.name]}`, cat.name);
|
||||||
return cat?.name ?? '';
|
return cat?.name ?? '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -578,7 +578,6 @@ export const adminService = {
|
|||||||
'customer_created_passive': `Passive customer created: ${md.email || ''}`,
|
'customer_created_passive': `Passive customer created: ${md.email || ''}`,
|
||||||
'admin_user_activated': `Admin user activated: ${md.username || ''}`,
|
'admin_user_activated': `Admin user activated: ${md.username || ''}`,
|
||||||
'admin_user_deleted': `Admin user deleted: ${md.username || ''}`,
|
'admin_user_deleted': `Admin user deleted: ${md.username || ''}`,
|
||||||
'admin_password_reset': `Admin password reset: ${md.username || ''}`,
|
|
||||||
// Misc / legacy.
|
// Misc / legacy.
|
||||||
'bulk_archive_completed': `Bulk archive completed: ${md.count || 0} events archived`,
|
'bulk_archive_completed': `Bulk archive completed: ${md.count || 0} events archived`,
|
||||||
'email_queue_flushed': 'Email queue flushed',
|
'email_queue_flushed': 'Email queue flushed',
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ export interface InvoiceSummary {
|
|||||||
* (migration 128). Carries status 'scheduled' but never auto-sends
|
* (migration 128). Carries status 'scheduled' but never auto-sends
|
||||||
* (manual) — shown with a "Draft" badge in the list. */
|
* (manual) — shown with a "Draft" badge in the list. */
|
||||||
isMonthlyDraft?: boolean;
|
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 ----
|
// ---- auth ----
|
||||||
async login(email: string, password: string, recaptchaToken?: string | null): Promise<{
|
async login(email: string, password: string, recaptchaToken?: string | null): Promise<{
|
||||||
customer: CustomerProfile;
|
customer: CustomerProfile;
|
||||||
features: { calendar: boolean; quotes: boolean; bills: boolean };
|
features: { calendar: boolean; quotes: boolean; bills: boolean; contracts: boolean };
|
||||||
branding: { showLogo: boolean; showCompanyName: boolean };
|
branding: { showLogo: boolean; showCompanyName: boolean };
|
||||||
}> {
|
}> {
|
||||||
const response = await api.post<{
|
const response = await api.post<{
|
||||||
customer: CustomerProfile;
|
customer: CustomerProfile;
|
||||||
features?: { calendar: boolean; quotes: boolean; bills: boolean };
|
features?: { calendar: boolean; quotes: boolean; bills: boolean; contracts: boolean };
|
||||||
branding?: { showLogo: boolean; showCompanyName: boolean };
|
branding?: { showLogo: boolean; showCompanyName: boolean };
|
||||||
}>(
|
}>(
|
||||||
'/customer/auth/login',
|
'/customer/auth/login',
|
||||||
@@ -112,7 +112,7 @@ export const customerService = {
|
|||||||
// upgraded yet — defaults match CustomerAuthContext's DEFAULT_*.
|
// upgraded yet — defaults match CustomerAuthContext's DEFAULT_*.
|
||||||
return {
|
return {
|
||||||
customer: response.data.customer,
|
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 },
|
branding: response.data.branding || { showLogo: true, showCompanyName: true },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -145,18 +145,18 @@ export const customerService = {
|
|||||||
*/
|
*/
|
||||||
async session(): Promise<{
|
async session(): Promise<{
|
||||||
customer: CustomerProfile;
|
customer: CustomerProfile;
|
||||||
features: { calendar: boolean; quotes: boolean; bills: boolean };
|
features: { calendar: boolean; quotes: boolean; bills: boolean; contracts: boolean };
|
||||||
branding: { showLogo: boolean; showCompanyName: boolean };
|
branding: { showLogo: boolean; showCompanyName: boolean };
|
||||||
} | null> {
|
} | null> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get<{
|
const response = await api.get<{
|
||||||
customer: CustomerProfile;
|
customer: CustomerProfile;
|
||||||
features?: { calendar: boolean; quotes: boolean; bills: boolean };
|
features?: { calendar: boolean; quotes: boolean; bills: boolean; contracts: boolean };
|
||||||
branding?: { showLogo: boolean; showCompanyName: boolean };
|
branding?: { showLogo: boolean; showCompanyName: boolean };
|
||||||
}>('/customer/auth/session');
|
}>('/customer/auth/session');
|
||||||
return {
|
return {
|
||||||
customer: response.data.customer,
|
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 },
|
branding: response.data.branding || { showLogo: true, showCompanyName: true },
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ interface UpdateEventData {
|
|||||||
admin_email?: string;
|
admin_email?: string;
|
||||||
require_password?: boolean;
|
require_password?: boolean;
|
||||||
password?: string;
|
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;
|
welcome_message?: string;
|
||||||
color_theme?: string;
|
color_theme?: string;
|
||||||
expires_at?: string;
|
expires_at?: string;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { AxiosResponse } from 'axios';
|
||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import type {
|
import type {
|
||||||
GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier,
|
GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier,
|
||||||
@@ -148,7 +149,7 @@ export const galleryService = {
|
|||||||
slug: string,
|
slug: string,
|
||||||
photoId: number,
|
photoId: number,
|
||||||
): Promise<{ blob: Blob; serverFilename: string | null }> {
|
): Promise<{ blob: Blob; serverFilename: string | null }> {
|
||||||
const readResponse = (response: { data: Blob; headers: Record<string, string> }) => {
|
const readResponse = (response: AxiosResponse<Blob>) => {
|
||||||
const headerName =
|
const headerName =
|
||||||
response.headers['content-disposition'] || response.headers['Content-Disposition'];
|
response.headers['content-disposition'] || response.headers['Content-Disposition'];
|
||||||
return {
|
return {
|
||||||
@@ -158,7 +159,7 @@ export const galleryService = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
|
const response = await api.get<Blob>(`/gallery/${slug}/download/${photoId}`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
return readResponse(response);
|
return readResponse(response);
|
||||||
@@ -167,7 +168,7 @@ export const galleryService = {
|
|||||||
// the original is missing and only a derivative remains). The
|
// the original is missing and only a derivative remains). The
|
||||||
// view endpoint doesn't emit a download-oriented Content-Disposition,
|
// view endpoint doesn't emit a download-oriented Content-Disposition,
|
||||||
// so serverFilename will be null and the caller's name wins.
|
// 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',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
return readResponse(response);
|
return readResponse(response);
|
||||||
|
|||||||
@@ -415,9 +415,31 @@ export interface FilteredPhotosResponse {
|
|||||||
summary: FilterSummary;
|
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 {
|
export interface ExportOptions {
|
||||||
photo_ids?: number[];
|
photo_ids?: number[];
|
||||||
filter?: FeedbackFilters;
|
filter?: ExportFilter;
|
||||||
format: 'txt' | 'csv' | 'xmp' | 'json';
|
format: 'txt' | 'csv' | 'xmp' | 'json';
|
||||||
options?: {
|
options?: {
|
||||||
filename_format?: 'original' | 'picpeak';
|
filename_format?: 'original' | 'picpeak';
|
||||||
@@ -427,6 +449,8 @@ export interface ExportOptions {
|
|||||||
include_label?: boolean;
|
include_label?: boolean;
|
||||||
include_description?: boolean;
|
include_description?: boolean;
|
||||||
include_keywords?: 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;
|
unitPriceMinor: number;
|
||||||
discountPercent: number;
|
discountPercent: number;
|
||||||
lineTotalMinor: 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_*`
|
/** Terms of Service block driven by the global `crm_quotes_tos_*`
|
||||||
* settings. When `required` is true, the public page must show a
|
* settings. When `required` is true, the public page must show a
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
|
import type { SlideshowGlobalDefaults } from './slideshow.service';
|
||||||
|
|
||||||
export interface BrandingSettings {
|
export interface BrandingSettings {
|
||||||
company_name: string;
|
company_name: string;
|
||||||
@@ -213,7 +214,7 @@ export const settingsService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Update global Live Slideshow defaults (watermark)
|
// 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);
|
await api.put('/admin/settings/slideshow', settings);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ export interface GalleryInfo {
|
|||||||
requires_password?: boolean;
|
requires_password?: boolean;
|
||||||
color_theme?: string;
|
color_theme?: string;
|
||||||
default_photo_sort?: string;
|
default_photo_sort?: string;
|
||||||
|
allow_downloads?: boolean;
|
||||||
|
allow_user_uploads?: boolean;
|
||||||
// Resolved server-side (#894): false only when the admin hid the logo
|
// Resolved server-side (#894): false only when the admin hid the logo
|
||||||
// on this gallery's password page.
|
// on this gallery's password page.
|
||||||
login_logo_visible?: boolean;
|
login_logo_visible?: boolean;
|
||||||
@@ -278,6 +280,8 @@ export interface GalleryData {
|
|||||||
upload_category_id?: number | null;
|
upload_category_id?: number | null;
|
||||||
hero_photo_id?: number | null;
|
hero_photo_id?: number | null;
|
||||||
allow_downloads?: boolean;
|
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;
|
disable_right_click?: boolean;
|
||||||
watermark_downloads?: boolean;
|
watermark_downloads?: boolean;
|
||||||
watermark_text?: string;
|
watermark_text?: string;
|
||||||
@@ -355,8 +359,8 @@ export interface AdminUser {
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
lastLogin?: string | null;
|
lastLogin?: string | null;
|
||||||
lastLoginIp?: string | null;
|
lastLoginIp?: string | null;
|
||||||
createdAt?: string;
|
createdAt?: string | null;
|
||||||
updatedAt?: string;
|
updatedAt?: string | null;
|
||||||
createdByUsername?: string;
|
createdByUsername?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export interface FormatMoneyOptions {
|
|||||||
fractionDigits?: number;
|
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 locale = opts?.locale || languageToLocale(i18next.language);
|
||||||
const numFormatOpts: Intl.NumberFormatOptions = {
|
const numFormatOpts: Intl.NumberFormatOptions = {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
@@ -81,10 +81,13 @@ function buildFormatter(currency: string, opts?: FormatMoneyOptions): Intl.Numbe
|
|||||||
* currency string. Pass `*_amount_minor` columns through
|
* currency string. Pass `*_amount_minor` columns through
|
||||||
* {@link formatMoneyMinor} instead — passing minor units here yields
|
* {@link formatMoneyMinor} instead — passing minor units here yields
|
||||||
* a 100× over-display.
|
* 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(
|
export function formatMoney(
|
||||||
amount: number,
|
amount: number,
|
||||||
currency: string,
|
currency: string | null | undefined,
|
||||||
opts?: FormatMoneyOptions,
|
opts?: FormatMoneyOptions,
|
||||||
): string {
|
): string {
|
||||||
const safe = Number.isFinite(amount) ? amount : 0;
|
const safe = Number.isFinite(amount) ? amount : 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user