Merge pull request #603 from Luca-Timo/feat/crm-improvements
CRM improvements: invoicing & payments, hours, email queue/scheduling, branding (dark mode + favicon), country pickers
This commit is contained in:
+9
-1
@@ -2,7 +2,15 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<!-- Point the favicon at the backend's dynamic /favicon.ico route so the
|
||||
admin-configured branding favicon is used from the very first paint,
|
||||
in every browser. CRITICAL: a hardcoded href here (e.g. the bundled
|
||||
/favicon-32x32.png) makes the browser use THAT and never request
|
||||
/favicon.ico — Safari then shows the default and ignores the JS that
|
||||
DynamicFavicon uses to swap it. No type/sizes so the byte stream
|
||||
(png/ico/svg) is honoured via the response content-type. -->
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<!--
|
||||
Static fallback title + Open Graph defaults (#521).
|
||||
|
||||
@@ -184,6 +184,40 @@ server {
|
||||
proxy_set_header X-Forwarded-Proto $real_proto;
|
||||
}
|
||||
|
||||
# Dynamic favicon / apple-touch-icon served by backend (resolves the
|
||||
# admin-configured branding favicon, falls back to the bundled asset).
|
||||
# Exact-match (=) wins over the static-asset regex below, so these reach
|
||||
# the backend instead of the build dir. Browsers (especially Safari)
|
||||
# request these at the site root regardless of any JS-injected
|
||||
# <link rel="icon">.
|
||||
location = /favicon.ico {
|
||||
set $backend_upstream backend;
|
||||
proxy_pass http://$backend_upstream:3000/favicon.ico;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $real_proto;
|
||||
}
|
||||
location = /apple-touch-icon.png {
|
||||
set $backend_upstream backend;
|
||||
proxy_pass http://$backend_upstream:3000/apple-touch-icon.png;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $real_proto;
|
||||
}
|
||||
location = /apple-touch-icon-precomposed.png {
|
||||
set $backend_upstream backend;
|
||||
proxy_pass http://$backend_upstream:3000/apple-touch-icon-precomposed.png;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $real_proto;
|
||||
}
|
||||
|
||||
# Delegate root requests to backend for public landing page handling
|
||||
location = / {
|
||||
# Use variable to force DNS resolution per request (required for Docker Swarm)
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ArchivesPage,
|
||||
AnalyticsPage,
|
||||
SettingsPage,
|
||||
SystemHealthPage,
|
||||
UserManagementPage,
|
||||
CustomerManagementPage,
|
||||
CustomerDetailPage,
|
||||
@@ -267,6 +268,7 @@ function App() {
|
||||
<Route path="customers/:id" element={<RedirectCustomerDetail />} />
|
||||
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="system-health" element={<SystemHealthPage />} />
|
||||
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
|
||||
|
||||
{/* Old top-level routes — these surfaces now live as
|
||||
|
||||
@@ -35,7 +35,11 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
const currentLanguage = SUPPORTED_LANGUAGES.find(lang => lang.code === i18n.language) || SUPPORTED_LANGUAGES[0];
|
||||
|
||||
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
|
||||
const logoUrl = brandingSettings?.branding_logo_url?.trim();
|
||||
// Dark-mode logo variant. Symmetric fallback: if only one logo is set,
|
||||
// use it for both modes (dark → dark||light, light → light||dark).
|
||||
const lightLogo = brandingSettings?.branding_logo_url?.trim();
|
||||
const darkLogo = brandingSettings?.branding_logo_url_dark?.trim();
|
||||
const logoUrl = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
|
||||
const logoDisplayMode = brandingSettings?.branding_logo_display_mode || 'logo_and_text';
|
||||
// Logo placement honours the same Branding > Logo Position setting
|
||||
// the gallery does. 'sidepanel' moves the logo into the AdminSidebar
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
@@ -10,6 +9,7 @@ import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../..
|
||||
import { Button } from '../common';
|
||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
import { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
type AdminFeedbackResponse = {
|
||||
feedback: PhotoFeedback[];
|
||||
@@ -38,6 +38,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
const [showCategoryMenu, setShowCategoryMenu] = useState(false);
|
||||
const [expandedComments, setExpandedComments] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
|
||||
const currentPhoto = photos[currentIndex];
|
||||
const isVideo = currentPhoto
|
||||
@@ -312,7 +313,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
Uploaded
|
||||
</span>
|
||||
<p className="text-white">
|
||||
{format(new Date(currentPhoto.uploaded_at), 'MMM d, yyyy h:mm a')}
|
||||
{fmtDateTime(currentPhoto.uploaded_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -408,7 +409,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
{comment.guest_name || 'Anonymous'}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-400">
|
||||
{format(new Date(comment.created_at), 'MMM d, yyyy h:mm a')}
|
||||
{fmtDateTime(comment.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Archive,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Activity,
|
||||
X,
|
||||
Users,
|
||||
Briefcase,
|
||||
@@ -17,6 +18,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { VersionInfo } from './VersionInfo';
|
||||
import { usePermissions } from '../../contexts/PermissionsContext';
|
||||
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
|
||||
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
@@ -62,6 +64,7 @@ const navigation: NavItem[] = [
|
||||
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' },
|
||||
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' },
|
||||
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
|
||||
{ nameKey: 'navigation.systemHealth', href: '/admin/system-health', icon: Activity, permission: 'settings.view' },
|
||||
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view', featureFlag: 'userManagement' },
|
||||
// Clients section (#354 follow-up) — admin-side surface for the
|
||||
// CRM-area sub-features. Today this entry leads to /admin/clients
|
||||
@@ -104,8 +107,12 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, col
|
||||
// chosen, the logo replaces the "PicPeak Admin" text in the brand
|
||||
// row, and the favicon takes over in the collapsed icon rail.
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
const { isDark } = useAdminDarkMode();
|
||||
const logoInSidebar = publicSettings?.branding_logo_position === 'sidepanel';
|
||||
const rawLogoUrl = publicSettings?.branding_logo_url?.trim();
|
||||
// Theme-aware logo with symmetric fallback (one logo serves both modes).
|
||||
const lightLogo = publicSettings?.branding_logo_url?.trim();
|
||||
const darkLogo = publicSettings?.branding_logo_url_dark?.trim();
|
||||
const rawLogoUrl = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
|
||||
const rawFaviconUrl = publicSettings?.branding_favicon_url?.trim();
|
||||
const resolvedLogoUrl = rawLogoUrl
|
||||
? (rawLogoUrl.startsWith('http') ? rawLogoUrl : buildResourceUrl(rawLogoUrl))
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
CheckCircle,
|
||||
User
|
||||
} from 'lucide-react';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Card, Loading, Button } from '../common';
|
||||
@@ -30,7 +29,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
|
||||
maxItems = 5
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { formatDateTime } = useLocalizedDate();
|
||||
const queryClient = useQueryClient();
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
@@ -116,12 +115,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
|
||||
</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400">•</span>
|
||||
<span className="text-neutral-500 dark:text-neutral-400">
|
||||
{format(
|
||||
typeof item.created_at === 'string'
|
||||
? parseISO(item.created_at)
|
||||
: new Date(item.created_at),
|
||||
'MMM d, h:mm a'
|
||||
)}
|
||||
{formatDateTime(item.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-neutral-700">{item.comment_text || item.comment}</p>
|
||||
|
||||
@@ -8,6 +8,7 @@ interface GalleryPreviewBranding {
|
||||
company_name?: string;
|
||||
company_tagline?: string;
|
||||
logo_url?: string;
|
||||
logo_url_dark?: string;
|
||||
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
|
||||
logo_position?: 'left' | 'center' | 'right';
|
||||
}
|
||||
@@ -82,10 +83,13 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
const showText = displayMode === 'text_only' || displayMode === 'logo_and_text';
|
||||
const brandName = branding?.company_name?.trim() || 'Your Studio';
|
||||
const brandTagline = branding?.company_tagline?.trim() || '';
|
||||
const resolvedLogoUrl = showLogo && branding?.logo_url
|
||||
? (branding.logo_url.startsWith('http')
|
||||
? branding.logo_url
|
||||
: buildResourceUrl(branding.logo_url))
|
||||
// Theme-aware logo with symmetric fallback — mirror the live surfaces
|
||||
// so the preview reflects what the gallery will actually show.
|
||||
const previewLogo = theme.colorMode === 'dark'
|
||||
? (branding?.logo_url_dark || branding?.logo_url)
|
||||
: (branding?.logo_url || branding?.logo_url_dark);
|
||||
const resolvedLogoUrl = showLogo && previewLogo
|
||||
? (previewLogo.startsWith('http') ? previewLogo : buildResourceUrl(previewLogo))
|
||||
: null;
|
||||
const logoPosition = branding?.logo_position || 'left';
|
||||
const brandFlexClass = logoPosition === 'center'
|
||||
|
||||
@@ -15,9 +15,10 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Clock } from 'lucide-react';
|
||||
import { Button, Card } from '../common';
|
||||
import { Clock, AlertTriangle } from 'lucide-react';
|
||||
import { Button, Card, LocalizedDateInput, TimeField } from '../common';
|
||||
import { DecimalInput } from '../common/DecimalInput';
|
||||
import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
|
||||
import { customerAdminService } from '../../services/customerAdmin.service';
|
||||
@@ -45,13 +46,8 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { format: fmtDate, formatTime: fmtTime, timeFormat } = useLocalizedDate();
|
||||
// `lang` hint on <input type="time"> nudges Chrome/Edge to render the
|
||||
// picker in the matching clock convention (de-DE → 24h, en-US → 12h).
|
||||
// Safari/Firefox follow OS locale and ignore this — that's a browser
|
||||
// limitation, not something we can fix in the page. The underlying
|
||||
// value stays HH:mm (24h) regardless of how the picker presents it.
|
||||
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
|
||||
const navigate = useNavigate();
|
||||
const { format: fmtDate, formatTime: fmtTime } = useLocalizedDate();
|
||||
const [entryDate, setEntryDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [startTime, setStartTime] = useState('09:00');
|
||||
const [endTime, setEndTime] = useState('10:00');
|
||||
@@ -91,6 +87,22 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const profileDefaultCurrency = profileSnapshot?.profile?.defaultCurrency || 'CHF';
|
||||
// Install-wide fallback rate (migration 113). Last link in the rate
|
||||
// chain after the per-entry override and the per-customer default.
|
||||
const installDefaultRateMinor = profileSnapshot?.profile?.defaultHourlyRateMinor ?? null;
|
||||
// The rate that applies to a NEW entry when no per-entry override is
|
||||
// typed: customer rate, else the install default. null = neither set,
|
||||
// so a save would fail unless the admin enters an override.
|
||||
const effectiveDefaultRateMinor = customerHourlyRateMinor ?? installDefaultRateMinor;
|
||||
// True when there's genuinely no rate to bill at — drives the inline
|
||||
// CTA + disables the save button. An override typed in the form lifts
|
||||
// this (handled below where the button is rendered).
|
||||
const noRateConfigured = effectiveDefaultRateMinor == null;
|
||||
const overrideTyped = (() => {
|
||||
if (!rateOverride.trim()) return false;
|
||||
const n = parseLocaleDecimal(rateOverride);
|
||||
return Number.isFinite(n) && n >= 0;
|
||||
})();
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => customerAdminService.createHourEntry(customerId, {
|
||||
@@ -114,7 +126,17 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
toast.success(t('customers.hours.toast.created', 'Entry logged'));
|
||||
},
|
||||
onError: (err: any) => {
|
||||
const msg = err?.response?.data?.error || err?.message || 'Failed to log entry';
|
||||
// The save-time "no rate" failure is translated here off the
|
||||
// backend error code (the raw message is English-only). The inline
|
||||
// guard below normally prevents this, but a race (rate cleared in
|
||||
// another tab) can still surface it.
|
||||
if (err?.response?.data?.code === 'HOURLY_RATE_REQUIRED') {
|
||||
toast.error(t('customers.hours.error.noRate',
|
||||
'No hourly rate set for this customer. Enter a rate override, set a rate on the customer, or configure an install-wide default in Settings.'));
|
||||
return;
|
||||
}
|
||||
const msg = err?.response?.data?.error || err?.message
|
||||
|| t('customers.hours.error.createFailed', 'Failed to log entry');
|
||||
toast.error(msg);
|
||||
},
|
||||
});
|
||||
@@ -133,10 +155,13 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
|
||||
const billMutation = useMutation({
|
||||
mutationFn: () => customerAdminService.billUnbilledHourEntries(customerId),
|
||||
onSuccess: () => {
|
||||
onSuccess: ({ invoiceId }) => {
|
||||
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
|
||||
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
|
||||
toast.success(t('customers.hours.toast.billed', 'Hours billed'));
|
||||
// Open the new scheduled invoice so the admin can add other line
|
||||
// items in addition to the hours before it ships.
|
||||
if (invoiceId) navigate(`/admin/clients/bills/${invoiceId}/edit`);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.error || 'Failed to bill hours');
|
||||
@@ -153,11 +178,11 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
for (const e of entries) {
|
||||
if (e.status !== 'unbilled') continue;
|
||||
count += 1;
|
||||
const rateMinor = e.hourlyRateMinorOverride ?? customerHourlyRateMinor ?? 0;
|
||||
const rateMinor = e.hourlyRateMinorOverride ?? effectiveDefaultRateMinor ?? 0;
|
||||
minor += rateMinor * e.durationMinutes / 60;
|
||||
}
|
||||
return { unbilledCount: count, unbilledTotalMajor: minor / 100 };
|
||||
}, [entries, customerHourlyRateMinor]);
|
||||
}, [entries, effectiveDefaultRateMinor]);
|
||||
const isMonthly = billingCadence === 'monthly';
|
||||
|
||||
// Local lockout check — mirrors customerHoursService.isEntryLocked
|
||||
@@ -181,36 +206,75 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
? t('customers.hours.monthlyHint',
|
||||
'Entries auto-append to the current monthly draft. Edit / delete remains possible until the scheduler arms the draft for send.')
|
||||
: t('customers.hours.perEventHint',
|
||||
'Logged entries stay unbilled until you click "Create draft invoice" — a standalone draft invoice is generated with one line per entry, ready for you to review before sending.')}
|
||||
'Logged entries stay unbilled until you click "Create draft invoice" — one scheduled invoice is generated with a line per entry and opened in the editor, so you can add other items before it ships.')}
|
||||
</p>
|
||||
|
||||
{/* Default rate — hidden in compact mode (history-only on the
|
||||
customer detail page; admin edits the rate elsewhere). */}
|
||||
{/* Rate summary — hidden in compact mode (history-only on the
|
||||
customer detail page). When a caller wires onHourlyRateChange
|
||||
the field is editable; otherwise (the standalone hours page)
|
||||
we show the RESOLVED rate read-only so a disabled input can't
|
||||
masquerade as an editable value, and surface a CTA when no rate
|
||||
is configured anywhere along the chain. */}
|
||||
{!compact && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customers.field.hourlyRate', 'Default hourly rate')}
|
||||
</label>
|
||||
<DecimalInput
|
||||
value={customerHourlyRateMinor != null ? customerHourlyRateMinor / 100 : NaN}
|
||||
fractionDigits={2}
|
||||
onChange={(n) => {
|
||||
if (!onHourlyRateChange) return;
|
||||
if (!Number.isFinite(n)) {
|
||||
onHourlyRateChange(null);
|
||||
return;
|
||||
}
|
||||
onHourlyRateChange(Math.max(0, Math.round(n * 100)));
|
||||
}}
|
||||
disabled={!onHourlyRateChange}
|
||||
className="w-40 input"
|
||||
placeholder="150.00"
|
||||
/>
|
||||
<p className="text-xs text-muted-theme mt-1">
|
||||
{t('customers.field.hourlyRateHint',
|
||||
'Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.',
|
||||
{ currency: profileDefaultCurrency })}
|
||||
</p>
|
||||
{onHourlyRateChange ? (
|
||||
<>
|
||||
<DecimalInput
|
||||
value={customerHourlyRateMinor != null ? customerHourlyRateMinor / 100 : NaN}
|
||||
fractionDigits={2}
|
||||
onChange={(n) => {
|
||||
if (!Number.isFinite(n)) { onHourlyRateChange(null); return; }
|
||||
onHourlyRateChange(Math.max(0, Math.round(n * 100)));
|
||||
}}
|
||||
className="w-40 input"
|
||||
placeholder="150.00"
|
||||
/>
|
||||
<p className="text-xs text-muted-theme mt-1">
|
||||
{t('customers.field.hourlyRateHint',
|
||||
'Major units (e.g. 150.00 for {{currency}} 150). Leave blank to require a per-entry override on every block.',
|
||||
{ currency: profileDefaultCurrency })}
|
||||
</p>
|
||||
</>
|
||||
) : noRateConfigured ? (
|
||||
<div className="rounded-md border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-3 text-sm">
|
||||
<div className="flex items-start gap-2 text-amber-800 dark:text-amber-200">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t('customers.hours.noRate.title', 'No hourly rate configured')}
|
||||
</p>
|
||||
<p className="mt-0.5 text-amber-700 dark:text-amber-300">
|
||||
{t('customers.hours.noRate.body',
|
||||
'Logging needs a rate. Set one for this customer, type a per-entry override below, or configure an install-wide default.')}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-3">
|
||||
<Link to={`/admin/clients/accounts/${customerId}`}
|
||||
className="text-accent-dark hover:underline font-medium">
|
||||
{t('customers.hours.noRate.setForCustomer', 'Set a rate for this customer')}
|
||||
</Link>
|
||||
<Link to="/admin/settings?tab=businessProfile" target="_blank" rel="noopener noreferrer"
|
||||
className="text-accent-dark hover:underline font-medium">
|
||||
{t('customers.hours.noRate.setInstallDefault', 'Set an install-wide default')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-theme">
|
||||
<span className="tabular-nums font-medium">
|
||||
{profileDefaultCurrency} {((effectiveDefaultRateMinor as number) / 100).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-theme ml-2">
|
||||
{customerHourlyRateMinor != null
|
||||
? t('customers.hours.rateSource.customer', 'from this customer')
|
||||
: t('customers.hours.rateSource.installDefault', 'install-wide default')}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -224,22 +288,19 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
{t('customers.hours.form.date', 'Date')}
|
||||
</label>
|
||||
<input type="date" value={entryDate}
|
||||
onChange={(e) => setEntryDate(e.target.value)} className="input w-full" />
|
||||
<LocalizedDateInput value={entryDate} onChange={setEntryDate} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
{t('customers.hours.form.start', 'Start')}
|
||||
</label>
|
||||
<input type="time" lang={timeInputLang} value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)} className="input w-full" />
|
||||
<TimeField value={startTime} onChange={setStartTime} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
{t('customers.hours.form.end', 'End')}
|
||||
</label>
|
||||
<input type="time" lang={timeInputLang} value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)} className="input w-full" />
|
||||
<TimeField value={endTime} onChange={setEndTime} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-muted-theme mb-1">
|
||||
@@ -271,8 +332,8 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
inputMode="decimal"
|
||||
value={rateOverride}
|
||||
onChange={(e) => setRateOverride(e.target.value)}
|
||||
placeholder={customerHourlyRateMinor != null
|
||||
? (customerHourlyRateMinor / 100).toFixed(2)
|
||||
placeholder={effectiveDefaultRateMinor != null
|
||||
? (effectiveDefaultRateMinor / 100).toFixed(2)
|
||||
: '—'}
|
||||
className="input w-full" />
|
||||
</div>
|
||||
@@ -287,10 +348,15 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
placeholder={t('customers.hours.form.notePlaceholder',
|
||||
'What was worked on?') as string} />
|
||||
</div>
|
||||
<div className="mt-3 flex justify-end">
|
||||
<div className="mt-3 flex items-center justify-end gap-3">
|
||||
{noRateConfigured && !overrideTyped && (
|
||||
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||
{t('customers.hours.form.needRate', 'Set a rate or enter an override to log time.')}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={createMutation.isPending}
|
||||
disabled={createMutation.isPending || (noRateConfigured && !overrideTyped)}
|
||||
isLoading={createMutation.isPending}
|
||||
onClick={() => createMutation.mutate()}
|
||||
>
|
||||
@@ -348,7 +414,7 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e) => {
|
||||
const rate = e.hourlyRateMinorOverride ?? customerHourlyRateMinor ?? 0;
|
||||
const rate = e.hourlyRateMinorOverride ?? effectiveDefaultRateMinor ?? 0;
|
||||
const hours = e.durationMinutes / 60;
|
||||
const total = (hours * rate) / 100;
|
||||
const locked = isLocked(e);
|
||||
|
||||
@@ -23,7 +23,7 @@ import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save, Send, X } from 'lucide-react';
|
||||
import { Button, Input } from '../common';
|
||||
import { Button, CountrySelect, Input } from '../common';
|
||||
import {
|
||||
customerAdminService,
|
||||
type CustomerAccountDetail,
|
||||
@@ -136,28 +136,42 @@ export const InlineCustomerCreate: React.FC<Props> = ({ onCreated, onCancel, mod
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const profileDefaultLocale = profileSnapshot?.profile?.defaultLocale || 'en';
|
||||
const profileCountryCode = profileSnapshot?.profile?.countryCode || '';
|
||||
|
||||
// Seed preferredLanguage with the profile default once the profile
|
||||
// arrives (only if the field is still empty so we don't clobber
|
||||
// explicit user input).
|
||||
// Seed preferredLanguage + countryCode with the profile defaults once
|
||||
// the profile arrives (only if the field is still empty so we don't
|
||||
// clobber explicit user input).
|
||||
React.useEffect(() => {
|
||||
if (profileDefaultLocale && !form.preferredLanguage) {
|
||||
setForm((prev) => prev.preferredLanguage ? prev : { ...prev, preferredLanguage: profileDefaultLocale });
|
||||
}
|
||||
setForm((prev) => {
|
||||
const next = { ...prev };
|
||||
if (profileDefaultLocale && !prev.preferredLanguage) next.preferredLanguage = profileDefaultLocale;
|
||||
if (profileCountryCode && !prev.countryCode) next.countryCode = profileCountryCode.toUpperCase();
|
||||
return next;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [profileDefaultLocale]);
|
||||
}, [profileDefaultLocale, profileCountryCode]);
|
||||
|
||||
const setField = (key: keyof FormState) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
|
||||
setForm((prev) => ({ ...prev, [key]: e.target.value }));
|
||||
|
||||
const isValid = !!form.email && /\S+@\S+\.\S+/.test(form.email);
|
||||
const hasEmail = !!form.email && /\S+@\S+\.\S+/.test(form.email);
|
||||
// At least one human-readable identifier so the record isn't a
|
||||
// nameless row that's impossible to recognise in lists later.
|
||||
const hasName = !!(form.companyName.trim() || form.displayName.trim()
|
||||
|| form.firstName.trim() || form.lastName.trim());
|
||||
const isValid = hasEmail && hasName;
|
||||
|
||||
const handleSave = async (mode: 'passive' | 'invite') => {
|
||||
if (!isValid) {
|
||||
if (!hasEmail) {
|
||||
toast.error(t('customers.create.emailRequired', 'A valid email is required.'));
|
||||
return;
|
||||
}
|
||||
if (!hasName) {
|
||||
toast.error(t('customers.create.nameRequired',
|
||||
'Enter at least a company name or a contact name.'));
|
||||
return;
|
||||
}
|
||||
setBusy(mode);
|
||||
try {
|
||||
const customer = await customerAdminService.createDirect(form.email, buildPrefill(form));
|
||||
@@ -298,12 +312,10 @@ export const InlineCustomerCreate: React.FC<Props> = ({ onCreated, onCancel, mod
|
||||
value={form.state}
|
||||
onChange={setField('state')}
|
||||
/>
|
||||
<Input
|
||||
label={t('customers.detail.countryCode', 'Country (ISO code)') as string}
|
||||
<CountrySelect
|
||||
label={t('customers.detail.country', 'Country') as string}
|
||||
value={form.countryCode}
|
||||
onChange={setField('countryCode')}
|
||||
placeholder="CH"
|
||||
maxLength={2}
|
||||
onChange={(code) => setForm((prev) => ({ ...prev, countryCode: code }))}
|
||||
/>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
|
||||
@@ -24,14 +24,15 @@ import {
|
||||
AlertCircle,
|
||||
ShieldCheck
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { Button, Card, Input, Loading } from '../common';
|
||||
import { api } from '../../config/api';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
export const RestoreWizard = ({ onVerifyIntegrity } = {}) => {
|
||||
const { t } = useTranslation();
|
||||
const { format: fmtDate, formatTime: fmtTime, formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
|
||||
const steps = [
|
||||
@@ -331,7 +332,7 @@ export const RestoreWizard = ({ onVerifyIntegrity } = {}) => {
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{format(new Date(backup.created_at), 'PPP')} {t('backup.restore.backup.at')} {format(new Date(backup.created_at), 'p')}
|
||||
{fmtDate(backup.created_at)} {t('backup.restore.backup.at')} {fmtTime(backup.created_at)}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('backup.dashboard.backupType', { type: backup.backup_type })} • {formatBytes(backup.total_size || 0)}
|
||||
@@ -586,7 +587,7 @@ export const RestoreWizard = ({ onVerifyIntegrity } = {}) => {
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.summary.backupDate')}:</dt>
|
||||
<dd className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{format(new Date(restoreData.selectedBackup.created_at), 'PPp')}
|
||||
{fmtDateTime(restoreData.selectedBackup.created_at)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Sent-emails feed — read-only, paginated view of the email_queue table.
|
||||
* Rendered as the "Sent emails" tab inside EmailConfigPage. Pairs with
|
||||
* the "Send queued emails now" flush button on the SMTP tab: flush, then
|
||||
* watch what sent / failed here.
|
||||
*
|
||||
* Filters: status (pending/sent/failed), free-text search (recipient or
|
||||
* type), and a created-at date range. email_data is never fetched.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Search, AlertCircle } from 'lucide-react';
|
||||
import { Button, Card, Loading } from '../common';
|
||||
import { LocalizedDateInput } from '../common/LocalizedDateInput';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { emailService, type EmailQueueStatus } from '../../services/email.service';
|
||||
|
||||
const STATUSES: EmailQueueStatus[] = ['pending', 'sent', 'failed'];
|
||||
|
||||
const statusClass = (s: EmailQueueStatus): string =>
|
||||
s === 'sent' ? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300'
|
||||
: s === 'failed' ? 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
|
||||
: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300';
|
||||
|
||||
export const SentEmailsPanel: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<EmailQueueStatus | null>(null);
|
||||
const [from, setFrom] = useState('');
|
||||
const [to, setTo] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['email-queue', { search, statusFilter, from, to, page }],
|
||||
queryFn: () => emailService.listQueue({
|
||||
q: search || undefined,
|
||||
status: statusFilter || undefined,
|
||||
from: from || undefined,
|
||||
to: to || undefined,
|
||||
page,
|
||||
pageSize: 25,
|
||||
}),
|
||||
});
|
||||
|
||||
const resetTo1 = () => setPage(1);
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
|
||||
{t('email.sentEmails.title', 'Sent emails')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('email.sentEmails.subtitle', 'Delivery status of every queued and sent notification.')}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="relative flex-1 min-w-[220px]">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('email.sentEmails.searchPlaceholder', 'Search by recipient or type…') as string}
|
||||
className="w-full pl-9 pr-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); resetTo1(); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<LocalizedDateInput label={t('email.sentEmails.from', 'From') as string} value={from}
|
||||
onChange={(iso) => { setFrom(iso); resetTo1(); }} />
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<LocalizedDateInput label={t('email.sentEmails.to', 'To') as string} value={to}
|
||||
onChange={(iso) => { setTo(iso); resetTo1(); }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{STATUSES.map((s) => {
|
||||
const active = statusFilter === s;
|
||||
return (
|
||||
<button key={s} type="button"
|
||||
onClick={() => { setStatusFilter(active ? null : s); resetTo1(); }}
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition-colors ${
|
||||
active
|
||||
? 'bg-accent-dark text-white border-accent-dark'
|
||||
: 'bg-white dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-300 dark:border-neutral-600'
|
||||
}`}
|
||||
>{t(`email.sentEmails.status.${s}`, s)}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading ? <Loading /> : !data || data.items.length === 0 ? (
|
||||
<p className="text-center text-neutral-500 dark:text-neutral-400 py-8">
|
||||
{t('email.sentEmails.empty', 'No emails match these filters.')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.recipient', 'Recipient')}</th>
|
||||
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.type', 'Type')}</th>
|
||||
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.status', 'Status')}</th>
|
||||
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.created', 'Queued')}</th>
|
||||
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.sent', 'Sent')}</th>
|
||||
<th className="px-3 py-2 text-left">{t('email.sentEmails.col.event', 'Event')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((m) => (
|
||||
<tr key={m.id} className="border-t border-neutral-200 dark:border-neutral-700 align-top">
|
||||
<td className="px-3 py-2 break-all">{m.recipientEmail}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{m.emailType}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${statusClass(m.status)}`}>
|
||||
{t(`email.sentEmails.status.${m.status}`, m.status)}
|
||||
</span>
|
||||
{m.status === 'failed' && m.errorMessage && (
|
||||
<div className="mt-1 flex items-start gap-1 text-xs text-red-700 dark:text-red-400 max-w-xs">
|
||||
<AlertCircle className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span className="break-words">{m.errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
{m.status === 'pending' && m.retryCount > 0 && (
|
||||
<div className="mt-1 text-xs text-amber-700 dark:text-amber-400">
|
||||
{t('email.sentEmails.retries', '{{count}} retries', { count: m.retryCount })}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{m.createdAt ? fmtDateTime(m.createdAt) : '—'}</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{m.sentAt ? fmtDateTime(m.sentAt) : '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
{m.eventId ? (
|
||||
<Link to={`/admin/events/${m.eventId}`} className="text-accent hover:underline" onClick={(e) => e.stopPropagation()}>
|
||||
{m.eventName || `#${m.eventId}`}
|
||||
</Link>
|
||||
) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{data.pagination.totalPages > 1 && (
|
||||
<div className="flex justify-between items-center px-3 py-2 border-t border-neutral-200 dark:border-neutral-700 text-sm">
|
||||
<span className="text-neutral-500 dark:text-neutral-400">
|
||||
{t('email.sentEmails.pagination', 'Page {{page}} of {{total}} · {{count}} emails', {
|
||||
page: data.pagination.page, total: data.pagination.totalPages, count: data.pagination.total,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||||
{t('common.previous', 'Previous')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= data.pagination.totalPages} onClick={() => setPage((p) => p + 1)}>
|
||||
{t('common.next', 'Next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { countryLabel, sortedCountryOptions } from '../../constants/countries';
|
||||
|
||||
/**
|
||||
* Country picker whose option labels are localized country names but
|
||||
* whose stored/emitted value is always the ISO 3166-1 alpha-2 code.
|
||||
* Labels come from `Intl.DisplayNames` in the active UI language, so the
|
||||
* list stays locale-aware without a hand-maintained translation map.
|
||||
*
|
||||
* A value that isn't in the curated list (e.g. legacy data) is preserved
|
||||
* as its own option so editing an existing record never silently drops it.
|
||||
*/
|
||||
interface CountrySelectProps {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
/** Label for the empty option; defaults to a translated placeholder. */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const CountrySelect: React.FC<CountrySelectProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
disabled,
|
||||
placeholder,
|
||||
}) => {
|
||||
const { i18n, t } = useTranslation();
|
||||
const lang = i18n.language || 'en';
|
||||
const selectId = React.useId();
|
||||
|
||||
const options = sortedCountryOptions(lang);
|
||||
const current = (value || '').trim().toUpperCase();
|
||||
const hasCurrent = options.some((o) => o.code === current);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
id={selectId}
|
||||
value={current}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={clsx('input', error && 'border-red-500 focus-visible:ring-red-500')}
|
||||
aria-invalid={error ? 'true' : 'false'}
|
||||
aria-describedby={error ? `${selectId}-error` : undefined}
|
||||
>
|
||||
<option value="">{placeholder ?? t('common.selectCountry', 'Select country…')}</option>
|
||||
{!hasCurrent && current && (
|
||||
<option value={current}>{countryLabel(current, lang)}</option>
|
||||
)}
|
||||
{options.map((o) => (
|
||||
<option key={o.code} value={o.code}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<p id={`${selectId}-error`} className="mt-1.5 text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,15 +14,37 @@ export const DynamicFavicon: React.FC = () => {
|
||||
const existingFavicons = document.querySelectorAll("link[rel*='icon']");
|
||||
existingFavicons.forEach(favicon => favicon.remove());
|
||||
|
||||
// Create new favicon link
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
link.type = 'image/png';
|
||||
link.href = settings.branding_favicon_url.startsWith('http')
|
||||
// Create new favicon link. Derive the MIME type from the file
|
||||
// extension — hardcoding image/png made SVG (and .ico) favicons
|
||||
// get declared as PNG, which browsers reject (favicon didn't show).
|
||||
const href = settings.branding_favicon_url.startsWith('http')
|
||||
? settings.branding_favicon_url
|
||||
: buildResourceUrl(settings.branding_favicon_url);
|
||||
const ext = href.split('?')[0].split('.').pop()?.toLowerCase();
|
||||
const typeByExt: Record<string, string> = {
|
||||
svg: 'image/svg+xml',
|
||||
png: 'image/png',
|
||||
ico: 'image/x-icon',
|
||||
gif: 'image/gif',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
webp: 'image/webp',
|
||||
};
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
if (ext && typeByExt[ext]) link.type = typeByExt[ext];
|
||||
link.href = href;
|
||||
document.head.appendChild(link);
|
||||
|
||||
// Safari uses apple-touch-icon for bookmarks / home-screen and is
|
||||
// unreliable about JS-injected rel="icon". The backend /favicon.ico +
|
||||
// /apple-touch-icon routes are the primary mechanism; this is
|
||||
// belt-and-braces for browsers that do read the DOM link.
|
||||
const appleLink = document.createElement('link');
|
||||
appleLink.rel = 'apple-touch-icon';
|
||||
appleLink.href = href;
|
||||
document.head.appendChild(appleLink);
|
||||
}
|
||||
}, [settings?.branding_favicon_url]);
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Calendar } from 'lucide-react';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
/**
|
||||
* Date input that displays + accepts values in the admin-configured
|
||||
* format from Settings → General (`general_date_format`), independent
|
||||
* of the browser locale. Stores + emits ISO (YYYY-MM-DD) so the rest
|
||||
* of the form / API surface keeps the canonical shape.
|
||||
*
|
||||
* A native `<input type="date">` always renders in the browser's own
|
||||
* locale (en-US users see MM/DD/YYYY) no matter what the app is
|
||||
* configured for, so it can't be used directly. This component shows
|
||||
* a plain text input in the configured format and parses on blur. A
|
||||
* calendar icon button opens the native date picker (via showPicker())
|
||||
* off a visually-hidden native input, giving the click-to-pick
|
||||
* affordance without rendering a second visible date box.
|
||||
*/
|
||||
interface LocalizedDateInputProps {
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (iso: string) => void;
|
||||
error?: string;
|
||||
/** Forwarded to the native picker so min/max date constraints work. */
|
||||
min?: string;
|
||||
max?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const LocalizedDateInput: React.FC<LocalizedDateInputProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
min,
|
||||
max,
|
||||
disabled,
|
||||
}) => {
|
||||
const { dateFormat } = useLocalizedDate();
|
||||
const nativeRef = React.useRef<HTMLInputElement>(null);
|
||||
const inputId = React.useId();
|
||||
|
||||
// Normalise the configured format down to the four shapes the parser
|
||||
// understands. Defaults to DD.MM.YYYY (the operator's primary locale)
|
||||
// when unknown.
|
||||
const normalisedFormat = ((): 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD' => {
|
||||
const f = String(dateFormat || 'dd.MM.yyyy').toLowerCase();
|
||||
if (f.startsWith('mm/dd')) return 'MM/DD/YYYY';
|
||||
if (f.startsWith('yyyy')) return 'YYYY-MM-DD';
|
||||
if (f.includes('/')) return 'DD/MM/YYYY';
|
||||
return 'DD.MM.YYYY';
|
||||
})();
|
||||
const placeholder = normalisedFormat.toLowerCase();
|
||||
|
||||
// ISO → display. NOTE: no `$` anchor — Postgres serialises DATE columns
|
||||
// as a full ISO datetime ("2026-04-06T00:00:00.000Z"), so we match the
|
||||
// leading yyyy-MM-dd and ignore any trailing time. (SQLite returns the
|
||||
// bare date string, which also matches.) Coerced to String in case a
|
||||
// Date object slips through. Without this the field rendered the raw
|
||||
// ISO timestamp on pg — see feedback_pg_date_columns_serialize.
|
||||
const toDisplay = (iso: string): string => {
|
||||
if (!iso) return '';
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso));
|
||||
if (!m) return String(iso);
|
||||
const [, y, mo, d] = m;
|
||||
switch (normalisedFormat) {
|
||||
case 'MM/DD/YYYY': return `${mo}/${d}/${y}`;
|
||||
case 'YYYY-MM-DD': return `${y}-${mo}-${d}`;
|
||||
case 'DD/MM/YYYY': return `${d}/${mo}/${y}`;
|
||||
case 'DD.MM.YYYY':
|
||||
default: return `${d}.${mo}.${y}`;
|
||||
}
|
||||
};
|
||||
|
||||
// display → ISO (accepts variant separators leniently)
|
||||
const toIso = (raw: string): string => {
|
||||
const s = raw.trim();
|
||||
if (!s) return '';
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
|
||||
const parts = s.split(/[./-]/);
|
||||
if (parts.length !== 3) return '';
|
||||
const [a, b, c] = parts;
|
||||
let y: string, mo: string, d: string;
|
||||
if (normalisedFormat === 'YYYY-MM-DD' || a.length === 4) {
|
||||
[y, mo, d] = [a, b, c];
|
||||
} else if (normalisedFormat === 'MM/DD/YYYY') {
|
||||
[mo, d, y] = [a, b, c];
|
||||
} else {
|
||||
[d, mo, y] = [a, b, c];
|
||||
}
|
||||
if (!/^\d{1,2}$/.test(d) || !/^\d{1,2}$/.test(mo) || !/^\d{4}$/.test(y)) return '';
|
||||
return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const [text, setText] = React.useState(toDisplay(value));
|
||||
React.useEffect(() => {
|
||||
setText(toDisplay(value));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
const openPicker = () => {
|
||||
const el = nativeRef.current;
|
||||
if (!el) return;
|
||||
try {
|
||||
el.showPicker();
|
||||
} catch {
|
||||
// showPicker throws on unsupported browsers / outside a user
|
||||
// gesture — the text field stays fully usable for typing.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label htmlFor={inputId} className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1.5">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<input
|
||||
id={inputId}
|
||||
value={text}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setText(next);
|
||||
// Commit live as soon as a complete, valid date is typed —
|
||||
// don't wait for blur. Otherwise a value entered then submitted
|
||||
// without blurring (or before React re-renders after the
|
||||
// blur-time setState) is lost and the parent keeps its previous
|
||||
// value (e.g. the import form's "today" default). toIso returns
|
||||
// '' for partial/invalid input, so intermediate keystrokes emit
|
||||
// nothing.
|
||||
const iso = toIso(next);
|
||||
if (iso) onChange(iso);
|
||||
}}
|
||||
onBlur={() => {
|
||||
const iso = toIso(text);
|
||||
if (iso) {
|
||||
onChange(iso);
|
||||
setText(toDisplay(iso));
|
||||
} else if (!text.trim()) {
|
||||
onChange('');
|
||||
}
|
||||
}}
|
||||
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}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPicker}
|
||||
disabled={disabled}
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 disabled:opacity-50"
|
||||
>
|
||||
<Calendar className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Visually hidden native picker — its only job is to provide
|
||||
the calendar popup the icon button triggers. Value stays in
|
||||
ISO so it's always parseable. */}
|
||||
<input
|
||||
ref={nativeRef}
|
||||
type="date"
|
||||
// Bare yyyy-MM-dd — a native date input rejects a full ISO
|
||||
// datetime (pg serialisation), which would blank the picker.
|
||||
value={value ? String(value).slice(0, 10) : ''}
|
||||
min={min}
|
||||
max={max}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
className="sr-only"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-600 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Finder-style sortable table header.
|
||||
*
|
||||
* The admin list pages (invoices / quotes / contracts) drive sorting
|
||||
* through a single server-side `sort` enum (e.g. 'customer_asc'). This
|
||||
* component + the `useColumnSort` hook map that flat enum onto clickable
|
||||
* column headers: clicking a column applies its ascending/descending
|
||||
* variant, clicking the active column again flips direction. The active
|
||||
* column shows a filled chevron; inactive sortable columns show a faint
|
||||
* up/down hint so it's discoverable that the header is clickable.
|
||||
*/
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
export type SortDir = 'asc' | 'desc';
|
||||
|
||||
/** Maps one logical column to its two server-side sort enum values. */
|
||||
export interface SortPair {
|
||||
asc: string;
|
||||
desc: string;
|
||||
/** Direction applied when this column is first clicked. Defaults to 'asc'. */
|
||||
defaultDir?: SortDir;
|
||||
}
|
||||
|
||||
export type SortColumnMap = Record<string, SortPair>;
|
||||
|
||||
/**
|
||||
* Holds the flat `sort` enum as the single source of truth and exposes
|
||||
* the active column + a toggle that flips direction on re-click. Returns
|
||||
* `sort` to feed straight into the list query and `setSort` for any
|
||||
* legacy callers that still set the enum directly.
|
||||
*/
|
||||
export function useColumnSort<T extends string>(columns: SortColumnMap, initialSort: T) {
|
||||
const [sort, setSort] = useState<T>(initialSort);
|
||||
|
||||
const active = useMemo(() => {
|
||||
for (const [key, pair] of Object.entries(columns)) {
|
||||
if (pair.asc === sort) return { key, dir: 'asc' as SortDir };
|
||||
if (pair.desc === sort) return { key, dir: 'desc' as SortDir };
|
||||
}
|
||||
return { key: null as string | null, dir: 'asc' as SortDir };
|
||||
}, [columns, sort]);
|
||||
|
||||
const toggle = useCallback((key: string) => {
|
||||
const pair = columns[key];
|
||||
if (!pair) return;
|
||||
setSort((prev) => {
|
||||
if (prev === pair.asc) return pair.desc as T;
|
||||
if (prev === pair.desc) return pair.asc as T;
|
||||
return (pair.defaultDir === 'desc' ? pair.desc : pair.asc) as T;
|
||||
});
|
||||
}, [columns]);
|
||||
|
||||
return { sort, setSort, activeKey: active.key, activeDir: active.dir, toggle };
|
||||
}
|
||||
|
||||
interface SortableHeaderProps {
|
||||
label: React.ReactNode;
|
||||
columnKey: string;
|
||||
activeKey: string | null;
|
||||
activeDir: SortDir;
|
||||
onSort: (key: string) => void;
|
||||
align?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export const SortableHeader: React.FC<SortableHeaderProps> = ({
|
||||
label, columnKey, activeKey, activeDir, onSort, align = 'left',
|
||||
}) => {
|
||||
const active = activeKey === columnKey;
|
||||
return (
|
||||
<th className={`px-3 py-2 ${align === 'right' ? 'text-right' : 'text-left'}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(columnKey)}
|
||||
className={`group inline-flex items-center gap-1 font-medium transition-colors hover:text-theme ${
|
||||
align === 'right' ? 'flex-row-reverse' : ''
|
||||
} ${active ? 'text-theme' : ''}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{active ? (
|
||||
activeDir === 'asc'
|
||||
? <ChevronUp className="w-3 h-3" />
|
||||
: <ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronsUpDown className="w-3 h-3 opacity-30 group-hover:opacity-60" />
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
/**
|
||||
* Parse a free-typed time into canonical 24h "HH:MM", or null if
|
||||
* unparseable. Tolerant of: "13:00", "1300", "9:5", "9", "1:00 PM",
|
||||
* "1pm", "12 am". Lets the field accept input in whichever format it is
|
||||
* displaying (24h or 12h) and normalise it back to storage form.
|
||||
*/
|
||||
export const parseTimeToHHMM = (raw: string): string | null => {
|
||||
const s = raw.trim().toLowerCase();
|
||||
if (!s) return null;
|
||||
let ampm: 'am' | 'pm' | null = null;
|
||||
let core = s;
|
||||
const am = s.match(/(a|p)\.?m?\.?\s*$/);
|
||||
if (am) {
|
||||
ampm = am[1] === 'p' ? 'pm' : 'am';
|
||||
core = s.slice(0, am.index).trim();
|
||||
}
|
||||
let h: number;
|
||||
let mi: number;
|
||||
const colon = core.match(/^(\d{1,2})\s*[:.]\s*(\d{1,2})$/);
|
||||
if (colon) {
|
||||
h = parseInt(colon[1], 10);
|
||||
mi = parseInt(colon[2], 10);
|
||||
} else {
|
||||
const digits = core.replace(/\D/g, '');
|
||||
if (!digits) return null;
|
||||
if (digits.length <= 2) { h = parseInt(digits, 10); mi = 0; }
|
||||
else if (digits.length === 3) { h = parseInt(digits.slice(0, 1), 10); mi = parseInt(digits.slice(1), 10); }
|
||||
else { h = parseInt(digits.slice(0, 2), 10); mi = parseInt(digits.slice(2, 4), 10); }
|
||||
}
|
||||
if (Number.isNaN(h) || Number.isNaN(mi)) return null;
|
||||
if (ampm === 'pm' && h < 12) h += 12;
|
||||
if (ampm === 'am' && h === 12) h = 0;
|
||||
h = Math.min(23, Math.max(0, h));
|
||||
mi = Math.min(59, Math.max(0, mi));
|
||||
return `${String(h).padStart(2, '0')}:${String(mi).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
interface TimeFieldProps {
|
||||
/** Canonical 24h "HH:MM" (or '' for empty). */
|
||||
value: string;
|
||||
/** Emits canonical 24h "HH:MM". */
|
||||
onChange: (v: string) => void;
|
||||
/** Optional label rendered above the field (matches the `Input` component). */
|
||||
label?: string;
|
||||
ariaLabel?: string;
|
||||
/** Tailwind width/extra classes for the input; defaults to w-full. */
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time field that DISPLAYS in the admin's `general_time_format` (24h →
|
||||
* "13:00", 12h → "01:00 PM") but always stores/emits canonical 24h
|
||||
* "HH:MM". A plain text input, so the rendered format is identical in
|
||||
* EVERY browser — native <input type="time"> ignores our setting (its
|
||||
* 12h/24h chrome is browser-locale-controlled and Safari ignores the
|
||||
* `lang` hint). Free text while typing; parsed + reformatted on blur,
|
||||
* reverting to the last good value if unparseable.
|
||||
*/
|
||||
export const TimeField: React.FC<TimeFieldProps> = ({
|
||||
value, onChange, label, ariaLabel, className, disabled,
|
||||
}) => {
|
||||
const { formatTime: fmtTime, timeFormat } = useLocalizedDate();
|
||||
const display = (v: string) => (/^\d{1,2}:\d{2}/.test(v) ? fmtTime(v) : (v || ''));
|
||||
const [text, setText] = useState(() => display(value));
|
||||
// Re-sync when the external value or the format setting changes.
|
||||
useEffect(() => { setText(display(value)); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [value, timeFormat]);
|
||||
|
||||
const commit = () => {
|
||||
const parsed = parseTimeToHHMM(text);
|
||||
if (parsed) {
|
||||
setText(display(parsed));
|
||||
if (parsed !== value) onChange(parsed);
|
||||
} else {
|
||||
setText(display(value));
|
||||
}
|
||||
};
|
||||
|
||||
const input = (
|
||||
<input
|
||||
type="text"
|
||||
inputMode={timeFormat === '12h' ? 'text' : 'numeric'}
|
||||
aria-label={ariaLabel || label}
|
||||
placeholder={timeFormat === '12h' ? '1:00 PM' : 'HH:MM'}
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={commit}
|
||||
className={clsx('input', className || 'w-full')}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!label) return input;
|
||||
return (
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1.5">
|
||||
{label}
|
||||
</label>
|
||||
{input}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,11 @@
|
||||
export { Button } from './Button';
|
||||
export { CMSContentBlock } from './CMSContentBlock';
|
||||
export { Input } from './Input';
|
||||
export { CountrySelect } from './CountrySelect';
|
||||
export { LocalizedDateInput } from './LocalizedDateInput';
|
||||
export { TimeField, parseTimeToHHMM } from './TimeField';
|
||||
export { SortableHeader, useColumnSort } from './SortableHeader';
|
||||
export type { SortDir, SortPair, SortColumnMap } from './SortableHeader';
|
||||
export { Card, CardHeader, CardContent, CardFooter } from './Card';
|
||||
export { Loading, LoadingSkeleton } from './Loading';
|
||||
export { ErrorBoundary, PageErrorBoundary } from './ErrorBoundary';
|
||||
|
||||
@@ -32,6 +32,7 @@ interface GalleryLayoutProps {
|
||||
footer_text?: string;
|
||||
favicon_url?: string;
|
||||
logo_url?: string;
|
||||
logo_url_dark?: string;
|
||||
logo_size?: 'small' | 'medium' | 'large' | 'xlarge' | 'custom';
|
||||
logo_max_height?: number;
|
||||
logo_position?: 'left' | 'center' | 'right' | 'sidepanel';
|
||||
@@ -122,6 +123,11 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { theme } = useTheme();
|
||||
// Dark-mode logo variant. Symmetric fallback: a single uploaded logo
|
||||
// serves both modes (dark → dark||light, light → light||dark).
|
||||
const brandLogoUrl = theme.colorMode === 'dark'
|
||||
? (brandingSettings?.logo_url_dark || brandingSettings?.logo_url)
|
||||
: (brandingSettings?.logo_url || brandingSettings?.logo_url_dark);
|
||||
const guestIdentity = useGuestIdentityOptional();
|
||||
|
||||
// Footer legal-link config. Cached aggressively because the toggle state
|
||||
@@ -304,8 +310,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
{shouldShowLogo('header') && (
|
||||
<div className={`gallery-logo-wrapper flex-shrink-0 flex items-center gap-2 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
|
||||
<img
|
||||
src={brandingSettings?.logo_url ?
|
||||
buildResourceUrl(brandingSettings.logo_url) :
|
||||
src={brandLogoUrl ?
|
||||
buildResourceUrl(brandLogoUrl) :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
@@ -587,17 +593,17 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||
{shouldShowLogo('hero') && (
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={brandingSettings?.logo_url ?
|
||||
buildResourceUrl(brandingSettings.logo_url) :
|
||||
<img
|
||||
src={brandLogoUrl ?
|
||||
buildResourceUrl(brandLogoUrl) :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className={`${heroLogoSize.className} w-auto object-contain mx-auto`}
|
||||
style={{
|
||||
...(heroLogoSize.style || {}),
|
||||
// Only apply brightness/invert filter to default logo; custom logos display as-is
|
||||
filter: brandingSettings?.logo_url
|
||||
filter: brandLogoUrl
|
||||
? 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
|
||||
: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
|
||||
}}
|
||||
|
||||
@@ -280,6 +280,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
footer_text: settingsData.branding_footer_text || '',
|
||||
watermark_enabled: settingsData.branding_watermark_enabled || false,
|
||||
logo_url: settingsData.branding_logo_url || null,
|
||||
logo_url_dark: settingsData.branding_logo_url_dark || null,
|
||||
logo_size: settingsData.branding_logo_size || 'medium',
|
||||
logo_max_height: settingsData.branding_logo_max_height || 48,
|
||||
logo_position: settingsData.branding_logo_position || 'left',
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Photo } from '../../../types';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
|
||||
export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
photos,
|
||||
@@ -23,6 +24,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
feedbackOptions
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const { formatTime: fmtTime } = useLocalizedDate();
|
||||
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
||||
// Seed from server is_liked on first non-empty payload (#590 follow-up).
|
||||
// Mount-only so refetches don't clobber in-session optimistic toggles.
|
||||
@@ -124,7 +126,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
|
||||
{/* Time label */}
|
||||
<div className="absolute bottom-2 left-2 px-2 py-1 bg-black/60 text-white text-xs rounded">
|
||||
{format(parseISO(photo.uploaded_at), 'h:mm a')}
|
||||
{fmtTime(photo.uploaded_at)}
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Country codes offered in the customer country picker. Stored value is
|
||||
* always the ISO 3166-1 alpha-2 code; Liechtenstein is `LI` (NOT the
|
||||
* colloquial `FL` plate code) so it matches ISO + the PDF renderer's
|
||||
* lookup. Display names are derived at runtime from `Intl.DisplayNames`
|
||||
* in the active UI language, so the list stays locale-aware without a
|
||||
* hand-maintained translation map.
|
||||
*
|
||||
* The list is the full ISO 3166-1 alpha-2 set so the picker covers every
|
||||
* country; options are sorted by localized label at render time, so the
|
||||
* array order here does not affect the UI.
|
||||
*/
|
||||
export const COUNTRY_CODES = [
|
||||
'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT',
|
||||
'AU', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI',
|
||||
'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS', 'BT', 'BV', 'BW', 'BY',
|
||||
'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN',
|
||||
'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM',
|
||||
'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK',
|
||||
'FM', 'FO', 'FR', 'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL',
|
||||
'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM',
|
||||
'HN', 'HR', 'HT', 'HU', 'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR',
|
||||
'IS', 'IT', 'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN',
|
||||
'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS',
|
||||
'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK',
|
||||
'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW',
|
||||
'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP',
|
||||
'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM',
|
||||
'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE', 'RO', 'RS', 'RU', 'RW',
|
||||
'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM',
|
||||
'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC', 'TD', 'TF',
|
||||
'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW',
|
||||
'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG', 'VI',
|
||||
'VN', 'VU', 'WF', 'WS', 'YE', 'YT', 'ZA', 'ZM', 'ZW',
|
||||
] as const;
|
||||
|
||||
export type CountryCode = (typeof COUNTRY_CODES)[number];
|
||||
|
||||
/** Localized country name for an ISO code, falling back to the code. */
|
||||
export function countryLabel(code: string, lang: string): string {
|
||||
if (!code) return '';
|
||||
const upper = code.trim().toUpperCase();
|
||||
try {
|
||||
return new Intl.DisplayNames([lang || 'en'], { type: 'region' }).of(upper) || upper;
|
||||
} catch {
|
||||
return upper;
|
||||
}
|
||||
}
|
||||
|
||||
/** Country codes sorted by their localized label for the active language. */
|
||||
export function sortedCountryOptions(lang: string): { code: string; label: string }[] {
|
||||
return COUNTRY_CODES.map((code) => ({ code, label: countryLabel(code, lang) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label, lang || 'en'));
|
||||
}
|
||||
@@ -14,16 +14,28 @@
|
||||
* Shared by QuoteResponsePage + PaymentCheckPage. Adding a third
|
||||
* public-page consumer? Reuse this hook.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePublicSettings } from './usePublicSettings';
|
||||
|
||||
export function usePublicDarkMode() {
|
||||
/**
|
||||
* Returns `{ isDark }` (reactive) in addition to applying the `.dark`
|
||||
* class, so callers can pick a theme-aware asset (e.g. the dark-mode
|
||||
* logo) without re-deriving the mode themselves.
|
||||
*/
|
||||
export function usePublicDarkMode(): { isDark: boolean } {
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
const forced = publicSettings?.branding_force_color_mode;
|
||||
const [isDark, setIsDark] = useState<boolean>(() => {
|
||||
if (forced === 'dark') return true;
|
||||
if (forced === 'light') return false;
|
||||
return typeof window !== 'undefined'
|
||||
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
});
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const forced = publicSettings?.branding_force_color_mode;
|
||||
const apply = (isDark: boolean) => {
|
||||
if (isDark) root.classList.add('dark');
|
||||
const apply = (dark: boolean) => {
|
||||
setIsDark(dark);
|
||||
if (dark) root.classList.add('dark');
|
||||
else root.classList.remove('dark');
|
||||
};
|
||||
if (forced === 'dark') {
|
||||
@@ -39,5 +51,6 @@ export function usePublicDarkMode() {
|
||||
const listener = (e: MediaQueryListEvent) => apply(e.matches);
|
||||
mql.addEventListener('change', listener);
|
||||
return () => mql.removeEventListener('change', listener);
|
||||
}, [publicSettings?.branding_force_color_mode]);
|
||||
}, [forced]);
|
||||
return { isDark };
|
||||
}
|
||||
|
||||
@@ -73,15 +73,37 @@
|
||||
"message": "Sind Sie sicher, dass Sie die Einladung für {{email}} abbrechen möchten?"
|
||||
}
|
||||
},
|
||||
"systemHealth": {
|
||||
"title": "Systemzustand",
|
||||
"subtitle": "Hintergrundfehler, die Aufmerksamkeit erfordern.",
|
||||
"retry": "Erneut versuchen",
|
||||
"dismiss": "Verwerfen",
|
||||
"retriedToast": "E-Mail erneut eingereiht.",
|
||||
"dismissedToast": "Verworfen.",
|
||||
"stuckEmails": {
|
||||
"title": "Hängende / fehlgeschlagene E-Mails",
|
||||
"empty": "Keine hängenden oder fehlgeschlagenen E-Mails — alles in Ordnung.",
|
||||
"noError": "Versuche aufgebraucht",
|
||||
"col": {
|
||||
"recipient": "Empfänger",
|
||||
"type": "Typ",
|
||||
"error": "Fehler",
|
||||
"queued": "Eingereiht",
|
||||
"actions": "Aktionen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Wird geladen...",
|
||||
"error": "Fehler",
|
||||
"configureInSettings": "Standards in den Einstellungen anpassen ↗",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"add": "Hinzufügen",
|
||||
"sortBy": "Sortieren nach",
|
||||
"selectCountry": "Land auswählen…",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"back": "Zurück",
|
||||
@@ -164,6 +186,7 @@
|
||||
"dashboard": "Dashboard",
|
||||
"events": "Veranstaltungen",
|
||||
"settings": "Einstellungen",
|
||||
"systemHealth": "Systemzustand",
|
||||
"archives": "Archive",
|
||||
"emailSettings": "E-Mail-Einstellungen",
|
||||
"branding": "Markenidentität",
|
||||
@@ -817,6 +840,7 @@
|
||||
"noTemplate": "Keine Vorlage",
|
||||
"useThemeOnly": "Nur Design-Vorlage verwenden",
|
||||
"customTemplate": "Benutzerdefinierte Vorlage",
|
||||
"createInvoice": "Rechnung erstellen",
|
||||
"title": "Veranstaltungen",
|
||||
"create": "Veranstaltung erstellen",
|
||||
"createEvent": "Veranstaltung erstellen",
|
||||
@@ -1627,6 +1651,21 @@
|
||||
"clients": {
|
||||
"title": "CRM",
|
||||
"description": "Hauptschalter für den CRM-Bereich in der Seitenleiste. Aus blendet den Bereich und alle Unterfunktionen aus, unabhängig von deren individuellen Schaltern – beim erneuten Einschalten wird der vorherige Zustand wiederhergestellt."
|
||||
},
|
||||
"contracts": {
|
||||
"title": "Verträge",
|
||||
"description": "Erstellen Sie Verträge aus einer Bibliothek wiederverwendbarer Bausteine (Bildrechte, Geheimhaltung, Model-Release, Stornierung, Gerichtsstand …) und lassen Sie Kunden im Browser unterschreiben oder eine handsignierte PDF hochladen. Die vorausgefüllten Baustein-Texte sind NUR BEISPIELE — vor dem Versand mit Ihrem Anwalt prüfen.",
|
||||
"sidebar": "Verträge"
|
||||
},
|
||||
"crmDevelopment": {
|
||||
"title": "CRM-Entwicklerwerkzeuge",
|
||||
"description": "Interne Helfer zum Prüfen von CRM-Abläufen (z. B. die Admin-Zahlungsprüfungs-E-Mail sofort auslösen, Drosselungen umgehen). Erscheint als Unterreiter „Entwicklung“ unter Kunden. Strikt opt-in — löst echte Seiteneffekte aus, nur mit Testdaten verwenden.",
|
||||
"sidebar": "Entwicklung"
|
||||
},
|
||||
"hoursLogging": {
|
||||
"title": "Stundenerfassung",
|
||||
"description": "Zeiterfassung pro Kunde. Admin erfasst Datum + Start-/Endzeit + optionalen Satz-Override + Notiz. Kunden im Monatsmodus akkumulieren Stunden automatisch in den laufenden Monatsentwurf; Kunden pro Anlass sehen eine Schaltfläche „Entwurfsrechnung erstellen“, die eine eigenständige Entwurfsrechnung mit einer Zeile pro Eintrag erzeugt. Unabhängig von Rechnungen — Stunden erfassen, noch bevor die volle Abrechnungsoberfläche aktiviert ist.",
|
||||
"sidebar": "Stunden"
|
||||
}
|
||||
},
|
||||
"customerSurface": {
|
||||
@@ -1639,6 +1678,12 @@
|
||||
"save": "Änderungen speichern",
|
||||
"saved": "Branding des Kundendashboards gespeichert",
|
||||
"error": "Einstellungen konnten nicht gespeichert werden"
|
||||
},
|
||||
"businessProfile": {
|
||||
"title": "Geschäftsprofil"
|
||||
},
|
||||
"crm": {
|
||||
"title": "CRM-Verhalten"
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
@@ -1657,11 +1702,13 @@
|
||||
"logo": "Logo",
|
||||
"uploadLogo": "Logo hochladen",
|
||||
"logoHelp": "Empfohlene Größe: 200x60px, PNG oder JPEG",
|
||||
"logoDark": "Logo für Dunkelmodus",
|
||||
"logoDarkHelp": "Optional. Wird bei dunklen Designs / im Dunkelmodus angezeigt; fällt auf das Hauptlogo zurück, wenn nicht gesetzt.",
|
||||
"favicon": "Favicon",
|
||||
"currentFavicon": "Aktuelles Favicon",
|
||||
"uploadFavicon": "Favicon hochladen",
|
||||
"removeFavicon": "Favicon entfernen",
|
||||
"faviconHelp": "PNG- oder ICO-Format, empfohlene Größe: 32x32px",
|
||||
"faviconHelp": "PNG-, ICO- oder SVG-Format. Bitte ein quadratisches Bild verwenden – ein SVG (oder ein 512×512px-PNG) liefert die schärfste Darstellung auf hochauflösenden Bildschirmen; kleinere Größen funktionieren ebenfalls.",
|
||||
"watermarkSettings": "Wasserzeichen-Einstellungen",
|
||||
"enableWatermarks": "Wasserzeichen aktivieren",
|
||||
"watermarkHelp": "Fügen Sie Ihren Firmennamen als Wasserzeichen auf heruntergeladenen Fotos hinzu",
|
||||
@@ -2385,6 +2432,37 @@
|
||||
"gmailAppPassword": "Für Gmail verwenden Sie ein App-spezifisches Passwort",
|
||||
"testEmailAddressLabel": "Test-E-Mail-Adresse",
|
||||
"sendTestEmailButton": "Test-E-Mail senden",
|
||||
"flushQueue": {
|
||||
"title": "Wartende E-Mails jetzt senden",
|
||||
"help": "Sendet sofort alle ausstehenden E-Mails, unabhängig von den Geschäftszeiten. Nützlich, um die Warteschlange vor Wartungsarbeiten oder Updates zu leeren.",
|
||||
"button": "Wartende E-Mails jetzt senden",
|
||||
"success": "Warteschlange geleert – {{sent}} gesendet, {{failed}} fehlgeschlagen",
|
||||
"empty": "Keine ausstehenden E-Mails zum Senden"
|
||||
},
|
||||
"sentEmails": {
|
||||
"tab": "Gesendete E-Mails",
|
||||
"title": "Gesendete E-Mails",
|
||||
"subtitle": "Versandstatus aller eingereihten und gesendeten Benachrichtigungen.",
|
||||
"searchPlaceholder": "Nach Empfänger oder Typ suchen…",
|
||||
"from": "Von",
|
||||
"to": "Bis",
|
||||
"empty": "Keine E-Mails entsprechen diesen Filtern.",
|
||||
"retries": "{{count}} Versuche",
|
||||
"pagination": "Seite {{page}} von {{total}} · {{count}} E-Mails",
|
||||
"status": {
|
||||
"pending": "Ausstehend",
|
||||
"sent": "Gesendet",
|
||||
"failed": "Fehlgeschlagen"
|
||||
},
|
||||
"col": {
|
||||
"recipient": "Empfänger",
|
||||
"type": "Typ",
|
||||
"status": "Status",
|
||||
"created": "Eingereiht",
|
||||
"sent": "Gesendet",
|
||||
"event": "Anlass"
|
||||
}
|
||||
},
|
||||
"commonSmtpSettings": "Häufige SMTP-Einstellungen:",
|
||||
"editTemplate": "Vorlage bearbeiten",
|
||||
"templateName": "Vorlagenname",
|
||||
@@ -3028,7 +3106,7 @@
|
||||
"hours": {
|
||||
"section": "Stunden",
|
||||
"monthlyHint": "Einträge werden automatisch dem aktuellen monatlichen Entwurf angehängt. Bearbeiten/Löschen möglich, bis der Scheduler den Versand auslöst.",
|
||||
"perEventHint": "Erfasste Einträge bleiben unverrechnet, bis Sie auf „Rechnungsentwurf erstellen“ klicken — dann wird ein eigenständiger Rechnungsentwurf mit einer Zeile pro Eintrag erzeugt, den Sie vor dem Versand prüfen können.",
|
||||
"perEventHint": "Erfasste Einträge bleiben unverrechnet, bis Sie auf „Rechnungsentwurf erstellen“ klicken — dann wird eine geplante Rechnung mit einer Zeile pro Eintrag erzeugt und im Editor geöffnet, sodass Sie vor dem Versand weitere Positionen hinzufügen können.",
|
||||
"form": {
|
||||
"title": "Neuen Eintrag erfassen",
|
||||
"date": "Datum",
|
||||
@@ -3040,7 +3118,8 @@
|
||||
"rateOverride": "Satz-Override",
|
||||
"note": "Notiz / Beschreibung",
|
||||
"notePlaceholder": "Was wurde gearbeitet?",
|
||||
"save": "Eintrag hinzufügen"
|
||||
"save": "Eintrag hinzufügen",
|
||||
"needRate": "Satz festlegen oder Override eingeben, um Zeit zu erfassen."
|
||||
},
|
||||
"col": {
|
||||
"date": "Datum",
|
||||
@@ -3065,6 +3144,20 @@
|
||||
"created": "Eintrag erfasst",
|
||||
"deleted": "Eintrag gelöscht",
|
||||
"billed": "Stunden verrechnet"
|
||||
},
|
||||
"noRate": {
|
||||
"title": "Kein Stundensatz hinterlegt",
|
||||
"body": "Für die Erfassung wird ein Satz benötigt. Legen Sie einen für diesen Kunden fest, geben Sie unten einen Override pro Eintrag ein oder konfigurieren Sie einen installationsweiten Standardsatz.",
|
||||
"setForCustomer": "Satz für diesen Kunden festlegen",
|
||||
"setInstallDefault": "Installationsweiten Standard festlegen"
|
||||
},
|
||||
"rateSource": {
|
||||
"customer": "von diesem Kunden",
|
||||
"installDefault": "installationsweiter Standard"
|
||||
},
|
||||
"error": {
|
||||
"noRate": "Für diesen Kunden ist kein Stundensatz hinterlegt. Geben Sie einen Satz-Override ein, hinterlegen Sie einen Satz beim Kunden oder konfigurieren Sie in den Einstellungen einen installationsweiten Standardsatz.",
|
||||
"createFailed": "Eintrag konnte nicht erfasst werden"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@@ -3078,7 +3171,8 @@
|
||||
"savedPassiveToast": "Passiver Kunde erstellt.",
|
||||
"savedActiveToast": "Kunde erstellt und Portal-Einladung gesendet.",
|
||||
"inviteFailedToast": "Kunde gespeichert (passiv). Einladungs-E-Mail fehlgeschlagen — bitte aus dem Kundendetail erneut versuchen.",
|
||||
"emailRequired": "Eine gültige E-Mail-Adresse ist erforderlich."
|
||||
"emailRequired": "Eine gültige E-Mail-Adresse ist erforderlich.",
|
||||
"nameRequired": "Geben Sie mindestens einen Firmennamen oder einen Ansprechpartner an."
|
||||
},
|
||||
"passive": {
|
||||
"badge": "Passiv — nur Admin",
|
||||
@@ -3183,6 +3277,7 @@
|
||||
"city": "Stadt",
|
||||
"state": "Bundesland / Region",
|
||||
"countryCode": "Land (ISO-2)",
|
||||
"country": "Land",
|
||||
"countryName": "Land (vollständiger Name)",
|
||||
"notesHint": "Nur für Administratoren sichtbar. Wird dem Kunden nie gezeigt.",
|
||||
"featuresSection": "Kundenfunktionen",
|
||||
@@ -3205,20 +3300,26 @@
|
||||
},
|
||||
"billing": {
|
||||
"section": "Abrechnungsrhythmus",
|
||||
"hint": "Per-Event (Standard): jede Rechnung wird einzeln versendet. Monatlich: alle Rechnungen einer Periode werden zu einer Sammelrechnung gebündelt, die am konfigurierten Stichtag ausgelöst wird.",
|
||||
"hint": "Per-Event (Standard): jede Rechnung wird einzeln versendet. Monatlich: alle Rechnungen einer Periode werden zu einer Sammelrechnung gebündelt, die am konfigurierten Stichtag ausgelöst wird. Manuell: Positionen sammeln sich genauso, aber die Rechnung wird erst versendet, wenn Sie sie auslösen.",
|
||||
"cadence": "Abrechnungsrhythmus",
|
||||
"perEvent": "Per Event",
|
||||
"monthly": "Monatlich",
|
||||
"quarterly": "Quartalsweise",
|
||||
"manual": "Manuell (nur auf Auslösung)",
|
||||
"cycleDay": "Stichtag",
|
||||
"cycleDayHint": "1..28 = Tag im Monat. Negativ -1..-15 für „N Tage vor Monatsende“ (so löst -3 in einem 31-Tage-Monat am 28. aus).",
|
||||
"skontoDisabled": "Kein Skonto für diesen Kunden",
|
||||
"skontoDisabledHint": "Deaktiviert den Skonto-Abzug auf allen Rechnungen dieses Kunden – unabhängig von Vorlage oder globalen Standardwerten.",
|
||||
"triggerNow": "Rechnung jetzt ausstellen",
|
||||
"triggerConfirm": "Monatsrechnung für diesen Kunden jetzt ausstellen? Der Kunde erhält die E-Mail sofort.",
|
||||
"triggerConfirmManual": "Gesammelte Rechnung für diesen Kunden jetzt ausstellen? Der Kunde erhält die E-Mail sofort.",
|
||||
"triggerHint": "Überspringt den Stichtag und stellt den aktuellen Entwurf sofort aus. Wird abgelehnt, wenn für die aktuelle Periode nichts erfasst wurde.",
|
||||
"triggerHintManual": "Stellt den aktuellen Entwurf sofort aus. Entwürfe mit manuellem Rhythmus werden nie automatisch versendet – dies ist der einzige Weg, sie zu versenden. Wird abgelehnt, wenn nichts erfasst wurde.",
|
||||
"triggered": "Monatsrechnung ausgestellt: {{number}}",
|
||||
"triggerError": "Monatsrechnung konnte nicht ausgelöst werden.",
|
||||
"draftPreview": {
|
||||
"title": "Offen für die Rechnung dieses Monats",
|
||||
"titleManual": "Offen – wird auf manuelle Auslösung versendet",
|
||||
"periodRange": "{{number}} · {{from}} – {{to}}"
|
||||
}
|
||||
},
|
||||
@@ -3325,7 +3426,16 @@
|
||||
"pickPlaceholder": "— Kunde wählen —",
|
||||
"emptyList": "Noch keine Kunden mit aktivierter Stundenerfassung. Aktivieren Sie „Stundenerfassung“ zuerst auf der Kundendetailseite.",
|
||||
"searchPlaceholder": "Nach E-Mail oder Firma suchen…",
|
||||
"customerLoggingDisabled": "Bei diesem Kunden ist die Stundenerfassung deaktiviert. Aktiviere sie auf der Kundendetailseite, um Stunden zu erfassen."
|
||||
"customerLoggingDisabled": "Bei diesem Kunden ist die Stundenerfassung deaktiviert. Aktiviere sie auf der Kundendetailseite, um Stunden zu erfassen.",
|
||||
"openHours": {
|
||||
"title": "Offene Stunden über alle Kunden",
|
||||
"subtitle": "Noch nicht abgerechnete Zeitblöcke. Oben einen Kunden wählen oder auf eine Zeile klicken, um Details zu öffnen.",
|
||||
"empty": "Aktuell keine offenen Stunden – alles abgerechnet oder noch keine Zeit erfasst.",
|
||||
"entryLine_one": "{{count}} Eintrag · {{hours}} Std.",
|
||||
"entryLine_other": "{{count}} Einträge · {{hours}} Std.",
|
||||
"passive": "Passiv",
|
||||
"needsRate": "Kein Satz hinterlegt"
|
||||
}
|
||||
},
|
||||
"crmDev": {
|
||||
"title": "CRM-Entwicklung",
|
||||
@@ -3479,6 +3589,9 @@
|
||||
"send": "Senden",
|
||||
"resend": "Erneut senden",
|
||||
"convert": "In Anlass umwandeln",
|
||||
"declineOnBehalf": "Im Namen ablehnen",
|
||||
"declineReasonPrompt": "Dieses Angebot im Namen des Kunden als abgelehnt markieren? Optional einen Grund angeben (leer lassen zum Überspringen).",
|
||||
"declinedOnBehalfToast": "Angebot als abgelehnt markiert.",
|
||||
"field": {
|
||||
"issueDate": "Ausgestellt am",
|
||||
"validUntil": "Gültig bis",
|
||||
@@ -3487,6 +3600,7 @@
|
||||
"sentAt": "Gesendet am",
|
||||
"acceptedAt": "Angenommen am",
|
||||
"declinedAt": "Abgelehnt am",
|
||||
"declineReason": "Ablehnungsgrund",
|
||||
"responseWindow": "Antwortfrist",
|
||||
"eventTimeStart": "Startzeit",
|
||||
"eventTimeEnd": "Endzeit",
|
||||
@@ -3588,8 +3702,9 @@
|
||||
"status": "Status",
|
||||
"dueDate": "Fällig",
|
||||
"total": "Gesamt",
|
||||
"sourceQuote": "Vom Angebot",
|
||||
"issueDate": "Ausgestellt am",
|
||||
"dueDateOverrideOn": "Manuelles Fälligkeitsdatum — Häkchen entfernen, um es automatisch aus Versanddatum + Zahlungsziel zu berechnen",
|
||||
"dueDateOverrideOff": "Automatisch aus Versanddatum + Zahlungsziel — ankreuzen, um es manuell zu setzen",
|
||||
"scheduledSendAt": "Geplanter Versand (optional)",
|
||||
"installment": "Rate",
|
||||
"paid": "Bezahlt",
|
||||
@@ -3611,6 +3726,7 @@
|
||||
"selectTiming": "— Zahlungsablauf wählen —",
|
||||
"eventName": "Anlass",
|
||||
"eventDate": "Anlassdatum",
|
||||
"eventNamePlaceholder": "z.B. Hochzeit Schmidt 2024",
|
||||
"eventTimeStart": "Startzeit",
|
||||
"eventTimeEnd": "Endzeit",
|
||||
"customer": "Kunde",
|
||||
@@ -3651,6 +3767,7 @@
|
||||
"customer": "Kunde",
|
||||
"event": "Anlass",
|
||||
"installment": "Rate",
|
||||
"issueDate": "Ausgestellt",
|
||||
"dueDate": "Fällig",
|
||||
"total": "Gesamt",
|
||||
"status": "Status"
|
||||
@@ -3701,6 +3818,7 @@
|
||||
"payment": {
|
||||
"paidAt": "Bezahlt am",
|
||||
"amount": "Betrag",
|
||||
"date": "Zahlungsdatum",
|
||||
"method": "Methode",
|
||||
"reference": "Referenz",
|
||||
"notes": "Notizen",
|
||||
@@ -3730,6 +3848,27 @@
|
||||
"savedToast": "Geschäftsprofil gespeichert.",
|
||||
"title": "Geschäftsprofil",
|
||||
"subtitle": "Briefkopf, Kontaktdaten und Standardwerte für Angebote und Rechnungen.",
|
||||
"businessHours": {
|
||||
"title": "Geschäftszeiten",
|
||||
"subtitle": "Öffnungszeiten je Wochentag festlegen — für eine Mittagspause einfach einen zweiten Block hinzufügen. Werden in der oben gewählten Zeitzone interpretiert ({{tz}}).",
|
||||
"closed": "Geschlossen",
|
||||
"addHours": "Zeiten hinzufügen",
|
||||
"addBlock": "Weiteren Block hinzufügen",
|
||||
"copyToAll": "Auf alle Tage übertragen",
|
||||
"startTime": "Öffnungszeit",
|
||||
"endTime": "Schließzeit",
|
||||
"floorToggle": "Geplante E-Mails bis zu den Geschäftszeiten zurückhalten",
|
||||
"floorToggleHelp": "Wenn aktiv, wird eine automatische E-Mail, die außerhalb der obigen Zeiten geplant ist, erst zur nächsten Öffnungszeit zugestellt statt zu einer ungünstigen Uhrzeit. Wenn aus, werden geplante E-Mails exakt zum geplanten Zeitpunkt versendet.",
|
||||
"weekday": {
|
||||
"1": "Montag",
|
||||
"2": "Dienstag",
|
||||
"3": "Mittwoch",
|
||||
"4": "Donnerstag",
|
||||
"5": "Freitag",
|
||||
"6": "Samstag",
|
||||
"7": "Sonntag"
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"company": "Firma",
|
||||
"contact": "Kontakt",
|
||||
@@ -3755,6 +3894,9 @@
|
||||
"timezone": "Zeitzone (IANA)",
|
||||
"vatLabel": "MwSt-Bezeichnung",
|
||||
"vatRateDefault": "Standard-MwSt-Satz %",
|
||||
"defaultHourlyRate": "Standard-Stundensatz",
|
||||
"defaultHourlyRatePlaceholder": "z. B. 120.00",
|
||||
"defaultHourlyRateHint": "Fallback, wenn ein Kunde keinen eigenen Satz hat. In {{currency}}, in ganzen Einheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen.",
|
||||
"defaultQrFormat": "Standard-QR-Format",
|
||||
"footerLine": "Fusszeile"
|
||||
},
|
||||
@@ -3785,7 +3927,11 @@
|
||||
"quotes": "Angebote",
|
||||
"invoices": "Rechnungen",
|
||||
"paymentDefaults": "Standard-Zahlungsbedingungen",
|
||||
"installmentDefaults": "Standard-Trigger für Teilzahlungen"
|
||||
"installmentDefaults": "Standard-Trigger für Teilzahlungen",
|
||||
"contracts": "Verträge",
|
||||
"quotesTos": "AGB-Schritt",
|
||||
"dashboardOverview": "CRM-Übersicht im Dashboard",
|
||||
"dashboardOverviewHint": "CRM-Übersichtskacheln im Admin-Dashboard ausblenden. Alle Kacheln werden standardmäßig angezeigt; zum Ausblenden abwählen."
|
||||
},
|
||||
"installmentDefaults": {
|
||||
"help": "Vorbelegung der Trigger für neue Zeilen im Teilzahlungs-Panel. Pro-Dokument-Anpassungen überschreiben; bestehende Dokumente behalten ihren gespeicherten Plan.",
|
||||
@@ -3860,6 +4006,48 @@
|
||||
},
|
||||
"crm_invoices_late_fee_enabled": {
|
||||
"label": "Mahngebühr aktivieren"
|
||||
},
|
||||
"crm_quotes_tos_required": {
|
||||
"label": "Kunden müssen „Ich akzeptiere die AGB“ ankreuzen, bevor sie annehmen können"
|
||||
},
|
||||
"crm_quotes_tos_url": {
|
||||
"label": "AGB-URL (optional)"
|
||||
},
|
||||
"crm_quotes_tos_text": {
|
||||
"label": "AGB-Text, der auf der Angebotsseite angezeigt wird",
|
||||
"placeholder": "Fügen Sie hier die Vertragsbedingungen ein. Nur Text. Leer lassen, um nur Häkchen + URL anzuzeigen."
|
||||
},
|
||||
"crm_contracts_pdf_attachment_enabled": {
|
||||
"label": "Vertrags-PDF an E-Mail anhängen"
|
||||
},
|
||||
"crm_contracts_require_drawn_signature": {
|
||||
"label": "Gezeichnete Unterschrift verlangen (getippter Name allein genügt nicht)"
|
||||
},
|
||||
"crm_contracts_allow_pdf_upload": {
|
||||
"label": "Kunden erlauben, eine handsignierte PDF hochzuladen"
|
||||
},
|
||||
"crm_contracts_store_ip": {
|
||||
"label": "IP-Adresse des Unterzeichners speichern (empfohlen — stützendes Beweismittel in Zivilstreitigkeiten)",
|
||||
"help": "Wenn deaktiviert, wird die IP-Adresse von Kunde und Admin zum Signaturzeitpunkt NICHT in der Vertragszeile oder der öffentlichen Signaturseiten-Bestätigung erfasst. Nach dem DSGVO-Grundsatz der Datenminimierung bevorzugen das manche Betreiber — die IP ist jedoch ein stützendes Identitätsmerkmal, falls der Vertrag angefochten wird, daher empfehlen wir, sie aktiviert zu lassen."
|
||||
},
|
||||
"crm_contracts_default_valid_days": {
|
||||
"label": "Unterzeichnungsfrist (Tage)"
|
||||
},
|
||||
"crm_contracts_number_format": {
|
||||
"label": "Vertragsnummern-Format",
|
||||
"help": "Unterstützte Platzhalter: {YEAR}, {MONTH}, {SEQ:04d}. Beispiel: LBM-C-{YEAR}-{SEQ:04d} → LBM-C-2026-0001."
|
||||
},
|
||||
"crm_overview_show_revenue": {
|
||||
"label": "Umsatz-Kacheln (30 / 90 / 365 Tage)"
|
||||
},
|
||||
"crm_overview_show_outstanding": {
|
||||
"label": "Kachel offene Zahlungen"
|
||||
},
|
||||
"crm_overview_show_quotes": {
|
||||
"label": "Angebots-Pipeline (nach Status)"
|
||||
},
|
||||
"crm_overview_show_invoices": {
|
||||
"label": "Rechnungs-Pipeline (nach Status)"
|
||||
}
|
||||
},
|
||||
"quoteResponse": {
|
||||
@@ -3989,6 +4177,7 @@
|
||||
"new": "Neuer Vertrag"
|
||||
},
|
||||
"detail": {
|
||||
"previewPdf": "PDF-Vorschau",
|
||||
"integrity": {
|
||||
"title": "PDF-Integritätsprüfung",
|
||||
"help": "Berechnet die SHA-256-Hashes der unsignierten und signierten PDFs neu und vergleicht sie mit den beim Ausstellen gespeicherten Werten. Deckt Backup-Beschädigungen oder nachträgliche Änderungen an der Kundenkopie auf.",
|
||||
@@ -4003,7 +4192,108 @@
|
||||
"expected": "erwartet",
|
||||
"actual": "tatsächlich",
|
||||
"error": "Integritätsprüfung fehlgeschlagen."
|
||||
}
|
||||
},
|
||||
"alreadyEventToast": "Bereits mit einem Anlass verknüpft.",
|
||||
"auditEmpty": "Noch keine Audit-Log-Einträge.",
|
||||
"auditTrail": "Audit-Verlauf",
|
||||
"auditTrailHelp": "Jedes auf diesem Vertrag erfasste Ereignis. Die Liste ist anhängend (append-only) und ist die Quelle der Wahrheit, falls der Vertrag angefochten wird.",
|
||||
"back": "Zurück zur Liste",
|
||||
"blocks": "Enthaltene Bausteine",
|
||||
"cancel": "Stornieren",
|
||||
"cancelConfirm": "Diesen Vertrag stornieren? Der Signatur-Link des Kunden wird ungültig.",
|
||||
"cancelError": "Stornierung fehlgeschlagen",
|
||||
"cancelledToast": "Vertrag storniert.",
|
||||
"clearSignature": "Löschen",
|
||||
"confirmConvertEvent": "Diesen Vertrag in einen Anlass + geplante Rechnungen umwandeln?",
|
||||
"confirmConvertInvoice": "Diesen Vertrag nur in Rechnung(en) umwandeln? Es wird keine Galerie / kein Anlass erstellt.",
|
||||
"confirmCountersign": "Gegenzeichnen",
|
||||
"confirmResendSigned": "Das signierte Vertrags-PDF erneut an beide Parteien senden?",
|
||||
"confirmRestamp": "PDF neu stempeln & neu rendern",
|
||||
"convertError": "Umwandlung fehlgeschlagen",
|
||||
"convertToEvent": "In Anlass umwandeln",
|
||||
"convertToInvoice": "Nur in Rechnung umwandeln",
|
||||
"convertedToEvent": "In Anlass umgewandelt",
|
||||
"convertedToEventToast": "Vertrag in Anlass #{{id}} umgewandelt",
|
||||
"convertedToInvoiceToast": "{{count}} Rechnung(en) aus diesem Vertrag erstellt",
|
||||
"countersignError": "Gegenzeichnen fehlgeschlagen",
|
||||
"countersignHelp": "Geben Sie Ihren Namen ein UND zeichnen Sie unten Ihre Unterschrift — beide werden auf das neu gerenderte PDF gestempelt. IP und Zeitstempel werden für das Audit erfasst.",
|
||||
"countersignSignaturePrompt": "Zeichnen Sie Ihre Unterschrift",
|
||||
"countersignTitle": "Gegenzeichnen, um den Vertrag bindend zu machen",
|
||||
"countersignedToast": "Gegengezeichnet.",
|
||||
"customer": "Kunde",
|
||||
"dates": "Daten",
|
||||
"downloadPdf": "PDF herunterladen",
|
||||
"downloadSignedPdf": "Signiertes PDF herunterladen",
|
||||
"edit": "Bearbeiten",
|
||||
"fromQuote": "Aus Angebot",
|
||||
"issued": "Ausgestellt",
|
||||
"linkedInvoice": "Rechnung",
|
||||
"newInvoice": "Neue Rechnung",
|
||||
"noBlocks": "Keine Bausteine enthalten.",
|
||||
"noSignatureImage": "Kein Unterschriftsbild erfasst — nutzen Sie unten „Unterschriften neu stempeln“, um eines hinzuzufügen.",
|
||||
"notFound": "Vertrag nicht gefunden.",
|
||||
"parties": "Parteien",
|
||||
"popupBlocked": "Erlauben Sie Pop-ups für diese Seite, um die PDF-Vorschau anzuzeigen.",
|
||||
"renderFailedBody": "Der Signaturnachweis ist erfasst, aber das gestempelte PDF wurde beim letzten Versuch nicht erzeugt. Klicken Sie oben auf „Signiertes PDF erneut senden“, um es aus dem Originaldokument neu zu stempeln und erneut zu senden.",
|
||||
"renderFailedTitle": "Stempeln des signierten PDFs fehlgeschlagen — Neustempeln erforderlich",
|
||||
"resendError": "Erneutes Senden fehlgeschlagen",
|
||||
"resendSigned": "Signiertes PDF erneut senden",
|
||||
"resentSignedToast": "Signierter Vertrag erneut an beide Parteien gesendet.",
|
||||
"restampAdmin": "Admin-Unterschrift",
|
||||
"restampCustomer": "Kundenunterschrift",
|
||||
"restampError": "Neustempeln fehlgeschlagen",
|
||||
"restampHelp": "Bei einer oder beiden Unterschriften wurde kein Bild erfasst. Zeichnen Sie die fehlende(n) Unterschrift(en) hier und wir rendern das PDF neu. Die bereits gespeicherten getippten Namen, Zeitstempel und IPs bleiben unverändert.",
|
||||
"restampTitle": "Fehlende Unterschriften neu stempeln",
|
||||
"restampedToast": "Unterschriften neu gestempelt und PDF neu gerendert.",
|
||||
"send": "An Kunden senden",
|
||||
"sendError": "Senden fehlgeschlagen",
|
||||
"sentAt": "Gesendet am",
|
||||
"sentToast": "Vertrag gesendet.",
|
||||
"signBy": "Unterzeichnen bis",
|
||||
"signatures": "Unterschriften",
|
||||
"signedByAdmin": "Gegengezeichnet",
|
||||
"signedByCustomer": "Vom Kunden signiert",
|
||||
"signedNamePlaceholder": "Ihr vollständiger Name",
|
||||
"uploadError": "Upload fehlgeschlagen",
|
||||
"uploadSigned": "Signiertes PDF hochladen",
|
||||
"uploadedToast": "Signiertes PDF hochgeladen."
|
||||
},
|
||||
"editor": {
|
||||
"back": "Zurück zur Liste",
|
||||
"backToDetail": "Zurück zum Vertrag",
|
||||
"create": "Entwurf erstellen",
|
||||
"createdToast": "Vertrag erstellt.",
|
||||
"customer": "Kunde",
|
||||
"disclaimerBody": "Die vorausgefüllten Baustein-Texte sind NUR BEISPIELE — vom Maintainer verfasst, nicht von einem Anwalt. Lassen Sie jeden verwendeten Baustein von Ihrem eigenen Anwalt prüfen, bevor Sie den Vertrag versenden. Siehe docs/crm-disclaimers.md.",
|
||||
"disclaimerTitle": "Anwaltliche Prüfung erforderlich",
|
||||
"eventDate": "Anlassdatum",
|
||||
"eventHelp": "Wird auf den Vertrag übernommen und an jeden daraus erzeugten Anlass / jede Rechnung weitergegeben. Setzen Sie dies, damit Kundenportal und Mahn-E-Mails die richtige Bezeichnung \"Hochzeit Doe / Müller\" anzeigen.",
|
||||
"eventName": "Anlassname",
|
||||
"eventNamePlaceholder": "z. B. Hochzeit Doe / Müller",
|
||||
"eventSection": "Anlass (optional)",
|
||||
"eventTimeEnd": "Ende",
|
||||
"eventTimeStart": "Beginn",
|
||||
"intro": "Einleitungstext (optional)",
|
||||
"issueDate": "Ausstellungsdatum",
|
||||
"language": "Sprache",
|
||||
"locked": "Gesendete Verträge können nicht bearbeitet werden. Für Änderungen stornieren und einen neuen erstellen.",
|
||||
"noBlocksInSection": "Noch keine Bausteine für diesen Abschnitt.",
|
||||
"outro": "Schlusstext (optional)",
|
||||
"popupBlocked": "Erlauben Sie Pop-ups für diese Seite, um die PDF-Vorschau anzuzeigen.",
|
||||
"preview": "PDF-Vorschau",
|
||||
"previewAfterSave": "Zuerst den Entwurf speichern, dann Vorschau anzeigen.",
|
||||
"previewError": "Vorschau fehlgeschlagen",
|
||||
"save": "Speichern",
|
||||
"saveError": "Speichern fehlgeschlagen",
|
||||
"savedToast": "Vertrag gespeichert.",
|
||||
"schriftformWarning": "Signaturtyp: einfache elektronische Signatur (EES). Ausreichend für routinemäßige Fotografieverträge in CH / DE / AT / FL. NICHT ausreichend für Dokumente, die gesetzlich Schriftform / forme qualifiée verlangen: Bürgschaft (DE § 766 BGB), Verbraucherdarlehensvertrag (DE § 492 BGB), befristete Arbeitsverträge (DE § 14 Abs. 4 TzBfG) und Ähnliches. Dafür ist eine qualifizierte elektronische Signatur (QES) eines Vertrauensdiensteanbieters erforderlich — picpeak bietet keine QES.",
|
||||
"searchCustomer": "Nach E-Mail suchen…",
|
||||
"systemBadge": "System",
|
||||
"titleEdit": "Vertrag bearbeiten",
|
||||
"titleField": "Vertragstitel",
|
||||
"titleNew": "Neuer Vertrag",
|
||||
"titlePlaceholder": "z. B. Hochzeitsvertrag Doe / Müller",
|
||||
"validUntil": "Unterzeichnen bis (optional)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,15 +73,37 @@
|
||||
"message": "Are you sure you want to cancel the invitation for {{email}}?"
|
||||
}
|
||||
},
|
||||
"systemHealth": {
|
||||
"title": "System health",
|
||||
"subtitle": "Background failures that need attention.",
|
||||
"retry": "Retry",
|
||||
"dismiss": "Dismiss",
|
||||
"retriedToast": "Email re-queued.",
|
||||
"dismissedToast": "Dismissed.",
|
||||
"stuckEmails": {
|
||||
"title": "Stuck / failed emails",
|
||||
"empty": "No stuck or failed emails — all clear.",
|
||||
"noError": "retries exhausted",
|
||||
"col": {
|
||||
"recipient": "Recipient",
|
||||
"type": "Type",
|
||||
"error": "Error",
|
||||
"queued": "Queued",
|
||||
"actions": "Actions"
|
||||
}
|
||||
}
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"configureInSettings": "Configure defaults in Settings ↗",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"sortBy": "Sort by",
|
||||
"selectCountry": "Select country…",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"back": "Back",
|
||||
@@ -165,6 +187,7 @@
|
||||
"events": "Events",
|
||||
"archives": "Archives",
|
||||
"settings": "Settings",
|
||||
"systemHealth": "System health",
|
||||
"eventTypes": "Event Types",
|
||||
"branding": "Branding",
|
||||
"emailSettings": "Email Settings",
|
||||
@@ -398,6 +421,7 @@
|
||||
"copyLink": "Copy Link",
|
||||
"linkCopied": "Link copied!",
|
||||
"viewGallery": "View Gallery",
|
||||
"createInvoice": "Create invoice",
|
||||
"uploadPhotos": "Upload Photos",
|
||||
"archiveEvent": "Archive Event",
|
||||
"archiveConfirm": "Are you sure you want to archive this event? This action cannot be undone.",
|
||||
@@ -1185,6 +1209,21 @@
|
||||
"clients": {
|
||||
"title": "CRM",
|
||||
"description": "Master switch for the CRM sidebar section. Off hides the section and every sub-feature below regardless of their individual toggles — re-enable to restore them to whatever you set last."
|
||||
},
|
||||
"contracts": {
|
||||
"title": "Contracts",
|
||||
"description": "Compose contracts from a library of reusable blocks (image rights, NDA, model release, cancellation, jurisdiction…) and have customers sign in-browser or upload a wet-signed PDF. Seeded block bodies are EXAMPLES ONLY — review with your lawyer before sending.",
|
||||
"sidebar": "Contracts"
|
||||
},
|
||||
"crmDevelopment": {
|
||||
"title": "CRM developer tools",
|
||||
"description": "Internal helpers for verifying CRM flows (e.g. fire the admin payment-check email instantly, bypass throttles). Surfaces as a \"Development\" sub-tab under Clients. Strictly opt-in — fires real side effects, use against test data only.",
|
||||
"sidebar": "Development"
|
||||
},
|
||||
"hoursLogging": {
|
||||
"title": "Hours logging",
|
||||
"description": "Per-customer time tracking. Admin logs date + start/end times + optional rate override + note. Monthly-mode customers auto-accumulate hours into the running monthly draft; per-event customers see a \"Create draft invoice\" button that mints a standalone draft invoice with one line per entry. Independent of Bills — log hours even before turning the full billing surface on.",
|
||||
"sidebar": "Hours"
|
||||
}
|
||||
},
|
||||
"customerSurface": {
|
||||
@@ -1197,6 +1236,12 @@
|
||||
"save": "Save changes",
|
||||
"saved": "Customer dashboard branding saved",
|
||||
"error": "Could not save settings"
|
||||
},
|
||||
"businessProfile": {
|
||||
"title": "Business profile"
|
||||
},
|
||||
"crm": {
|
||||
"title": "CRM behaviour"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
@@ -1246,11 +1291,13 @@
|
||||
"logo": "Logo",
|
||||
"uploadLogo": "Upload Logo",
|
||||
"logoHelp": "Recommended size: 200x60px, PNG or JPEG",
|
||||
"logoDark": "Dark-mode logo",
|
||||
"logoDarkHelp": "Optional. Shown on dark themes / dark mode; falls back to the main logo when unset.",
|
||||
"favicon": "Favicon",
|
||||
"currentFavicon": "Current favicon",
|
||||
"uploadFavicon": "Upload Favicon",
|
||||
"removeFavicon": "Remove Favicon",
|
||||
"faviconHelp": "PNG or ICO format, recommended size: 32x32px",
|
||||
"faviconHelp": "PNG, ICO, or SVG format. Use a square image — an SVG (or a 512×512px PNG) gives the crispest display on high-resolution screens; smaller sizes work too.",
|
||||
"watermarkSettings": "Watermark Settings",
|
||||
"enableWatermarks": "Enable Watermarks",
|
||||
"watermarkHelp": "Add your company name as a watermark on downloaded photos",
|
||||
@@ -1958,6 +2005,37 @@
|
||||
"gmailAppPassword": "For Gmail, use an app-specific password",
|
||||
"testEmailAddressLabel": "Test Email Address",
|
||||
"sendTestEmailButton": "Send Test Email",
|
||||
"flushQueue": {
|
||||
"title": "Send queued emails now",
|
||||
"help": "Immediately send every pending email, ignoring the business-hours schedule. Useful for draining the queue before maintenance or updates.",
|
||||
"button": "Send queued emails now",
|
||||
"success": "Email queue flushed — {{sent}} sent, {{failed}} failed",
|
||||
"empty": "No pending emails to send"
|
||||
},
|
||||
"sentEmails": {
|
||||
"tab": "Sent emails",
|
||||
"title": "Sent emails",
|
||||
"subtitle": "Delivery status of every queued and sent notification.",
|
||||
"searchPlaceholder": "Search by recipient or type…",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"empty": "No emails match these filters.",
|
||||
"retries": "{{count}} retries",
|
||||
"pagination": "Page {{page}} of {{total}} · {{count}} emails",
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"sent": "Sent",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"col": {
|
||||
"recipient": "Recipient",
|
||||
"type": "Type",
|
||||
"status": "Status",
|
||||
"created": "Queued",
|
||||
"sent": "Sent",
|
||||
"event": "Event"
|
||||
}
|
||||
},
|
||||
"commonSmtpSettings": "Common SMTP Settings:",
|
||||
"editTemplate": "Edit Template",
|
||||
"templateName": "Template Name",
|
||||
@@ -3028,7 +3106,7 @@
|
||||
"hours": {
|
||||
"section": "Hours",
|
||||
"monthlyHint": "Entries auto-append to the current monthly draft. Edit / delete remains possible until the scheduler arms the draft for send.",
|
||||
"perEventHint": "Logged entries stay unbilled until you click \"Create draft invoice\" — a standalone draft invoice is generated with one line per entry, ready for you to review before sending.",
|
||||
"perEventHint": "Logged entries stay unbilled until you click \"Create draft invoice\" — one scheduled invoice is generated with a line per entry and opened in the editor, so you can add other items before it ships.",
|
||||
"form": {
|
||||
"title": "Log new entry",
|
||||
"date": "Date",
|
||||
@@ -3040,7 +3118,8 @@
|
||||
"rateOverride": "Rate override",
|
||||
"note": "Note / description",
|
||||
"notePlaceholder": "What was worked on?",
|
||||
"save": "Add entry"
|
||||
"save": "Add entry",
|
||||
"needRate": "Set a rate or enter an override to log time."
|
||||
},
|
||||
"col": {
|
||||
"date": "Date",
|
||||
@@ -3065,6 +3144,20 @@
|
||||
"created": "Entry logged",
|
||||
"deleted": "Entry deleted",
|
||||
"billed": "Hours billed"
|
||||
},
|
||||
"noRate": {
|
||||
"title": "No hourly rate configured",
|
||||
"body": "Logging needs a rate. Set one for this customer, type a per-entry override below, or configure an install-wide default.",
|
||||
"setForCustomer": "Set a rate for this customer",
|
||||
"setInstallDefault": "Set an install-wide default"
|
||||
},
|
||||
"rateSource": {
|
||||
"customer": "from this customer",
|
||||
"installDefault": "install-wide default"
|
||||
},
|
||||
"error": {
|
||||
"noRate": "No hourly rate set for this customer. Enter a rate override, set a rate on the customer, or configure an install-wide default in Settings.",
|
||||
"createFailed": "Failed to log entry"
|
||||
}
|
||||
},
|
||||
"create": {
|
||||
@@ -3078,7 +3171,8 @@
|
||||
"savedPassiveToast": "Passive customer created.",
|
||||
"savedActiveToast": "Customer created and portal invitation sent.",
|
||||
"inviteFailedToast": "Customer saved (passive). Invitation email failed — retry from the customer detail page.",
|
||||
"emailRequired": "A valid email is required."
|
||||
"emailRequired": "A valid email is required.",
|
||||
"nameRequired": "Enter at least a company name or a contact name."
|
||||
},
|
||||
"passive": {
|
||||
"badge": "Passive — admin only",
|
||||
@@ -3183,6 +3277,7 @@
|
||||
"city": "City",
|
||||
"state": "State / region",
|
||||
"countryCode": "Country (ISO 2)",
|
||||
"country": "Country",
|
||||
"countryName": "Country (full name)",
|
||||
"notesHint": "Visible only to admins. Never shown to the customer.",
|
||||
"featuresSection": "Customer features",
|
||||
@@ -3205,20 +3300,26 @@
|
||||
},
|
||||
"billing": {
|
||||
"section": "Billing cadence",
|
||||
"hint": "Per-event (default): every invoice is sent on its own schedule. Monthly: all invoices issued in the period accumulate into one bill that fires on the configured day.",
|
||||
"hint": "Per-event (default): every invoice is sent on its own schedule. Monthly: all invoices issued in the period accumulate into one bill that fires on the configured day. Manual: items accumulate the same way, but the bill ships only when you trigger it.",
|
||||
"cadence": "Billing cadence",
|
||||
"perEvent": "Per event",
|
||||
"monthly": "Monthly",
|
||||
"quarterly": "Quarterly",
|
||||
"manual": "Manual (trigger only)",
|
||||
"cycleDay": "Cycle day",
|
||||
"cycleDayHint": "1..28 = day of month. Use negative -1..-15 for \"N days before month end\" (so -3 fires on the 28th of a 31-day month).",
|
||||
"skontoDisabled": "No Skonto for this customer",
|
||||
"skontoDisabledHint": "Disables the early-payment discount on all of this customer’s invoices, regardless of template or global defaults.",
|
||||
"triggerNow": "Trigger invoice now",
|
||||
"triggerConfirm": "Issue this customer's monthly bill now? The customer receives the email immediately.",
|
||||
"triggerConfirmManual": "Issue this customer's accumulated bill now? The customer receives the email immediately.",
|
||||
"triggerHint": "Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.",
|
||||
"triggerHintManual": "Issues the running draft immediately. Manual-cadence drafts never ship automatically — this is the only way to send them. Refuses when nothing has been queued.",
|
||||
"triggered": "Monthly bill issued: {{number}}",
|
||||
"triggerError": "Could not trigger the monthly bill.",
|
||||
"draftPreview": {
|
||||
"title": "Pending in this month's bill",
|
||||
"titleManual": "Pending — ships on manual trigger",
|
||||
"periodRange": "{{number}} · {{from}} – {{to}}"
|
||||
}
|
||||
},
|
||||
@@ -3325,7 +3426,16 @@
|
||||
"pickPlaceholder": "— Select customer —",
|
||||
"emptyList": "No customers have hours logging enabled yet. Flip \"Hours logging\" on a customer's detail page first.",
|
||||
"searchPlaceholder": "Search by email or company…",
|
||||
"customerLoggingDisabled": "This customer has hour logging disabled. Enable it on the customer's detail page to log hours."
|
||||
"customerLoggingDisabled": "This customer has hour logging disabled. Enable it on the customer's detail page to log hours.",
|
||||
"openHours": {
|
||||
"title": "Open hours across all customers",
|
||||
"subtitle": "Unbilled time blocks waiting to be billed. Pick a customer above, or click a row to drill in.",
|
||||
"empty": "No unbilled hours right now — everything is billed or no time has been logged yet.",
|
||||
"entryLine_one": "{{count}} entry · {{hours}}h",
|
||||
"entryLine_other": "{{count}} entries · {{hours}}h",
|
||||
"passive": "Passive",
|
||||
"needsRate": "Rate not set"
|
||||
}
|
||||
},
|
||||
"crmDev": {
|
||||
"title": "CRM Development",
|
||||
@@ -3471,6 +3581,9 @@
|
||||
"send": "Send",
|
||||
"resend": "Resend",
|
||||
"convert": "Convert to event",
|
||||
"declineOnBehalf": "Decline on behalf",
|
||||
"declineReasonPrompt": "Mark this quote as declined on behalf of the customer? Optionally note why (leave blank to skip).",
|
||||
"declinedOnBehalfToast": "Quote marked as declined.",
|
||||
"field": {
|
||||
"issueDate": "Issued",
|
||||
"validUntil": "Valid until",
|
||||
@@ -3479,6 +3592,7 @@
|
||||
"sentAt": "Sent at",
|
||||
"acceptedAt": "Accepted at",
|
||||
"declinedAt": "Declined at",
|
||||
"declineReason": "Decline reason",
|
||||
"responseWindow": "Response window",
|
||||
"eventTimeStart": "Start time",
|
||||
"eventTimeEnd": "End time",
|
||||
@@ -3594,6 +3708,7 @@
|
||||
"customer": "Customer",
|
||||
"event": "Event",
|
||||
"installment": "Installment",
|
||||
"issueDate": "Issued",
|
||||
"dueDate": "Due",
|
||||
"total": "Total",
|
||||
"status": "Status"
|
||||
@@ -3635,6 +3750,8 @@
|
||||
"field": {
|
||||
"issueDate": "Issued",
|
||||
"dueDate": "Due",
|
||||
"dueDateOverrideOn": "Manual due date — untick to auto-set from send date + payment term",
|
||||
"dueDateOverrideOff": "Auto from send date + payment term — tick to set manually",
|
||||
"scheduledSendAt": "Scheduled send",
|
||||
"installment": "Installment",
|
||||
"total": "Total",
|
||||
@@ -3657,6 +3774,7 @@
|
||||
"selectTiming": "— Select schedule —",
|
||||
"eventName": "Event",
|
||||
"eventDate": "Event date",
|
||||
"eventNamePlaceholder": "e.g. Smith wedding 2024",
|
||||
"eventTimeStart": "Start time",
|
||||
"eventTimeEnd": "End time",
|
||||
"customer": "Customer",
|
||||
@@ -3689,6 +3807,7 @@
|
||||
"payment": {
|
||||
"paidAt": "Date",
|
||||
"amount": "Amount",
|
||||
"date": "Payment date",
|
||||
"method": "Method",
|
||||
"reference": "Reference",
|
||||
"notes": "Notes",
|
||||
@@ -3727,6 +3846,27 @@
|
||||
"savedToast": "Business profile saved.",
|
||||
"title": "Business profile",
|
||||
"subtitle": "Issuer block shown on every quote and invoice PDF.",
|
||||
"businessHours": {
|
||||
"title": "Business hours",
|
||||
"subtitle": "Set opening hours per weekday — add a second block for a lunch break. Interpreted in the timezone above ({{tz}}).",
|
||||
"closed": "Closed",
|
||||
"addHours": "Add hours",
|
||||
"addBlock": "Add another block",
|
||||
"copyToAll": "Copy to all days",
|
||||
"startTime": "Opening time",
|
||||
"endTime": "Closing time",
|
||||
"floorToggle": "Hold scheduled emails until business hours",
|
||||
"floorToggleHelp": "When on, an automated email scheduled outside the hours above is delivered at the next opening instead of at an odd hour. When off, scheduled emails send at their exact time.",
|
||||
"weekday": {
|
||||
"1": "Monday",
|
||||
"2": "Tuesday",
|
||||
"3": "Wednesday",
|
||||
"4": "Thursday",
|
||||
"5": "Friday",
|
||||
"6": "Saturday",
|
||||
"7": "Sunday"
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"company": "Company",
|
||||
"contact": "Contact",
|
||||
@@ -3752,6 +3892,9 @@
|
||||
"timezone": "Timezone (IANA)",
|
||||
"vatLabel": "VAT label (e.g. MwSt., VAT)",
|
||||
"vatRateDefault": "Default VAT rate %",
|
||||
"defaultHourlyRate": "Default hourly rate",
|
||||
"defaultHourlyRatePlaceholder": "e.g. 120.00",
|
||||
"defaultHourlyRateHint": "Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.",
|
||||
"defaultQrFormat": "Default invoice QR",
|
||||
"footerLine": "PDF footer line"
|
||||
},
|
||||
@@ -3782,7 +3925,11 @@
|
||||
"quotes": "Quotes",
|
||||
"invoices": "Invoices",
|
||||
"paymentDefaults": "Default payment conditions",
|
||||
"installmentDefaults": "Default installment triggers"
|
||||
"installmentDefaults": "Default installment triggers",
|
||||
"contracts": "Contracts",
|
||||
"quotesTos": "Terms of Service / AGB step",
|
||||
"dashboardOverview": "Dashboard CRM overview",
|
||||
"dashboardOverviewHint": "Hide CRM overview tiles on the admin dashboard. All tiles render by default; uncheck to hide."
|
||||
},
|
||||
"paymentDefaults": {
|
||||
"help": "Pre-filled on every new quote and invoice. The editor still lets you pick a different combination per document.",
|
||||
@@ -3857,6 +4004,48 @@
|
||||
},
|
||||
"crm_invoices_late_fee_enabled": {
|
||||
"label": "Add a late fee on the second reminder"
|
||||
},
|
||||
"crm_quotes_tos_required": {
|
||||
"label": "Require customers to tick \"I accept the Terms of Service\" before accepting"
|
||||
},
|
||||
"crm_quotes_tos_url": {
|
||||
"label": "Terms of Service URL (optional)"
|
||||
},
|
||||
"crm_quotes_tos_text": {
|
||||
"label": "Inline Terms text shown on the quote page",
|
||||
"placeholder": "Paste the contract terms here. Plain text. Leave empty to only show the checkbox + URL."
|
||||
},
|
||||
"crm_contracts_pdf_attachment_enabled": {
|
||||
"label": "Attach contract PDF to email"
|
||||
},
|
||||
"crm_contracts_require_drawn_signature": {
|
||||
"label": "Require drawn signature (typed name alone is not enough)"
|
||||
},
|
||||
"crm_contracts_allow_pdf_upload": {
|
||||
"label": "Allow customer to upload a wet-signed PDF"
|
||||
},
|
||||
"crm_contracts_store_ip": {
|
||||
"label": "Store signer's IP address (recommended — corroborating evidence in civil disputes)",
|
||||
"help": "When off, the customer's and admin's IP at signing time is NOT recorded into the contract row or the public sign-page audit confirmation. Per GDPR data-minimisation principle some operators prefer this — but IP is corroborating identity evidence if the contract is challenged, so we recommend keeping it on."
|
||||
},
|
||||
"crm_contracts_default_valid_days": {
|
||||
"label": "Signing window (days)"
|
||||
},
|
||||
"crm_contracts_number_format": {
|
||||
"label": "Contract number format",
|
||||
"help": "Supported tokens: {YEAR}, {MONTH}, {SEQ:04d}. Example: LBM-C-{YEAR}-{SEQ:04d} → LBM-C-2026-0001."
|
||||
},
|
||||
"crm_overview_show_revenue": {
|
||||
"label": "Revenue tiles (30 / 90 / 365 days)"
|
||||
},
|
||||
"crm_overview_show_outstanding": {
|
||||
"label": "Outstanding payments tile"
|
||||
},
|
||||
"crm_overview_show_quotes": {
|
||||
"label": "Quotes pipeline (per-status)"
|
||||
},
|
||||
"crm_overview_show_invoices": {
|
||||
"label": "Invoices pipeline (per-status)"
|
||||
}
|
||||
},
|
||||
"quoteResponse": {
|
||||
@@ -3986,6 +4175,7 @@
|
||||
"new": "New contract"
|
||||
},
|
||||
"detail": {
|
||||
"previewPdf": "Preview PDF",
|
||||
"integrity": {
|
||||
"title": "PDF integrity check",
|
||||
"help": "Re-hashes the unsigned + signed PDFs on disk and compares them to the SHA-256 stored when the document was issued. Catches backup corruption or manual edits since the customer received their copy.",
|
||||
@@ -4000,7 +4190,108 @@
|
||||
"expected": "expected",
|
||||
"actual": "actual",
|
||||
"error": "Integrity check failed."
|
||||
}
|
||||
},
|
||||
"alreadyEventToast": "Already linked to an event.",
|
||||
"auditEmpty": "No audit-log entries yet.",
|
||||
"auditTrail": "Audit trail",
|
||||
"auditTrailHelp": "Every event recorded on this contract. The list is append-only and is the source of truth if the contract is challenged.",
|
||||
"back": "Back to list",
|
||||
"blocks": "Included blocks",
|
||||
"cancel": "Cancel",
|
||||
"cancelConfirm": "Cancel this contract? Customer signing link will be invalidated.",
|
||||
"cancelError": "Cancel failed",
|
||||
"cancelledToast": "Contract cancelled.",
|
||||
"clearSignature": "Clear",
|
||||
"confirmConvertEvent": "Convert this contract into an event + scheduled invoices?",
|
||||
"confirmConvertInvoice": "Convert this contract into invoice(s) only? No gallery / event will be created.",
|
||||
"confirmCountersign": "Counter-sign",
|
||||
"confirmResendSigned": "Re-send the signed contract PDF to both parties?",
|
||||
"confirmRestamp": "Re-stamp & re-render PDF",
|
||||
"convertError": "Convert failed",
|
||||
"convertToEvent": "Convert to event",
|
||||
"convertToInvoice": "Convert to invoice only",
|
||||
"convertedToEvent": "Converted to event",
|
||||
"convertedToEventToast": "Contract converted to event #{{id}}",
|
||||
"convertedToInvoiceToast": "{{count}} invoice(s) created from this contract",
|
||||
"countersignError": "Counter-sign failed",
|
||||
"countersignHelp": "Type your name AND draw your signature below — both are stamped onto the re-rendered PDF. IP and timestamp are recorded for audit.",
|
||||
"countersignSignaturePrompt": "Draw your signature",
|
||||
"countersignTitle": "Counter-sign to make it binding",
|
||||
"countersignedToast": "Counter-signed.",
|
||||
"customer": "Customer",
|
||||
"dates": "Dates",
|
||||
"downloadPdf": "Download PDF",
|
||||
"downloadSignedPdf": "Download signed PDF",
|
||||
"edit": "Edit",
|
||||
"fromQuote": "From quote",
|
||||
"issued": "Issued",
|
||||
"linkedInvoice": "Invoice",
|
||||
"newInvoice": "New invoice",
|
||||
"noBlocks": "No blocks included.",
|
||||
"noSignatureImage": "No signature image captured — use \"Re-stamp signatures\" below to add one.",
|
||||
"notFound": "Contract not found.",
|
||||
"parties": "Parties",
|
||||
"popupBlocked": "Allow pop-ups for this site to preview the PDF.",
|
||||
"renderFailedBody": "The signature evidence is recorded, but the stamped PDF was not generated on the last attempt. Click \"Re-send signed PDF\" above to re-stamp from the original document and resend.",
|
||||
"renderFailedTitle": "Signed PDF stamp failed — re-stamp required",
|
||||
"resendError": "Resend failed",
|
||||
"resendSigned": "Re-send signed PDF",
|
||||
"resentSignedToast": "Signed contract re-sent to both parties.",
|
||||
"restampAdmin": "Admin signature",
|
||||
"restampCustomer": "Customer signature",
|
||||
"restampError": "Re-stamp failed",
|
||||
"restampHelp": "One or both signatures didn't capture an image. Draw the missing signature(s) here and we'll re-render the PDF. The typed names, timestamps, and IPs already on file stay untouched.",
|
||||
"restampTitle": "Re-stamp missing signatures",
|
||||
"restampedToast": "Signatures re-stamped and PDF re-rendered.",
|
||||
"send": "Send to customer",
|
||||
"sendError": "Send failed",
|
||||
"sentAt": "Sent at",
|
||||
"sentToast": "Contract sent.",
|
||||
"signBy": "Sign by",
|
||||
"signatures": "Signatures",
|
||||
"signedByAdmin": "Counter-signed",
|
||||
"signedByCustomer": "Signed by customer",
|
||||
"signedNamePlaceholder": "Your full name",
|
||||
"uploadError": "Upload failed",
|
||||
"uploadSigned": "Upload signed PDF",
|
||||
"uploadedToast": "Signed PDF uploaded."
|
||||
},
|
||||
"editor": {
|
||||
"back": "Back to list",
|
||||
"backToDetail": "Back to contract",
|
||||
"create": "Create draft",
|
||||
"createdToast": "Contract created.",
|
||||
"customer": "Customer",
|
||||
"disclaimerBody": "The seeded block bodies are EXAMPLES ONLY — written by the maintainer, not by a lawyer. Have your own lawyer review every block you include before sending the contract. See docs/crm-disclaimers.md.",
|
||||
"disclaimerTitle": "Lawyer review required",
|
||||
"eventDate": "Event date",
|
||||
"eventHelp": "Snapshotted onto the contract and propagated to any event / invoice generated from it. Set this so the customer portal and dunning emails show the right \"Wedding Doe / Müller\" label.",
|
||||
"eventName": "Event name",
|
||||
"eventNamePlaceholder": "e.g. Wedding Doe / Müller",
|
||||
"eventSection": "Event (optional)",
|
||||
"eventTimeEnd": "End",
|
||||
"eventTimeStart": "Start",
|
||||
"intro": "Intro text (optional)",
|
||||
"issueDate": "Issue date",
|
||||
"language": "Language",
|
||||
"locked": "Sent contracts cannot be edited. Cancel and create a fresh one for amendments.",
|
||||
"noBlocksInSection": "No blocks for this section yet.",
|
||||
"outro": "Closing text (optional)",
|
||||
"popupBlocked": "Allow pop-ups for this site to preview the PDF.",
|
||||
"preview": "Preview PDF",
|
||||
"previewAfterSave": "Save the draft first, then preview.",
|
||||
"previewError": "Preview failed",
|
||||
"save": "Save",
|
||||
"saveError": "Save failed",
|
||||
"savedToast": "Contract saved.",
|
||||
"schriftformWarning": "Signature type: simple electronic signature (SES). Sufficient for routine photography contracts in CH / DE / AT / FL. NOT sufficient for documents that legally require Schriftform / form qualifiée: Bürgschaft (DE § 766 BGB), Verbraucherdarlehensvertrag (DE § 492 BGB), befristete Arbeitsverträge (DE § 14 Abs. 4 TzBfG), and similar. For those, a qualified electronic signature (QES) from a Trust Service Provider is required — picpeak does not provide QES.",
|
||||
"searchCustomer": "Search by email…",
|
||||
"systemBadge": "System",
|
||||
"titleEdit": "Edit contract",
|
||||
"titleField": "Contract title",
|
||||
"titleNew": "New contract",
|
||||
"titlePlaceholder": "e.g. Wedding contract Doe / Müller",
|
||||
"validUntil": "Sign by (optional)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Card, CardContent, Input, Button, Loading } from '../components/common'
|
||||
import { useGalleryAuth } from '../contexts';
|
||||
import { useGalleryInfo } from '../hooks/useGallery';
|
||||
import { usePublicSettings } from '../hooks/usePublicSettings';
|
||||
import { usePublicDarkMode } from '../hooks/usePublicDarkMode';
|
||||
import { buildResourceUrl } from '../utils/url';
|
||||
|
||||
export const ClientAccessPage: React.FC = () => {
|
||||
@@ -22,6 +23,13 @@ export const ClientAccessPage: React.FC = () => {
|
||||
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug);
|
||||
|
||||
const { data: settingsData } = usePublicSettings();
|
||||
// Theme-aware logo: the page background follows the themed
|
||||
// --color-background (dark when branding_force_color_mode / OS is dark),
|
||||
// so pick the dark logo variant accordingly.
|
||||
const { isDark } = usePublicDarkMode();
|
||||
const lightLogo = settingsData?.branding_logo_url?.trim();
|
||||
const darkLogo = settingsData?.branding_logo_url_dark?.trim();
|
||||
const brandLogo = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
|
||||
|
||||
// If already authenticated as client, redirect to gallery
|
||||
React.useEffect(() => {
|
||||
@@ -76,10 +84,10 @@ export const ClientAccessPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{settingsData?.branding_logo_url && (
|
||||
{brandLogo && (
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={buildResourceUrl(settingsData.branding_logo_url)}
|
||||
src={buildResourceUrl(brandLogo)}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
@@ -103,10 +111,10 @@ export const ClientAccessPage: React.FC = () => {
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Logo */}
|
||||
{settingsData?.branding_logo_url && (
|
||||
{brandLogo && (
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={buildResourceUrl(settingsData.branding_logo_url)}
|
||||
src={buildResourceUrl(brandLogo)}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Button, Input, Card, ReCaptcha } from '../../components/common';
|
||||
import { useAdminAuth } from '../../contexts';
|
||||
import { authService } from '../../services/auth.service';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
|
||||
import { resolveLoginLogoClasses } from '../../utils/loginLogoSize';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
@@ -27,12 +28,20 @@ export const AdminLoginPage: React.FC = () => {
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const { data: settingsData } = usePublicSettings();
|
||||
const { isDark } = useAdminDarkMode();
|
||||
|
||||
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
|
||||
const logoUrl = settingsData?.branding_logo_url?.trim();
|
||||
const resolvedLogoUrl = logoUrl
|
||||
? (logoUrl.startsWith('http') ? logoUrl : logoUrl)
|
||||
: '/picpeak-logo-transparent.png';
|
||||
// Theme-aware logo: the login page honours the admin dark-mode preference
|
||||
// (and any branding_force_color_mode). NOTE the frame nuance — a framed
|
||||
// logo sits on a fixed cream plate (see render), so the light (dark-ink)
|
||||
// logo always reads there; only the frameless logo sits on the themed
|
||||
// (possibly dark) page background and needs the dark variant.
|
||||
const lightLogo = settingsData?.branding_logo_url?.trim();
|
||||
const darkLogo = settingsData?.branding_logo_url_dark?.trim();
|
||||
const loginFrameEnabled = settingsData?.branding_login_logo_frame_enabled !== false;
|
||||
const themedLogo = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
|
||||
const logoUrl = loginFrameEnabled ? (lightLogo || darkLogo) : themedLogo;
|
||||
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
|
||||
|
||||
// Check for session expired message
|
||||
useEffect(() => {
|
||||
|
||||
@@ -19,10 +19,12 @@ import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { archiveService } from '../../services/archive.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const ArchivesPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { formatTime: fmtTime } = useLocalizedDate();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterType, setFilterType] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<'date' | 'size' | 'name'>('date');
|
||||
@@ -286,7 +288,7 @@ export const ArchivesPage: React.FC = () => {
|
||||
<div>
|
||||
<p>{formatDate(archive.archivedAt, 'MMM d, yyyy') || t('archives.processing')}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{formatDate(archive.archivedAt, 'h:mm a')}
|
||||
{archive.archivedAt ? fmtTime(archive.archivedAt) : ''}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -15,10 +15,10 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Card, Loading } from '../../components/common';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { BackupDashboard } from '../../components/admin/BackupDashboard';
|
||||
import { BackupConfiguration } from '../../components/admin/BackupConfiguration';
|
||||
import { BackupHistory } from '../../components/admin/BackupHistory';
|
||||
@@ -33,6 +33,7 @@ export const BackupManagement: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
|
||||
const tabs = [
|
||||
{ id: 'dashboard' as const, label: t('backup.tabs.dashboard'), icon: HardDrive },
|
||||
@@ -122,7 +123,7 @@ export const BackupManagement: React.FC = () => {
|
||||
<>
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
<span className="text-neutral-700 dark:text-neutral-300">
|
||||
{t('backup.status.lastBackup')}: {format(new Date(backupStatus.lastBackup.created_at), 'PPp')}
|
||||
{t('backup.status.lastBackup')}: {fmtDateTime(backupStatus.lastBackup.created_at)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -12,6 +12,7 @@ import { buildResourceUrl } from '../../utils/url';
|
||||
import { useFeatureEnabled, useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
||||
import { CustomerDashboardBrandingCard } from '../../components/admin/CustomerDashboardBrandingCard';
|
||||
import { PdfTypographyCard } from '../../components/admin/PdfTypographyCard';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
|
||||
export const BrandingPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -260,6 +261,47 @@ export const BrandingPage: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// Dark-mode logo — self-contained (the upload endpoint persists
|
||||
// branding_logo_url_dark directly; not part of the theme payload).
|
||||
// Consumers (admin header, gallery) pick it when the theme is dark.
|
||||
const { data: pubSettings } = usePublicSettings();
|
||||
const [logoDarkUrl, setLogoDarkUrl] = useState('');
|
||||
useEffect(() => {
|
||||
if (pubSettings?.branding_logo_url_dark !== undefined) {
|
||||
setLogoDarkUrl(pubSettings.branding_logo_url_dark || '');
|
||||
}
|
||||
}, [pubSettings?.branding_logo_url_dark]);
|
||||
|
||||
const refreshSettings = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
};
|
||||
|
||||
const handleDarkLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const url = await settingsService.uploadLogo(file, 'dark');
|
||||
setLogoDarkUrl(url);
|
||||
refreshSettings();
|
||||
toast.success(t('toast.uploadSuccess'));
|
||||
} catch (error) {
|
||||
console.error('Failed to upload dark logo:', error);
|
||||
toast.error(t('toast.uploadError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDarkLogo = async () => {
|
||||
try {
|
||||
await settingsService.removeLogo('dark');
|
||||
setLogoDarkUrl('');
|
||||
refreshSettings();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove dark logo:', error);
|
||||
toast.error(t('toast.saveError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleWatermarkLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
@@ -621,6 +663,50 @@ export const BrandingPage: React.FC = () => {
|
||||
{t('branding.logoHelp', 'PNG, JPG or SVG format, recommended width: 200px')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Dark-mode logo */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.logoDark', 'Dark-mode logo')}
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
{logoDarkUrl && (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={logoDarkUrl.startsWith('http') ? logoDarkUrl : buildResourceUrl(logoDarkUrl)}
|
||||
alt="Dark logo"
|
||||
className="h-16 object-contain bg-neutral-800 rounded p-2"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveDarkLogo}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
onChange={handleDarkLogoUpload}
|
||||
className="hidden"
|
||||
id="logo-dark-upload"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => document.getElementById('logo-dark-upload')?.click()}
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
>
|
||||
{logoDarkUrl ? t('branding.changeLogo', 'Change Logo') : t('branding.uploadLogo', 'Upload Logo')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
{t('branding.logoDarkHelp', 'Optional. Shown on dark themes / dark mode; falls back to the main logo when unset.')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Logo Size */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
@@ -1038,7 +1124,7 @@ export const BrandingPage: React.FC = () => {
|
||||
</h3>
|
||||
<GalleryPreview
|
||||
theme={currentTheme}
|
||||
branding={brandingSettings}
|
||||
branding={{ ...brandingSettings, logo_url_dark: logoDarkUrl }}
|
||||
className="shadow-lg"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
export const CMSPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
const { formatDateTime: fmtDateTime, formatTime: fmtTime } = useLocalizedDate();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedPage, setSelectedPage] = useState<string>('impressum');
|
||||
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
|
||||
@@ -592,7 +592,7 @@ export const CMSPage: React.FC = () => {
|
||||
{!hasUnsavedChanges && lastSaved && (
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<Clock className="w-4 h-4" />
|
||||
Saved {new Date(lastSaved).toLocaleTimeString()}
|
||||
Saved {fmtTime(new Date(lastSaved))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { addDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Input, Card, PasswordGenerator } from '../../components/common';
|
||||
import { Button, Input, Card, PasswordGenerator, LocalizedDateInput, TimeField } from '../../components/common';
|
||||
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor, FeedbackSettings } from '../../components/admin';
|
||||
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
@@ -587,13 +587,11 @@ export const CreateEventPage: React.FC = () => {
|
||||
leftIcon={<Calendar className="w-5 h-5" />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
type="date"
|
||||
<LocalizedDateInput
|
||||
label={requireEventDate ? t('events.eventDate') : `${t('events.eventDate')} (${t('common.optional')})`}
|
||||
value={formData.event_date}
|
||||
onChange={handleInputChange('event_date')}
|
||||
onChange={(iso) => setFormData(prev => ({ ...prev, event_date: iso }))}
|
||||
error={errors.event_date}
|
||||
leftIcon={<Calendar className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -615,19 +613,15 @@ export const CreateEventPage: React.FC = () => {
|
||||
</label>
|
||||
{!formData.is_full_day && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
type="time"
|
||||
step={900}
|
||||
<TimeField
|
||||
label={t('events.eventTimeStart', 'Start time') as string}
|
||||
value={formData.event_time_start}
|
||||
onChange={handleInputChange('event_time_start')}
|
||||
onChange={(v) => setFormData(prev => ({ ...prev, event_time_start: v }))}
|
||||
/>
|
||||
<Input
|
||||
type="time"
|
||||
step={900}
|
||||
<TimeField
|
||||
label={t('events.eventTimeEnd', 'End time') as string}
|
||||
value={formData.event_time_end}
|
||||
onChange={handleInputChange('event_time_end')}
|
||||
onChange={(v) => setFormData(prev => ({ ...prev, event_time_end: v }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button, Card, Input, Loading } from '../../components/common';
|
||||
import { Button, Card, CountrySelect, Input, Loading } from '../../components/common';
|
||||
import { SUPPORTED_LANGUAGES } from '../../components/common/LanguageSelector';
|
||||
import { DecimalInput } from '../../components/common/DecimalInput';
|
||||
import { AssignedEventsDialog } from '../../components/admin/AssignedEventsDialog';
|
||||
@@ -40,7 +40,7 @@ type EditableFields =
|
||||
| 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state'
|
||||
| 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes'
|
||||
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging'
|
||||
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay';
|
||||
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled';
|
||||
|
||||
// `fmtDate` (from useLocalizedDate, below) is the single canonical date
|
||||
// formatter. It honors the admin's `general_date_format` setting AND
|
||||
@@ -77,7 +77,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
queryKey: ['admin-customer-monthly-draft', customerId],
|
||||
queryFn: () => customerAdminService.getMonthlyDraft(customerId),
|
||||
enabled: Number.isFinite(customerId) && customerId > 0
|
||||
&& (customer?.billingCadence === 'monthly'),
|
||||
&& (customer?.billingCadence === 'monthly' || customer?.billingCadence === 'manual'),
|
||||
});
|
||||
const monthlyDraft = monthlyDraftRes?.draft || null;
|
||||
|
||||
@@ -140,6 +140,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
hourlyRateMinor: customer.hourlyRateMinor ?? null,
|
||||
billingCadence: customer.billingCadence ?? 'per_event',
|
||||
billingCycleDay: customer.billingCycleDay ?? 1,
|
||||
skontoDisabled: customer.skontoDisabled ?? false,
|
||||
} as any);
|
||||
}
|
||||
}, [customer, form]);
|
||||
@@ -538,27 +539,17 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
<Input value={form.state || ''} onChange={setField('state')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.countryCode', 'Country abbreviation (FL, CH, DE …)')}</label>
|
||||
<Input
|
||||
<CountrySelect
|
||||
label={t('customers.detail.country', 'Country') as string}
|
||||
value={form.countryCode || ''}
|
||||
onChange={setField('countryCode')}
|
||||
maxLength={2}
|
||||
placeholder="FL"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{/* Free-text country name override (migration 107). When
|
||||
left empty the PDF renderer falls back to the locale-
|
||||
aware lookup on the abbreviation; useful when the
|
||||
abbreviation isn't an ISO code (e.g. "FL" for
|
||||
Liechtenstein, which is "LI" in ISO). */}
|
||||
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.countryName', 'Country (full name)')}</label>
|
||||
<Input
|
||||
value={form.countryName || ''}
|
||||
onChange={setField('countryName')}
|
||||
placeholder="Liechtenstein"
|
||||
onChange={(code) => setForm((prev) => ({ ...prev, countryCode: code }))}
|
||||
/>
|
||||
</div>
|
||||
{/* The free-text "Country (full name)" override (migration 107) was
|
||||
removed as redundant — the country picker stores the ISO code and
|
||||
the PDF renderer derives the localized full name from it
|
||||
(pdfService.countryName). The DB column + the `country_name ||`
|
||||
fallback stay, so any legacy override still renders. */}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -717,9 +708,10 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
<option value="per_event">{t('customers.billing.perEvent', 'Per event')}</option>
|
||||
<option value="monthly">{t('customers.billing.monthly', 'Monthly')}</option>
|
||||
<option value="quarterly">{t('customers.billing.quarterly', 'Quarterly')}</option>
|
||||
<option value="manual">{t('customers.billing.manual', 'Manual (trigger only)')}</option>
|
||||
</select>
|
||||
</div>
|
||||
{form.billingCadence && form.billingCadence !== 'per_event' && (
|
||||
{(form.billingCadence === 'monthly' || form.billingCadence === 'quarterly') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-theme mb-1">
|
||||
{t('customers.billing.cycleDay', 'Cycle day')}
|
||||
@@ -740,26 +732,50 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Per-customer Skonto opt-out (migration 112). For B2B
|
||||
customers who negotiated "no early-payment discount" — set
|
||||
once instead of ticking the per-invoice toggle every time. */}
|
||||
<label className="mt-4 flex items-start gap-2 text-sm text-theme">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.skontoDisabled}
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, skontoDisabled: e.target.checked } as any))}
|
||||
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600"
|
||||
/>
|
||||
<span>
|
||||
{t('customers.billing.skontoDisabled', 'No Skonto for this customer')}
|
||||
<span className="block text-xs text-muted-theme">
|
||||
{t('customers.billing.skontoDisabledHint',
|
||||
'Disables the early-payment discount on all of this customer’s invoices, regardless of template or global defaults.')}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Preview of the open monthly draft (migration 128). Shows
|
||||
every line item queued for the customer's current billing
|
||||
period so admin sees exactly what "Trigger invoice now"
|
||||
would ship. Hidden when no draft exists yet (admin hasn't
|
||||
saved anything onto the period). */}
|
||||
{form.billingCadence === 'monthly' && monthlyDraft && monthlyDraft.lineItems.length > 0 && (
|
||||
{(form.billingCadence === 'monthly' || form.billingCadence === 'manual') && monthlyDraft && monthlyDraft.lineItems.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-semibold text-theme">
|
||||
{t('customers.billing.draftPreview.title',
|
||||
'Pending in this month\'s bill')}
|
||||
{form.billingCadence === 'manual'
|
||||
? t('customers.billing.draftPreview.titleManual',
|
||||
'Pending — ships on manual trigger')
|
||||
: t('customers.billing.draftPreview.title',
|
||||
'Pending in this month\'s bill')}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-theme">
|
||||
{t('customers.billing.draftPreview.periodRange',
|
||||
'{{number}} · {{from}} – {{to}}',
|
||||
{
|
||||
number: monthlyDraft.invoiceNumber,
|
||||
from: fmtDate(monthlyDraft.periodStart),
|
||||
to: fmtDate(monthlyDraft.periodEnd),
|
||||
})}
|
||||
{monthlyDraft.periodStart && monthlyDraft.periodEnd
|
||||
? t('customers.billing.draftPreview.periodRange',
|
||||
'{{number}} · {{from}} – {{to}}',
|
||||
{
|
||||
number: monthlyDraft.invoiceNumber,
|
||||
from: fmtDate(monthlyDraft.periodStart),
|
||||
to: fmtDate(monthlyDraft.periodEnd),
|
||||
})
|
||||
: monthlyDraft.invoiceNumber}
|
||||
</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
||||
@@ -816,20 +832,25 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual trigger — issue the running monthly draft NOW
|
||||
instead of waiting for the cadence-day scheduler tick.
|
||||
Only shown for monthly-mode customers (per-event has no
|
||||
draft to arm; the equivalent action there is "Bill these
|
||||
hours" on the standalone Hours-logging page). */}
|
||||
{form.billingCadence === 'monthly' && (
|
||||
{/* Manual trigger — issue the running draft NOW. For monthly
|
||||
customers this bypasses the cadence-day scheduler tick; for
|
||||
manual-cadence customers it's the ONLY way the draft ships
|
||||
(the scheduler never auto-flushes a manual draft). Per-event
|
||||
has no draft to arm; the equivalent action there is "Bill
|
||||
these hours" on the standalone Hours-logging page. */}
|
||||
{(form.billingCadence === 'monthly' || form.billingCadence === 'manual') && (
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={triggerMonthlyBillMutation.isPending}
|
||||
isLoading={triggerMonthlyBillMutation.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(t('customers.billing.triggerConfirm',
|
||||
'Issue this customer\'s monthly bill now? The customer receives the email immediately.') as string)) {
|
||||
const confirmMsg = form.billingCadence === 'manual'
|
||||
? t('customers.billing.triggerConfirmManual',
|
||||
'Issue this customer\'s accumulated bill now? The customer receives the email immediately.')
|
||||
: t('customers.billing.triggerConfirm',
|
||||
'Issue this customer\'s monthly bill now? The customer receives the email immediately.');
|
||||
if (window.confirm(confirmMsg as string)) {
|
||||
triggerMonthlyBillMutation.mutate();
|
||||
}
|
||||
}}
|
||||
@@ -837,8 +858,11 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
{t('customers.billing.triggerNow', 'Trigger invoice now')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-theme mt-2">
|
||||
{t('customers.billing.triggerHint',
|
||||
'Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.')}
|
||||
{form.billingCadence === 'manual'
|
||||
? t('customers.billing.triggerHintManual',
|
||||
'Issues the running draft immediately. Manual-cadence drafts never ship automatically — this is the only way to send them. Refuses when nothing has been queued.')
|
||||
: t('customers.billing.triggerHint',
|
||||
'Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { toast } from 'react-toastify';
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
|
||||
import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor';
|
||||
import { SentEmailsPanel } from '../../components/admin/SentEmailsPanel';
|
||||
import { Palette, RefreshCw, Info } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
|
||||
@@ -129,7 +130,7 @@ The Photo Sharing Team`,
|
||||
|
||||
export const EmailConfigPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<'smtp' | 'templates'>('smtp');
|
||||
const [activeTab, setActiveTab] = useState<'smtp' | 'templates' | 'sent'>('smtp');
|
||||
const [selectedTemplateKey, setSelectedTemplateKey] = useState<string>('gallery_created');
|
||||
const [editedTemplate, setEditedTemplate] = useState<Partial<EmailTemplate>>({});
|
||||
const [editingLang, setEditingLang] = useState<string>('en');
|
||||
@@ -227,14 +228,23 @@ export const EmailConfigPage: React.FC = () => {
|
||||
}, [selectedTemplate]);
|
||||
|
||||
// Mutations
|
||||
// Surface the actual backend error (SMTP auth/connection failure, masked
|
||||
// password, private-host rejection, …) instead of a generic toast — for
|
||||
// email config these messages are the whole diagnosis.
|
||||
const errMsg = (e: any, fallback: string): string =>
|
||||
e?.response?.data?.error
|
||||
|| e?.response?.data?.details
|
||||
|| e?.message
|
||||
|| fallback;
|
||||
|
||||
const saveConfigMutation = useMutation({
|
||||
mutationFn: (config: EmailConfig) => emailService.updateConfig(config),
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.emailConfigSaved'));
|
||||
queryClient.invalidateQueries({ queryKey: ['email-config'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.saveError'));
|
||||
onError: (e: any) => {
|
||||
toast.error(errMsg(e, t('toast.saveError')));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -243,8 +253,22 @@ export const EmailConfigPage: React.FC = () => {
|
||||
onSuccess: () => {
|
||||
toast.success(t('email.testEmailSuccess'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.saveError'));
|
||||
onError: (e: any) => {
|
||||
toast.error(errMsg(e, t('toast.saveError')));
|
||||
}
|
||||
});
|
||||
|
||||
const flushQueueMutation = useMutation({
|
||||
mutationFn: () => emailService.flushQueue(),
|
||||
onSuccess: (summary) => {
|
||||
if (summary.processed === 0) {
|
||||
toast.info(t('email.flushQueue.empty'));
|
||||
} else {
|
||||
toast.success(t('email.flushQueue.success', { sent: summary.sent, failed: summary.failed }));
|
||||
}
|
||||
},
|
||||
onError: (e: any) => {
|
||||
toast.error(errMsg(e, t('toast.saveError')));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -462,9 +486,22 @@ export const EmailConfigPage: React.FC = () => {
|
||||
>
|
||||
{t('email.emailTemplates')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('sent')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === 'sent'
|
||||
? 'border-accent text-accent'
|
||||
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{t('email.sentEmails.tab', 'Sent emails')}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Sent emails Tab */}
|
||||
{activeTab === 'sent' && <SentEmailsPanel />}
|
||||
|
||||
{/* SMTP Settings Tab */}
|
||||
{activeTab === 'smtp' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
@@ -665,6 +702,20 @@ export const EmailConfigPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('email.flushQueue.title')}</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">{t('email.flushQueue.help')}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => flushQueueMutation.mutate()}
|
||||
isLoading={flushQueueMutation.isPending}
|
||||
leftIcon={<Send className="w-5 h-5" />}
|
||||
className="w-full"
|
||||
>
|
||||
{t('email.flushQueue.button')}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Key,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Receipt,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
@@ -57,7 +58,7 @@ const safeParseDate = (dateValue: unknown): Date | null => {
|
||||
import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Button, Input, Card, Loading, MarkdownContent } from '../../components/common';
|
||||
import { Button, Input, Card, Loading, MarkdownContent, LocalizedDateInput } from '../../components/common';
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
|
||||
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
|
||||
import { EventReminderOverrideCard } from '../../components/admin/EventReminderOverrideCard';
|
||||
@@ -244,7 +245,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { format, formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
const { flags } = useFeatureFlags();
|
||||
|
||||
// Validate ID parameter
|
||||
@@ -980,6 +981,26 @@ export const EventDetailsPage: React.FC = () => {
|
||||
{t('feedback.manage', 'Manage Feedback')}
|
||||
</Button>
|
||||
)}
|
||||
{/* Create a draft invoice for this event — pre-fills the
|
||||
bill editor with the event snapshot + (when exactly
|
||||
one is linked) the customer. Gated on the bills flag. */}
|
||||
{flags.bills && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Receipt className="w-4 h-4" />}
|
||||
onClick={() => {
|
||||
const accts = ((event as { customer_accounts?: Array<{ id: number }> }).customer_accounts) || [];
|
||||
const params = new URLSearchParams({ eventId: String(event.id) });
|
||||
if (event.event_name) params.set('eventName', event.event_name);
|
||||
if (event.event_date) params.set('eventDate', String(event.event_date).slice(0, 10));
|
||||
if (accts.length === 1) params.set('customerAccountId', String(accts[0].id));
|
||||
navigate(`/admin/clients/bills/new?${params.toString()}`);
|
||||
}}
|
||||
>
|
||||
{t('events.createInvoice', 'Create invoice')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -1194,10 +1215,9 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('events.expirationDate')}
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
<LocalizedDateInput
|
||||
value={editForm.expires_at}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, expires_at: e.target.value }))}
|
||||
onChange={(iso) => setEditForm(prev => ({ ...prev, expires_at: iso }))}
|
||||
min={format(new Date(), 'yyyy-MM-dd')}
|
||||
/>
|
||||
</div>
|
||||
@@ -2331,7 +2351,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.archivedOn')}</p>
|
||||
<p className="text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{event.archived_at && format(safeParseDate(event.archived_at)!, 'PPp')}
|
||||
{event.archived_at && fmtDateTime(safeParseDate(event.archived_at)!)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -25,13 +25,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import type { PhotoFeedback, FeedbackAnalytics, FeedbackResponse } from '../../services/feedback.service';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
export const EventFeedbackPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'settings' | 'feedback' | 'analytics' | 'moderation'>('settings');
|
||||
const [feedbackFilter, setFeedbackFilter] = useState({
|
||||
type: '',
|
||||
@@ -297,7 +299,7 @@ export const EventFeedbackPage: React.FC = () => {
|
||||
const d = typeof item.created_at === 'string'
|
||||
? parseISO(item.created_at)
|
||||
: new Date(item.created_at);
|
||||
return isNaN(d.getTime()) ? t('common.unknownDate', 'Unknown date') : format(d, 'PPpp');
|
||||
return isNaN(d.getTime()) ? t('common.unknownDate', 'Unknown date') : fmtDateTime(d);
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Admin → System health. Aggregates background failures that would
|
||||
* otherwise go unnoticed. v1: stuck/failed outbound emails (the queue
|
||||
* processor gave up or exhausted retries), with retry + dismiss.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { AlertCircle, RefreshCw, Trash2, CheckCircle } from 'lucide-react';
|
||||
import { Button, Card, Loading } from '../../components/common';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { systemHealthService } from '../../services/systemHealth.service';
|
||||
|
||||
export const SystemHealthPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['system-health-failures'],
|
||||
queryFn: () => systemHealthService.getFailures(),
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['system-health-failures'] });
|
||||
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (id: number) => systemHealthService.retryEmail(id),
|
||||
onSuccess: () => { toast.success(t('systemHealth.retriedToast', 'Email re-queued.')); invalidate(); },
|
||||
onError: () => toast.error(t('toast.saveError')),
|
||||
});
|
||||
const dismissMutation = useMutation({
|
||||
mutationFn: (id: number) => systemHealthService.dismissEmail(id),
|
||||
onSuccess: () => { toast.success(t('systemHealth.dismissedToast', 'Dismissed.')); invalidate(); },
|
||||
onError: () => toast.error(t('toast.saveError')),
|
||||
});
|
||||
|
||||
const stuckEmails = data?.stuckEmails ?? [];
|
||||
|
||||
return (
|
||||
<div className="container py-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-theme">{t('systemHealth.title', 'System health')}</h1>
|
||||
<p className="text-sm text-muted-theme mt-1">
|
||||
{t('systemHealth.subtitle', 'Background failures that need attention.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card padding="lg">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('systemHealth.stuckEmails.title', 'Stuck / failed emails')}
|
||||
</h2>
|
||||
{!isLoading && (
|
||||
<span className="ml-1 text-sm text-muted-theme">({stuckEmails.length})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? <Loading /> : stuckEmails.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-green-700 dark:text-green-400 py-6">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
{t('systemHealth.stuckEmails.empty', 'No stuck or failed emails — all clear.')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.recipient', 'Recipient')}</th>
|
||||
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.type', 'Type')}</th>
|
||||
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.error', 'Error')}</th>
|
||||
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.queued', 'Queued')}</th>
|
||||
<th className="px-3 py-2 text-right">{t('systemHealth.stuckEmails.col.actions', 'Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stuckEmails.map((m) => (
|
||||
<tr key={m.id} className="border-t border-neutral-200 dark:border-neutral-700 align-top">
|
||||
<td className="px-3 py-2 break-all">{m.recipientEmail}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">{m.emailType}</td>
|
||||
<td className="px-3 py-2 max-w-xs">
|
||||
<span className="text-xs text-red-700 dark:text-red-400 break-words">
|
||||
{m.errorMessage || t('systemHealth.stuckEmails.noError', 'retries exhausted')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{m.createdAt ? fmtDateTime(m.createdAt) : '—'}</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="outline" size="sm"
|
||||
isLoading={retryMutation.isPending && retryMutation.variables === m.id}
|
||||
onClick={() => retryMutation.mutate(m.id)}
|
||||
leftIcon={<RefreshCw className="w-3.5 h-3.5" />}>
|
||||
{t('systemHealth.retry', 'Retry')}
|
||||
</Button>
|
||||
<button type="button"
|
||||
aria-label={t('systemHealth.dismiss', 'Dismiss') as string}
|
||||
onClick={() => dismissMutation.mutate(m.id)}
|
||||
className="p-1.5 text-neutral-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Eye, Send, CheckCircle, BellRing, XCircle, Truck, Edit2, RefreshCw } from 'lucide-react';
|
||||
import { Button, Card, Loading, Input } from '../../../components/common';
|
||||
import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common';
|
||||
import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
|
||||
import { billsService } from '../../../services/bills.service';
|
||||
import { formatMoney } from '../../../components/admin/LineItemsTable';
|
||||
@@ -29,6 +29,9 @@ export const BillDetailPage: React.FC = () => {
|
||||
|
||||
const [payDialogOpen, setPayDialogOpen] = useState(false);
|
||||
const [payAmount, setPayAmount] = useState('');
|
||||
// Optional payment date — defaults to today, backdate it to when the
|
||||
// payment actually arrived. Drives `paid_at` (cash-basis revenue windows).
|
||||
const [payDate, setPayDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [payMethod, setPayMethod] = useState('');
|
||||
const [payReference, setPayReference] = useState('');
|
||||
const [payNotes, setPayNotes] = useState('');
|
||||
@@ -210,6 +213,7 @@ export const BillDetailPage: React.FC = () => {
|
||||
try {
|
||||
await billsService.markPaid(inv.id, {
|
||||
amountMinor: Math.round(Number(payAmount) * 100),
|
||||
paidAt: payDate || undefined,
|
||||
paymentMethod: payMethod || undefined,
|
||||
reference: payReference || undefined,
|
||||
notes: payNotes || undefined,
|
||||
@@ -218,6 +222,7 @@ export const BillDetailPage: React.FC = () => {
|
||||
setPayDialogOpen(false);
|
||||
setPayAmount(''); setPayMethod(''); setPayReference(''); setPayNotes('');
|
||||
setPayWithSkonto(false);
|
||||
setPayDate(new Date().toISOString().slice(0, 10));
|
||||
qc.invalidateQueries({ queryKey: ['invoice', id] });
|
||||
toast.success(t('bills.paymentRecordedToast', 'Payment recorded.'));
|
||||
} catch (e: any) {
|
||||
@@ -386,7 +391,14 @@ export const BillDetailPage: React.FC = () => {
|
||||
<Card>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
{inv.eventName && (
|
||||
<div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.eventName', 'Event')}</div><div>{inv.eventName}{inv.eventDate ? ` · ${inv.eventDate}` : ''}</div></div>
|
||||
<div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.eventName', 'Event')}</div>
|
||||
<div>
|
||||
{inv.eventId ? (
|
||||
<Link to={`/admin/events/${inv.eventId}`} className="text-theme hover:underline">{inv.eventName}</Link>
|
||||
) : inv.eventName}
|
||||
{inv.eventDate ? ` · ${fmtDate(inv.eventDate)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.issueDate', 'Issued')}</div><div>{fmtDate(inv.issueDate)}</div></div>
|
||||
<div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.dueDate', 'Due')}</div><div>{fmtDate(inv.dueDate)}</div></div>
|
||||
@@ -459,6 +471,14 @@ export const BillDetailPage: React.FC = () => {
|
||||
<div className="space-y-3">
|
||||
<Input type="number" step="0.01" label={t('bills.payment.amount', 'Amount') as string} value={payAmount}
|
||||
onChange={(e) => setPayAmount(e.target.value)} placeholder={String(outstanding.toFixed(2))} />
|
||||
{/* Optional payment date — drives `paid_at`, which the
|
||||
dashboard's cash-basis revenue windows key on. Defaults
|
||||
to today; backdate it to when the payment actually arrived. */}
|
||||
<LocalizedDateInput
|
||||
label={t('bills.payment.date', 'Payment date') as string}
|
||||
value={payDate}
|
||||
onChange={setPayDate}
|
||||
/>
|
||||
{/* Skonto checkbox (migration 126). Only surfaced when
|
||||
the invoice's payment terms actually offer Skonto —
|
||||
the backend resolves skontoPercent from the snapshot
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Eye, Save as SaveIcon } from 'lucide-react';
|
||||
import { Button, Card, Loading, Input } from '../../../components/common';
|
||||
import { Button, Card, Loading, Input, LocalizedDateInput, TimeField } from '../../../components/common';
|
||||
import { billsService, type InvoiceCreatePayload, type InvoiceQrFormat } from '../../../services/bills.service';
|
||||
import { quotesService } from '../../../services/quotes.service';
|
||||
import { contractsService } from '../../../services/contracts.service';
|
||||
@@ -20,7 +20,6 @@ import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||
import { userManagementService } from '../../../services/userManagement.service';
|
||||
import { settingsService } from '../../../services/settings.service';
|
||||
import { useAdminAuth } from '../../../contexts/AdminAuthContext';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
function toMinor(amount: number) {
|
||||
@@ -29,8 +28,6 @@ function toMinor(amount: number) {
|
||||
|
||||
export const BillEditorPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { timeFormat } = useLocalizedDate();
|
||||
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -48,6 +45,12 @@ export const BillEditorPage: React.FC = () => {
|
||||
const [currency, setCurrency] = useState('CHF');
|
||||
const [issueDate, setIssueDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
// Due date is normally view-only: it auto-tracks (send date else issue
|
||||
// date) + the selected Net-days template, so the payment clock starts
|
||||
// on the day the invoice actually goes out. Flipping this lets the
|
||||
// admin type a different date by hand; we keep it pinned so the auto
|
||||
// effect below stops clobbering their value.
|
||||
const [dueDateOverridden, setDueDateOverridden] = useState(false);
|
||||
const [scheduledSendAt, setScheduledSendAt] = useState('');
|
||||
// null = inherit profile default at render time. 'none' / 'swiss' /
|
||||
// 'epc' = explicit per-invoice override. (Existing invoices that
|
||||
@@ -80,6 +83,7 @@ export const BillEditorPage: React.FC = () => {
|
||||
// event section — admin can type a free-text label without needing
|
||||
// an actual events row, and it carries through to the customer
|
||||
// portal + tax report + email templates.
|
||||
const [eventId, setEventId] = useState<number | null>(null);
|
||||
const [eventName, setEventName] = useState('');
|
||||
const [eventDate, setEventDate] = useState('');
|
||||
const [eventTimeStart, setEventTimeStart] = useState('');
|
||||
@@ -124,6 +128,10 @@ export const BillEditorPage: React.FC = () => {
|
||||
setCurrency(inv.currency);
|
||||
setIssueDate(inv.issueDate);
|
||||
setDueDate(inv.dueDate);
|
||||
// The invoice already carries a due date — preserve it rather than
|
||||
// letting the auto effect recompute and surprise the admin. They
|
||||
// can untick "Override" to re-enable auto-tracking.
|
||||
setDueDateOverridden(true);
|
||||
setScheduledSendAt(inv.scheduledSendAt ? inv.scheduledSendAt.slice(0, 16) : '');
|
||||
// Preserve null when the saved invoice has no explicit format —
|
||||
// it inherits the profile default at render time.
|
||||
@@ -136,6 +144,7 @@ export const BillEditorPage: React.FC = () => {
|
||||
setPaymentTimingTemplateId(inv.paymentTimingTemplateId ?? null);
|
||||
setBusinessBankAccountId(inv.businessBankAccountId ?? null);
|
||||
setSkontoDisabled(Boolean(inv.skontoDisabled));
|
||||
setEventId(inv.eventId ?? null);
|
||||
setEventName(inv.eventName || '');
|
||||
setEventDate(inv.eventDate || '');
|
||||
setEventTimeStart(inv.eventTimeStart || '');
|
||||
@@ -209,6 +218,23 @@ export const BillEditorPage: React.FC = () => {
|
||||
})();
|
||||
}, [isEdit, searchParams, customerId]);
|
||||
|
||||
// Pre-fill the event link + snapshot when opened from an event's
|
||||
// "Create invoice" button (/admin/clients/bills/new?eventId=&eventName=&eventDate=).
|
||||
const didPrefillEventRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (isEdit) return;
|
||||
if (didPrefillEventRef.current) return;
|
||||
const eidRaw = searchParams.get('eventId');
|
||||
const eid = eidRaw ? parseInt(eidRaw, 10) : NaN;
|
||||
const en = searchParams.get('eventName');
|
||||
const ed = searchParams.get('eventDate');
|
||||
if (!(Number.isFinite(eid) && eid > 0) && !en && !ed) return;
|
||||
didPrefillEventRef.current = true;
|
||||
if (Number.isFinite(eid) && eid > 0) setEventId(eid);
|
||||
if (en) setEventName((prev) => prev || en);
|
||||
if (ed) setEventDate((prev) => prev || ed);
|
||||
}, [isEdit, searchParams]);
|
||||
|
||||
// Pre-fill from a fully-signed contract when the editor is opened
|
||||
// via `?fromContractId=<id>` (the "New invoice" link on
|
||||
// ContractDetailPage's header, used after the contract has been
|
||||
@@ -317,6 +343,25 @@ export const BillEditorPage: React.FC = () => {
|
||||
setPaymentTimingTemplateId((prev) => prev ?? defaultTiming.id);
|
||||
}, [isEdit, netDaysTemplates, timingTemplates, appSettings]);
|
||||
|
||||
// Auto-track the due date off (scheduled send date else issue date) +
|
||||
// the selected Net-days template, mirroring the backend's
|
||||
// computeDueDate. The clock starts the day the invoice goes out, so
|
||||
// scheduling a future send pushes the due date out with it. Skipped
|
||||
// once the admin overrides the field by hand. Date math is in UTC to
|
||||
// match the backend (which parses the YYYY-MM-DD base as UTC midnight).
|
||||
useEffect(() => {
|
||||
if (dueDateOverridden) return;
|
||||
const base = (scheduledSendAt ? scheduledSendAt.slice(0, 10) : issueDate) || '';
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(base)) return;
|
||||
const tpl = netDaysTemplates?.templates?.find((t) => t.id === paymentNetDaysTemplateId);
|
||||
const netDays = tpl?.netDays != null
|
||||
? Number(tpl.netDays)
|
||||
: Number(appSettings?.crm_payment_default_net_days) || 30;
|
||||
const d = new Date(`${base}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + netDays);
|
||||
setDueDate(d.toISOString().slice(0, 10));
|
||||
}, [dueDateOverridden, scheduledSendAt, issueDate, paymentNetDaysTemplateId, netDaysTemplates, appSettings]);
|
||||
|
||||
const buildPayload = (): InvoiceCreatePayload => ({
|
||||
customerAccountId: customerId || 0,
|
||||
currency,
|
||||
@@ -358,6 +403,7 @@ export const BillEditorPage: React.FC = () => {
|
||||
// so the backend can distinguish "not provided" from a deliberate
|
||||
// clear (which the route's `optional({ values: 'falsy' })` already
|
||||
// treats identically — falsy values bypass validation entirely).
|
||||
eventId: eventId ?? undefined,
|
||||
eventName: eventName || undefined,
|
||||
eventDate: eventDate || undefined,
|
||||
eventTimeStart: eventTimeStart || undefined,
|
||||
@@ -488,20 +534,38 @@ export const BillEditorPage: React.FC = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Input label={t('bills.field.eventName', 'Event') as string}
|
||||
value={eventName} onChange={(e) => setEventName(e.target.value)} />
|
||||
<Input type="date" label={t('bills.field.eventDate', 'Event date') as string}
|
||||
value={eventDate} onChange={(e) => setEventDate(e.target.value)} />
|
||||
<Input type="time" lang={timeInputLang} label={t('bills.field.eventTimeStart', 'Start time') as string}
|
||||
value={eventTimeStart} onChange={(e) => setEventTimeStart(e.target.value)} />
|
||||
<Input type="time" lang={timeInputLang} label={t('bills.field.eventTimeEnd', 'End time') as string}
|
||||
value={eventTimeEnd} onChange={(e) => setEventTimeEnd(e.target.value)} />
|
||||
<LocalizedDateInput label={t('bills.field.eventDate', 'Event date') as string}
|
||||
value={eventDate} onChange={setEventDate} />
|
||||
<TimeField label={t('bills.field.eventTimeStart', 'Start time') as string}
|
||||
value={eventTimeStart} onChange={setEventTimeStart} />
|
||||
<TimeField label={t('bills.field.eventTimeEnd', 'End time') as string}
|
||||
value={eventTimeEnd} onChange={setEventTimeEnd} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-2">{t('bills.section.details', 'Details')}</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Input type="date" label={t('bills.field.issueDate', 'Issue date') as string} value={issueDate} onChange={(e) => setIssueDate(e.target.value)} />
|
||||
<Input type="date" label={t('bills.field.dueDate', 'Due date') as string} value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
|
||||
<LocalizedDateInput label={t('bills.field.issueDate', 'Issue date') as string} value={issueDate} onChange={setIssueDate} />
|
||||
<div>
|
||||
<LocalizedDateInput
|
||||
label={t('bills.field.dueDate', 'Due date') as string}
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
disabled={!dueDateOverridden}
|
||||
/>
|
||||
<label className="mt-1.5 flex items-center gap-2 text-xs text-neutral-600 dark:text-neutral-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dueDateOverridden}
|
||||
onChange={(e) => setDueDateOverridden(e.target.checked)}
|
||||
className="rounded border-neutral-300 dark:border-neutral-600"
|
||||
/>
|
||||
{dueDateOverridden
|
||||
? t('bills.field.dueDateOverrideOn', 'Manual due date — untick to auto-set from send date + payment term')
|
||||
: t('bills.field.dueDateOverrideOff', 'Auto from send date + payment term — tick to set manually')}
|
||||
</label>
|
||||
</div>
|
||||
<Input type="datetime-local" label={t('bills.field.scheduledSendAt', 'Scheduled send (optional)') as string}
|
||||
value={scheduledSendAt} onChange={(e) => setScheduledSendAt(e.target.value)} />
|
||||
<div>
|
||||
@@ -589,6 +653,10 @@ export const BillEditorPage: React.FC = () => {
|
||||
only has the single FK still resolve their preview text. */}
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-2">{t('bills.section.payment', 'Payment conditions')}</h3>
|
||||
<Link to="/admin/settings?tab=crm"
|
||||
className="text-xs text-accent hover:underline mb-2 inline-block">
|
||||
{t('common.configureInSettings', 'Configure defaults in Settings ↗')}
|
||||
</Link>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('bills.field.paymentNetDays', 'Net days')}</label>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Search, Upload, X } from 'lucide-react';
|
||||
import { billsService, type InvoiceStatus, type InvoiceSort } from '../../../services/bills.service';
|
||||
import { Button, Card, Input, Loading } from '../../../components/common';
|
||||
import { Button, Card, Input, Loading, LocalizedDateInput, SortableHeader, useColumnSort, type SortColumnMap } from '../../../components/common';
|
||||
import { formatMoney } from '../../../components/admin/LineItemsTable';
|
||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
@@ -16,6 +16,18 @@ import { toast } from 'react-toastify';
|
||||
|
||||
const STATUSES: InvoiceStatus[] = ['scheduled', 'pending_delivery', 'sent', 'paid', 'overdue', 'cancelled', 'skipped'];
|
||||
|
||||
// Maps each clickable column to its server-side sort enum pair. The "#"
|
||||
// column sorts by creation order (newest/oldest) since that's how the
|
||||
// invoice sequence is assigned; "Issued" sorts the admin-controlled
|
||||
// issue_date and is the default (newest issued first).
|
||||
const SORT_COLUMNS: SortColumnMap = {
|
||||
number: { asc: 'oldest', desc: 'newest', defaultDir: 'desc' },
|
||||
customer: { asc: 'customer_asc', desc: 'customer_desc' },
|
||||
issue: { asc: 'issue_asc', desc: 'issue_desc', defaultDir: 'desc' },
|
||||
due: { asc: 'due_asc', desc: 'due_desc' },
|
||||
value: { asc: 'value_asc', desc: 'value_desc', defaultDir: 'desc' },
|
||||
};
|
||||
|
||||
export const BillsListPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -23,10 +35,12 @@ export const BillsListPage: React.FC = () => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<InvoiceStatus[]>([]);
|
||||
const [unpaidOnly, setUnpaidOnly] = useState(false);
|
||||
const [sort, setSort] = useState<InvoiceSort>('newest');
|
||||
const { sort, activeKey, activeDir, toggle } = useColumnSort<InvoiceSort>(SORT_COLUMNS, 'issue_desc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
const onSort = (key: string) => { toggle(key); setPage(1); };
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['invoices', { search, statusFilter, unpaidOnly, sort, page }],
|
||||
queryFn: () => billsService.list({
|
||||
@@ -87,18 +101,6 @@ export const BillsListPage: React.FC = () => {
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as InvoiceSort)}
|
||||
>
|
||||
<option value="newest">{t('bills.sort.newest', 'Newest first')}</option>
|
||||
<option value="due_asc">{t('bills.sort.dueAsc', 'Due soon first')}</option>
|
||||
<option value="due_desc">{t('bills.sort.dueDesc', 'Due latest first')}</option>
|
||||
<option value="customer_asc">{t('bills.sort.customerAsc', 'Customer A→Z')}</option>
|
||||
<option value="value_asc">{t('bills.sort.valueAsc', 'Value low→high')}</option>
|
||||
<option value="value_desc">{t('bills.sort.valueDesc', 'Value high→low')}</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={unpaidOnly} onChange={(e) => setUnpaidOnly(e.target.checked)} />
|
||||
{t('bills.filter.unpaidOnly', 'Unpaid only')}
|
||||
@@ -127,12 +129,13 @@ export const BillsListPage: React.FC = () => {
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">#</th>
|
||||
<th className="px-3 py-2 text-left">{t('bills.table.customer', 'Customer')}</th>
|
||||
<SortableHeader label="#" columnKey="number" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
|
||||
<SortableHeader label={t('bills.table.customer', 'Customer')} columnKey="customer" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
|
||||
<th className="px-3 py-2 text-left">{t('bills.table.event', 'Event')}</th>
|
||||
<th className="px-3 py-2 text-left">{t('bills.table.installment', 'Installment')}</th>
|
||||
<th className="px-3 py-2 text-left">{t('bills.table.dueDate', 'Due')}</th>
|
||||
<th className="px-3 py-2 text-right">{t('bills.table.total', 'Total')}</th>
|
||||
<SortableHeader label={t('bills.table.issueDate', 'Issued')} columnKey="issue" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
|
||||
<SortableHeader label={t('bills.table.dueDate', 'Due')} columnKey="due" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
|
||||
<SortableHeader label={t('bills.table.total', 'Total')} columnKey="value" activeKey={activeKey} activeDir={activeDir} onSort={onSort} align="right" />
|
||||
<th className="px-3 py-2 text-left">{t('bills.table.status', 'Status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -167,10 +170,17 @@ export const BillsListPage: React.FC = () => {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2">{inv.customer.companyName || inv.customer.displayName || inv.customer.email}</td>
|
||||
<td className="px-3 py-2 truncate max-w-xs">{inv.eventName || '—'}</td>
|
||||
<td className="px-3 py-2 truncate max-w-xs">
|
||||
{inv.eventName
|
||||
? (inv.eventId
|
||||
? <Link to={`/admin/events/${inv.eventId}`} className="text-theme hover:underline" onClick={(e) => e.stopPropagation()}>{inv.eventName}</Link>
|
||||
: inv.eventName)
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-muted-theme">
|
||||
{inv.installmentTotal > 1 ? `${inv.installmentIndex + 1}/${inv.installmentTotal} · ${inv.installmentLabel || ''}` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap">{inv.issueDate ? fmtDate(inv.issueDate) : '—'}</td>
|
||||
<td className="px-3 py-2">{fmtDate(inv.dueDate)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">
|
||||
{formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)}
|
||||
@@ -215,6 +225,8 @@ const ImportHistoricalInvoiceModal: React.FC<ImportModalProps> = ({ onClose }) =
|
||||
const [customerId, setCustomerId] = useState<number | null>(null);
|
||||
const [customerLabel, setCustomerLabel] = useState('');
|
||||
const [invoiceNumber, setInvoiceNumber] = useState('');
|
||||
const [eventName, setEventName] = useState('');
|
||||
const [eventDate, setEventDate] = useState('');
|
||||
const [issueDate, setIssueDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [totalMajor, setTotalMajor] = useState('');
|
||||
@@ -239,6 +251,8 @@ const ImportHistoricalInvoiceModal: React.FC<ImportModalProps> = ({ onClose }) =
|
||||
await billsService.importHistorical({
|
||||
customerAccountId: customerId,
|
||||
invoiceNumber,
|
||||
eventName: eventName.trim() || undefined,
|
||||
eventDate: eventDate || undefined,
|
||||
issueDate,
|
||||
dueDate: dueDate || undefined,
|
||||
totalAmountMinor: Math.round(Number(totalMajor) * 100),
|
||||
@@ -321,6 +335,17 @@ const ImportHistoricalInvoiceModal: React.FC<ImportModalProps> = ({ onClose }) =
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="md:col-span-2">
|
||||
<Input label={t('bills.field.eventName', 'Event / occasion (optional)') as string}
|
||||
value={eventName}
|
||||
placeholder={t('bills.field.eventNamePlaceholder', 'e.g. Smith wedding 2024') as string}
|
||||
onChange={(e) => setEventName(e.target.value)} />
|
||||
</div>
|
||||
<LocalizedDateInput
|
||||
label={t('bills.field.eventDate', 'Event date (optional)') as string}
|
||||
value={eventDate}
|
||||
onChange={setEventDate}
|
||||
/>
|
||||
<Input label={t('bills.field.invoiceNumber', 'Invoice number') as string}
|
||||
value={invoiceNumber}
|
||||
placeholder="R-2024-0001"
|
||||
@@ -329,12 +354,12 @@ const ImportHistoricalInvoiceModal: React.FC<ImportModalProps> = ({ onClose }) =
|
||||
value={currency}
|
||||
maxLength={3}
|
||||
onChange={(e) => setCurrency(e.target.value.toUpperCase())} />
|
||||
<LocalizedDateField
|
||||
<LocalizedDateInput
|
||||
label={t('bills.field.issueDate', 'Issued') as string}
|
||||
value={issueDate}
|
||||
onChange={setIssueDate}
|
||||
/>
|
||||
<LocalizedDateField
|
||||
<LocalizedDateInput
|
||||
label={t('bills.field.dueDate', 'Due') as string}
|
||||
value={dueDate}
|
||||
onChange={setDueDate}
|
||||
@@ -392,113 +417,3 @@ const ImportHistoricalInvoiceModal: React.FC<ImportModalProps> = ({ onClose }) =
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Date field that displays + accepts values in the admin-configured
|
||||
* format from Settings → General (`general_date_format`). Stores
|
||||
* + emits ISO (YYYY-MM-DD) so the rest of the form / API surface
|
||||
* keeps the canonical shape.
|
||||
*
|
||||
* Native `<input type="date">` always renders in the browser's
|
||||
* locale (en-US users see MM/DD/YYYY), which mismatched what
|
||||
* customers + the rest of the app see elsewhere. This component
|
||||
* uses a plain text input + parses on blur, with the configured
|
||||
* format shown as both placeholder and helper text. A small
|
||||
* shadow native date input next to the field gives the click-to-
|
||||
* open calendar without affecting the displayed format.
|
||||
*/
|
||||
interface LocalizedDateFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (iso: string) => void;
|
||||
}
|
||||
const LocalizedDateField: React.FC<LocalizedDateFieldProps> = ({ label, value, onChange }) => {
|
||||
const { dateFormat } = useLocalizedDate();
|
||||
// Normalise the configured format down to the four shapes our
|
||||
// parser understands. Defaults to DD.MM.YYYY (the maintainer's
|
||||
// primary locale) when unknown.
|
||||
const normalisedFormat = ((): 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD' => {
|
||||
const f = String(dateFormat || 'dd.MM.yyyy').toLowerCase();
|
||||
if (f.startsWith('mm/dd')) return 'MM/DD/YYYY';
|
||||
if (f.startsWith('yyyy')) return 'YYYY-MM-DD';
|
||||
if (f.includes('/')) return 'DD/MM/YYYY';
|
||||
return 'DD.MM.YYYY';
|
||||
})();
|
||||
const placeholder = normalisedFormat.toLowerCase();
|
||||
|
||||
// ISO → display
|
||||
const toDisplay = (iso: string): string => {
|
||||
if (!iso) return '';
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||||
if (!m) return iso;
|
||||
const [, y, mo, d] = m;
|
||||
switch (normalisedFormat) {
|
||||
case 'MM/DD/YYYY': return `${mo}/${d}/${y}`;
|
||||
case 'YYYY-MM-DD': return `${y}-${mo}-${d}`;
|
||||
case 'DD/MM/YYYY': return `${d}/${mo}/${y}`;
|
||||
case 'DD.MM.YYYY':
|
||||
default: return `${d}.${mo}.${y}`;
|
||||
}
|
||||
};
|
||||
|
||||
// display → ISO (accepts variant separators leniently)
|
||||
const toIso = (raw: string): string => {
|
||||
const s = raw.trim();
|
||||
if (!s) return '';
|
||||
// Already ISO?
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
|
||||
// Split on . / -
|
||||
const parts = s.split(/[./-]/);
|
||||
if (parts.length !== 3) return '';
|
||||
let [a, b, c] = parts;
|
||||
let y: string, mo: string, d: string;
|
||||
if (normalisedFormat === 'YYYY-MM-DD' || a.length === 4) {
|
||||
[y, mo, d] = [a, b, c];
|
||||
} else if (normalisedFormat === 'MM/DD/YYYY') {
|
||||
[mo, d, y] = [a, b, c];
|
||||
} else {
|
||||
// DD.MM.YYYY or DD/MM/YYYY
|
||||
[d, mo, y] = [a, b, c];
|
||||
}
|
||||
if (!/^\d{1,2}$/.test(d) || !/^\d{1,2}$/.test(mo) || !/^\d{4}$/.test(y)) return '';
|
||||
return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const [text, setText] = React.useState(toDisplay(value));
|
||||
React.useEffect(() => { setText(toDisplay(value)); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [value]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{label}</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={text}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onBlur={() => {
|
||||
const iso = toIso(text);
|
||||
if (iso) {
|
||||
onChange(iso);
|
||||
setText(toDisplay(iso));
|
||||
} else if (!text.trim()) {
|
||||
onChange('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Tiny native date picker shortcut — gives the calendar
|
||||
without polluting the visible text input. Hidden value
|
||||
stays in ISO so it's always parseable. */}
|
||||
<input
|
||||
type="date"
|
||||
value={value || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={label}
|
||||
className="text-sm px-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800"
|
||||
style={{ width: 36 }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1">{placeholder}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Lock, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Button, Card, Input } from '../../../components/common';
|
||||
import { Button, Card, Input, TimeField } from '../../../components/common';
|
||||
import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||
import type { CalendarHoursItem } from '../../../services/calendar.service';
|
||||
|
||||
@@ -176,23 +176,13 @@ export const HourEntryInlinePopover: React.FC<HourEntryInlinePopoverProps> = ({
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t('calendar.hourEntry.startLabel', 'Start')}
|
||||
</label>
|
||||
<Input
|
||||
type="time"
|
||||
step={900}
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
/>
|
||||
<TimeField value={startTime} onChange={setStartTime} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t('calendar.hourEntry.endLabel', 'End')}
|
||||
</label>
|
||||
<Input
|
||||
type="time"
|
||||
step={900}
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
/>
|
||||
<TimeField value={endTime} onChange={setEndTime} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Clock, ChevronRight, AlertTriangle } from 'lucide-react';
|
||||
import { Card } from '../../../components/common';
|
||||
import { HoursSection } from '../../../components/admin/HoursSection';
|
||||
import {
|
||||
@@ -22,7 +23,21 @@ import {
|
||||
import {
|
||||
customerAdminService,
|
||||
type CustomerAccountDetail,
|
||||
type UnbilledHoursSummaryRow,
|
||||
} from '../../../services/customerAdmin.service';
|
||||
import { businessProfileService } from '../../../services/businessProfile.service';
|
||||
import { formatMoneyMinor } from '../../../utils/money';
|
||||
|
||||
/** Build a display label matching the CustomerPicker convention. */
|
||||
function summaryLabel(r: UnbilledHoursSummaryRow): string {
|
||||
return (
|
||||
r.companyName
|
||||
|| [r.firstName, r.lastName].filter(Boolean).join(' ')
|
||||
|| r.displayName
|
||||
|| r.email
|
||||
|| `#${r.customerAccountId}`
|
||||
);
|
||||
}
|
||||
|
||||
export const HoursLoggingPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -47,6 +62,30 @@ export const HoursLoggingPage: React.FC = () => {
|
||||
enabled: !!selectedId,
|
||||
});
|
||||
|
||||
// Landing aggregate — every customer with open (unbilled) hours. Only
|
||||
// fetched while no customer is picked; once one is selected the page
|
||||
// hands over to HoursSection. invalidated implicitly by remount on
|
||||
// re-entry (HoursSection mutations bump per-customer keys).
|
||||
const { data: summary = [], isLoading: summaryLoading } = useQuery({
|
||||
queryKey: ['admin-unbilled-hours-summary'],
|
||||
queryFn: () => customerAdminService.getUnbilledHoursSummary(),
|
||||
enabled: !selectedId,
|
||||
});
|
||||
|
||||
const { data: profileSnapshot } = useQuery({
|
||||
queryKey: ['business-profile-snapshot'],
|
||||
queryFn: () => businessProfileService.get(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const currency = profileSnapshot?.profile?.defaultCurrency || 'CHF';
|
||||
|
||||
const selectFromSummary = (r: UnbilledHoursSummaryRow) => {
|
||||
setSelectedId(r.customerAccountId);
|
||||
setCustomerLabel(summaryLabel(r));
|
||||
setCustomerIsPassive(r.isPassive);
|
||||
setCustomerHoursAllowed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container py-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -116,6 +155,76 @@ export const HoursLoggingPage: React.FC = () => {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{!selectedId && (
|
||||
<Card padding="lg">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock className="w-4 h-4 text-muted-theme" />
|
||||
<h2 className="text-base font-semibold text-theme">
|
||||
{t('hoursLogging.openHours.title', 'Open hours across all customers')}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-theme mb-4">
|
||||
{t('hoursLogging.openHours.subtitle',
|
||||
'Unbilled time blocks waiting to be billed. Pick a customer above, or click a row to drill in.')}
|
||||
</p>
|
||||
|
||||
{summaryLoading ? (
|
||||
<p className="text-sm text-muted-theme py-6 text-center">
|
||||
{t('common.loading', 'Loading…')}
|
||||
</p>
|
||||
) : summary.length === 0 ? (
|
||||
<p className="text-sm text-muted-theme py-6 text-center">
|
||||
{t('hoursLogging.openHours.empty',
|
||||
'No unbilled hours right now — everything is billed or no time has been logged yet.')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{summary.map((r) => (
|
||||
<button
|
||||
key={r.customerAccountId}
|
||||
type="button"
|
||||
onClick={() => selectFromSummary(r)}
|
||||
className="w-full flex items-center justify-between gap-4 py-3 text-left hover:bg-neutral-50 dark:hover:bg-neutral-800/60 rounded-md px-2 -mx-2 transition-colors"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-theme truncate">{summaryLabel(r)}</span>
|
||||
{r.isPassive && (
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-600 dark:bg-neutral-700 dark:text-neutral-300">
|
||||
{t('hoursLogging.openHours.passive', 'Passive')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-theme mt-0.5">
|
||||
{t('hoursLogging.openHours.entryLine', {
|
||||
count: r.entryCount,
|
||||
hours: (r.totalMinutes / 60).toFixed(2),
|
||||
defaultValue: '{{count}} entries · {{hours}}h',
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="text-right">
|
||||
{r.rateResolvable ? (
|
||||
<div className="font-semibold text-theme tabular-nums">
|
||||
{formatMoneyMinor(r.openAmountMinor, currency)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-amber-700 dark:text-amber-300 text-xs">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
{t('hoursLogging.openHours.needsRate', 'Rate not set')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-theme" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{selectedId && customerHoursAllowed && (
|
||||
<HoursSection
|
||||
customerId={selectedId}
|
||||
|
||||
@@ -18,7 +18,7 @@ import React, { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Calculator, Download, FileDown, AlertCircle } from 'lucide-react';
|
||||
import { Button, Card, Loading, Input } from '../../../components/common';
|
||||
import { Button, Card, Loading, LocalizedDateInput } from '../../../components/common';
|
||||
|
||||
// Lightweight native select styled to match Input — the common barrel
|
||||
// doesn't export a Select component, and the form pieces here are
|
||||
@@ -89,7 +89,7 @@ function triggerBrowserDownload(url: string, filename: string) {
|
||||
|
||||
export const TaxReportPage: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { format: fmtDate, dateInputLang } = useLocalizedDate();
|
||||
const { format: fmtDate } = useLocalizedDate();
|
||||
const [preset, setPreset] = useState<PeriodPreset>('thisYear');
|
||||
const initialPeriod = useMemo(() => periodForPreset('thisYear'), []);
|
||||
const [from, setFrom] = useState(initialPeriod.from);
|
||||
@@ -196,27 +196,21 @@ export const TaxReportPage: React.FC = () => {
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="period-from" className="block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('taxReport.filters.from', 'From')}
|
||||
</label>
|
||||
<Input
|
||||
id="period-from"
|
||||
type="date"
|
||||
lang={dateInputLang}
|
||||
<LocalizedDateInput
|
||||
value={from}
|
||||
onChange={(e) => { setFrom(e.target.value); setPreset('custom'); }}
|
||||
onChange={(iso) => { setFrom(iso); setPreset('custom'); }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="period-to" className="block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('taxReport.filters.to', 'To')}
|
||||
</label>
|
||||
<Input
|
||||
id="period-to"
|
||||
type="date"
|
||||
lang={dateInputLang}
|
||||
<LocalizedDateInput
|
||||
value={to}
|
||||
onChange={(e) => { setTo(e.target.value); setPreset('custom'); }}
|
||||
onChange={(iso) => { setTo(iso); setPreset('custom'); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -197,6 +197,25 @@ export const ContractDetailPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-send preview: renders a fresh PDF from the current draft without
|
||||
// writing/sending anything, so the admin can sanity-check layout +
|
||||
// signature blocks before committing to send (no audit trail created).
|
||||
async function handlePdfPreview() {
|
||||
if (!numericId) return;
|
||||
const previewWindow = window.open('about:blank', '_blank');
|
||||
if (!previewWindow) {
|
||||
toast.error(t('contracts.detail.popupBlocked', 'Allow pop-ups for this site to preview the PDF.') as string);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const url = await contractsService.previewPdfUrl(numericId);
|
||||
previewWindow.location.href = url;
|
||||
} catch (err: any) {
|
||||
previewWindow.close();
|
||||
toast.error(err?.response?.data?.error || 'Preview failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignedPdfDownload() {
|
||||
if (!numericId) return;
|
||||
const previewWindow = window.open('about:blank', '_blank');
|
||||
@@ -241,6 +260,10 @@ export const ContractDetailPage: React.FC = () => {
|
||||
<Edit2 className="w-4 h-4 mr-1" />
|
||||
{t('contracts.detail.edit', 'Edit')}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handlePdfPreview}>
|
||||
<FileDown className="w-4 h-4 mr-1" />
|
||||
{t('contracts.detail.previewPdf', 'Preview PDF')}
|
||||
</Button>
|
||||
<Button onClick={() => sendMutation.mutate()} disabled={sendMutation.isPending}>
|
||||
<Send className="w-4 h-4 mr-1" />
|
||||
{t('contracts.detail.send', 'Send to customer')}
|
||||
|
||||
@@ -17,14 +17,13 @@ import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { ArrowLeft, Eye, Save } from 'lucide-react';
|
||||
import { Button, Card, Input, Loading } from '../../../components/common';
|
||||
import { Button, Card, Input, Loading, LocalizedDateInput, TimeField } from '../../../components/common';
|
||||
import {
|
||||
contractsService,
|
||||
type ContractBlockSection,
|
||||
CONTRACT_SECTIONS,
|
||||
} from '../../../services/contracts.service';
|
||||
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
|
||||
interface BlockRow {
|
||||
blockId: number;
|
||||
@@ -38,8 +37,6 @@ interface BlockRow {
|
||||
|
||||
export const ContractEditorPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { timeFormat } = useLocalizedDate();
|
||||
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isEdit = Boolean(id);
|
||||
@@ -380,13 +377,13 @@ export const ContractEditorPage: React.FC = () => {
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t('contracts.editor.issueDate', 'Issue date')}
|
||||
</label>
|
||||
<Input type="date" value={issueDate} onChange={(e) => setIssueDate(e.target.value)} />
|
||||
<LocalizedDateInput value={issueDate} onChange={setIssueDate} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t('contracts.editor.validUntil', 'Sign by (optional)')}
|
||||
</label>
|
||||
<Input type="date" value={validUntil} onChange={(e) => setValidUntil(e.target.value)} />
|
||||
<LocalizedDateInput value={validUntil} onChange={setValidUntil} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -419,20 +416,20 @@ export const ContractEditorPage: React.FC = () => {
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t('contracts.editor.eventDate', 'Event date')}
|
||||
</label>
|
||||
<Input type="date" value={eventDate} onChange={(e) => setEventDate(e.target.value)} />
|
||||
<LocalizedDateInput value={eventDate} onChange={setEventDate} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t('contracts.editor.eventTimeStart', 'Start')}
|
||||
</label>
|
||||
<Input type="time" lang={timeInputLang} value={eventTimeStart} onChange={(e) => setEventTimeStart(e.target.value)} />
|
||||
<TimeField value={eventTimeStart} onChange={setEventTimeStart} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t('contracts.editor.eventTimeEnd', 'End')}
|
||||
</label>
|
||||
<Input type="time" lang={timeInputLang} value={eventTimeEnd} onChange={(e) => setEventTimeEnd(e.target.value)} />
|
||||
<TimeField value={eventTimeEnd} onChange={setEventTimeEnd} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Plus, Search, BookOpen } from 'lucide-react';
|
||||
import { Button, Card, Loading } from '../../../components/common';
|
||||
import { Button, Card, Loading, SortableHeader, useColumnSort, type SortColumnMap } from '../../../components/common';
|
||||
import {
|
||||
contractsService,
|
||||
type ContractStatus,
|
||||
@@ -27,15 +27,25 @@ const STATUSES: ContractStatus[] = [
|
||||
'draft', 'sent', 'signed_by_customer', 'signed_by_admin', 'fully_signed', 'cancelled',
|
||||
];
|
||||
|
||||
// "Number" sorts by creation order (newest/oldest); "Issued" sorts by
|
||||
// the admin-controlled issue_date, which can drift from chronology.
|
||||
const SORT_COLUMNS: SortColumnMap = {
|
||||
number: { asc: 'oldest', desc: 'newest', defaultDir: 'desc' },
|
||||
customer: { asc: 'customer_asc', desc: 'customer_desc' },
|
||||
issue: { asc: 'issue_asc', desc: 'issue_desc', defaultDir: 'desc' },
|
||||
};
|
||||
|
||||
export const ContractsListPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { format } = useLocalizedDate();
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<ContractStatus[]>([]);
|
||||
const [sort, setSort] = useState<ContractSort>('newest');
|
||||
const { sort, activeKey, activeDir, toggle } = useColumnSort<ContractSort>(SORT_COLUMNS, 'issue_desc');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const onSort = (key: string) => { toggle(key); setPage(1); };
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['contracts', { search, statusFilter, sort, page }],
|
||||
queryFn: () => contractsService.list({
|
||||
@@ -98,15 +108,6 @@ export const ContractsListPage: React.FC = () => {
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as ContractSort)}
|
||||
>
|
||||
<option value="newest">{t('contracts.list.sort.newest', 'Newest first')}</option>
|
||||
<option value="oldest">{t('contracts.list.sort.oldest', 'Oldest first')}</option>
|
||||
<option value="customer_asc">{t('contracts.list.sort.customer', 'Customer A→Z')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
@@ -135,10 +136,10 @@ export const ContractsListPage: React.FC = () => {
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">{t('contracts.list.table.number', 'Number')}</th>
|
||||
<th className="px-3 py-2 text-left">{t('contracts.list.table.customer', 'Customer')}</th>
|
||||
<SortableHeader label={t('contracts.list.table.number', 'Number')} columnKey="number" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
|
||||
<SortableHeader label={t('contracts.list.table.customer', 'Customer')} columnKey="customer" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
|
||||
<th className="px-3 py-2 text-left">{t('contracts.list.table.title', 'Title')}</th>
|
||||
<th className="px-3 py-2 text-left">{t('contracts.list.table.issueDate', 'Issued')}</th>
|
||||
<SortableHeader label={t('contracts.list.table.issueDate', 'Issued')} columnKey="issue" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
|
||||
<th className="px-3 py-2 text-left">{t('contracts.list.table.status', 'Status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -8,6 +8,7 @@ export { ArchivesPage } from './ArchivesPage';
|
||||
export { AnalyticsPage } from './AnalyticsPage';
|
||||
export { BrandingPage } from './BrandingPage';
|
||||
export { SettingsPage } from './SettingsPage';
|
||||
export { SystemHealthPage } from './SystemHealthPage';
|
||||
export { CMSPage } from './CMSPage';
|
||||
export { BackupManagement } from './BackupManagement';
|
||||
export { EventFeedbackPage } from './EventFeedbackPage';
|
||||
|
||||
@@ -7,7 +7,7 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Eye, Send, Copy, ArrowRightCircle, Edit2, Receipt, CheckCircle2, ScrollText } 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 { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
|
||||
import { quotesService } from '../../../services/quotes.service';
|
||||
@@ -125,6 +125,26 @@ export const QuoteDetailPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Admin decline-on-behalf. Used when the customer says no by phone/
|
||||
* email — admin flips the quote to declined and (optionally) records
|
||||
* why. The quote can still be duplicated to start a fresh round.
|
||||
*/
|
||||
const handleDeclineOnBehalf = async () => {
|
||||
const reason = window.prompt(t('quotes.declineReasonPrompt',
|
||||
'Mark this quote as declined on behalf of the customer? Optionally note why (leave blank to skip).'));
|
||||
// prompt returns null on Cancel; '' (empty) means "decline, no reason".
|
||||
if (reason === null) return;
|
||||
try {
|
||||
await quotesService.declineOnBehalf(q.id, reason.trim() || undefined);
|
||||
toast.success(t('quotes.declinedOnBehalfToast', 'Quote marked as declined.'));
|
||||
qc.invalidateQueries({ queryKey: ['quote', id] });
|
||||
qc.invalidateQueries({ queryKey: ['quotes'] });
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || 'Decline failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
try {
|
||||
const result = await quotesService.duplicate(q.id);
|
||||
@@ -168,6 +188,15 @@ export const QuoteDetailPage: React.FC = () => {
|
||||
{t('quotes.acceptOnBehalf', 'Accept on behalf')}
|
||||
</Button>
|
||||
)}
|
||||
{/* Decline-on-behalf — same states as accept-on-behalf. Flips
|
||||
the quote to declined for "customer said no by phone"
|
||||
cases; hidden once accepted / declined / converted. */}
|
||||
{['draft', 'sent', 'expired'].includes(q.status) && (
|
||||
<Button variant="outline" onClick={handleDeclineOnBehalf}>
|
||||
<XCircle className="w-4 h-4 mr-1" />
|
||||
{t('quotes.declineOnBehalf', 'Decline on behalf')}
|
||||
</Button>
|
||||
)}
|
||||
{q.status === 'accepted' && (
|
||||
<>
|
||||
<Button onClick={handleConvert}>
|
||||
@@ -207,6 +236,7 @@ export const QuoteDetailPage: React.FC = () => {
|
||||
{q.sentAt && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.sentAt', 'Sent at')}</div><div>{fmtDateTime(q.sentAt)}</div></div>}
|
||||
{q.acceptedAt && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.acceptedAt', 'Accepted at')}</div><div>{fmtDateTime(q.acceptedAt)}</div></div>}
|
||||
{q.declinedAt && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.declinedAt', 'Declined at')}</div><div>{fmtDateTime(q.declinedAt)}</div></div>}
|
||||
{q.declineReason && <div className="col-span-2 md:col-span-4"><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.declineReason', 'Decline reason')}</div><div className="whitespace-pre-line">{q.declineReason}</div></div>}
|
||||
{q.respondedAt && !responseLocked && (
|
||||
<div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.responseWindow', 'Response window')}</div>
|
||||
<div className="text-amber-700">{t('quotes.responseWindowOpen', 'Open until {{at}}', { at: q.responseLockedAt ? fmtDateTime(q.responseLockedAt) : '' })}</div></div>
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
*/
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Eye, Send } from 'lucide-react';
|
||||
import { Button, Card, Loading, Input } from '../../../components/common';
|
||||
import { Button, Card, Loading, Input, LocalizedDateInput, TimeField } from '../../../components/common';
|
||||
import {
|
||||
quotesService,
|
||||
type QuoteCreatePayload,
|
||||
@@ -29,7 +29,6 @@ import { customerAdminService } from '../../../services/customerAdmin.service';
|
||||
import { userManagementService } from '../../../services/userManagement.service';
|
||||
import { settingsService } from '../../../services/settings.service';
|
||||
import { useAdminAuth } from '../../../contexts/AdminAuthContext';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface FormState {
|
||||
@@ -140,8 +139,6 @@ function buildPayload(f: FormState): QuoteCreatePayload {
|
||||
|
||||
export const QuoteEditorPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { timeFormat } = useLocalizedDate();
|
||||
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
|
||||
const { id } = useParams<{ id?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -490,17 +487,17 @@ export const QuoteEditorPage: React.FC = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Input label={t('quotes.field.eventName', 'Event name') as string} value={form.eventName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, eventName: e.target.value }))} />
|
||||
<Input type="date" label={t('quotes.field.eventDate', 'Event date') as string} value={form.eventDate}
|
||||
onChange={(e) => setForm((f) => ({ ...f, eventDate: e.target.value }))} />
|
||||
<Input type="time" lang={timeInputLang} label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart}
|
||||
onChange={(e) => setForm((f) => ({ ...f, eventTimeStart: e.target.value }))} />
|
||||
<Input type="time" lang={timeInputLang} label={t('quotes.field.eventTimeEnd', 'End time') as string} value={form.eventTimeEnd}
|
||||
onChange={(e) => setForm((f) => ({ ...f, eventTimeEnd: e.target.value }))} />
|
||||
<LocalizedDateInput label={t('quotes.field.eventDate', 'Event date') as string} value={form.eventDate}
|
||||
onChange={(iso) => setForm((f) => ({ ...f, eventDate: iso }))} />
|
||||
<TimeField label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart}
|
||||
onChange={(v) => setForm((f) => ({ ...f, eventTimeStart: v }))} />
|
||||
<TimeField label={t('quotes.field.eventTimeEnd', 'End time') as string} value={form.eventTimeEnd}
|
||||
onChange={(v) => setForm((f) => ({ ...f, eventTimeEnd: v }))} />
|
||||
<Input type="number" step="0.5" label={t('quotes.field.expectedDuration', 'Expected duration (h)') as string}
|
||||
value={form.expectedDurationHours}
|
||||
onChange={(e) => setForm((f) => ({ ...f, expectedDurationHours: e.target.value }))} />
|
||||
<Input type="date" label={t('quotes.field.validUntil', 'Valid until') as string} value={form.validUntil}
|
||||
onChange={(e) => setForm((f) => ({ ...f, validUntil: e.target.value }))} />
|
||||
<LocalizedDateInput label={t('quotes.field.validUntil', 'Valid until') as string} value={form.validUntil}
|
||||
onChange={(iso) => setForm((f) => ({ ...f, validUntil: iso }))} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -539,6 +536,10 @@ export const QuoteEditorPage: React.FC = () => {
|
||||
below now reads from the timing template. */}
|
||||
<Card>
|
||||
<h3 className="font-semibold mb-2">4. {t('quotes.section.payment', 'Payment conditions')}</h3>
|
||||
<Link to="/admin/settings?tab=crm"
|
||||
className="text-xs text-accent hover:underline mb-2 inline-block">
|
||||
{t('common.configureInSettings', 'Configure defaults in Settings ↗')}
|
||||
</Link>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('quotes.field.paymentNetDays', 'Net days')}</label>
|
||||
|
||||
@@ -8,21 +8,32 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { quotesService, type QuoteStatus, type QuoteSort } from '../../../services/quotes.service';
|
||||
import { Button, Card, Loading } from '../../../components/common';
|
||||
import { Button, Card, Loading, SortableHeader, useColumnSort, type SortColumnMap } from '../../../components/common';
|
||||
import { formatMoney } from '../../../components/admin/LineItemsTable';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
|
||||
const STATUSES: QuoteStatus[] = ['draft', 'sent', 'accepted', 'declined', 'expired', 'converted'];
|
||||
|
||||
// "#" sorts by creation order (newest/oldest); "Issued" sorts by the
|
||||
// admin-controlled issue_date, which can drift from chronology.
|
||||
const SORT_COLUMNS: SortColumnMap = {
|
||||
number: { asc: 'oldest', desc: 'newest', defaultDir: 'desc' },
|
||||
customer: { asc: 'customer_asc', desc: 'customer_desc' },
|
||||
issue: { asc: 'issue_asc', desc: 'issue_desc', defaultDir: 'desc' },
|
||||
value: { asc: 'value_asc', desc: 'value_desc', defaultDir: 'desc' },
|
||||
};
|
||||
|
||||
export const QuotesListPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { format: fmtDate } = useLocalizedDate();
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<QuoteStatus[]>([]);
|
||||
const [sort, setSort] = useState<QuoteSort>('newest');
|
||||
const { sort, activeKey, activeDir, toggle } = useColumnSort<QuoteSort>(SORT_COLUMNS, 'issue_desc');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const onSort = (key: string) => { toggle(key); setPage(1); };
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['quotes', { search, statusFilter, sort, page }],
|
||||
queryFn: () => quotesService.list({
|
||||
@@ -73,17 +84,6 @@ export const QuotesListPage: React.FC = () => {
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
className="px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as QuoteSort)}
|
||||
>
|
||||
<option value="newest">{t('quotes.sort.newest', 'Newest first')}</option>
|
||||
<option value="oldest">{t('quotes.sort.oldest', 'Oldest first')}</option>
|
||||
<option value="customer_asc">{t('quotes.sort.customerAsc', 'Customer A→Z')}</option>
|
||||
<option value="value_asc">{t('quotes.sort.valueAsc', 'Value low→high')}</option>
|
||||
<option value="value_desc">{t('quotes.sort.valueDesc', 'Value high→low')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{STATUSES.map((s) => {
|
||||
@@ -111,11 +111,11 @@ export const QuotesListPage: React.FC = () => {
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">#</th>
|
||||
<th className="px-3 py-2 text-left">{t('quotes.table.customer', 'Customer')}</th>
|
||||
<SortableHeader label="#" columnKey="number" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
|
||||
<SortableHeader label={t('quotes.table.customer', 'Customer')} columnKey="customer" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
|
||||
<th className="px-3 py-2 text-left">{t('quotes.table.event', 'Event')}</th>
|
||||
<th className="px-3 py-2 text-left">{t('quotes.table.issueDate', 'Issued')}</th>
|
||||
<th className="px-3 py-2 text-right">{t('quotes.table.total', 'Total')}</th>
|
||||
<SortableHeader label={t('quotes.table.issueDate', 'Issued')} columnKey="issue" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
|
||||
<SortableHeader label={t('quotes.table.total', 'Total')} columnKey="value" activeKey={activeKey} activeDir={activeDir} onSort={onSort} align="right" />
|
||||
<th className="px-3 py-2 text-left">{t('quotes.table.status', 'Status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -8,14 +8,17 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, Star, Pencil, Save } from 'lucide-react';
|
||||
import { Plus, Trash2, Star, Pencil, Save, Clock, Copy } from 'lucide-react';
|
||||
import {
|
||||
businessProfileService,
|
||||
type BusinessProfile,
|
||||
type BankAccount,
|
||||
type BusinessHours,
|
||||
type BusinessHoursBlock,
|
||||
type QrFormat,
|
||||
} from '../../../services/businessProfile.service';
|
||||
import { Button, Card, Loading, Input } from '../../../components/common';
|
||||
import { Button, Card, Loading, Input, CountrySelect, TimeField } from '../../../components/common';
|
||||
import { DecimalInput } from '../../../components/common/DecimalInput';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
@@ -82,18 +85,14 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
onChange={(e) => setProfile({ ...profile, city: e.target.value })} />
|
||||
<Input label={t('businessProfile.field.state', 'State / Region') as string} value={profile.state}
|
||||
onChange={(e) => setProfile({ ...profile, state: e.target.value })} />
|
||||
<Input label={t('businessProfile.field.countryCode', 'Country abbreviation (FL, CH, DE …)') as string}
|
||||
value={profile.countryCode}
|
||||
maxLength={2}
|
||||
placeholder="FL"
|
||||
onChange={(e) => setProfile({ ...profile, countryCode: e.target.value.toUpperCase() })} />
|
||||
{/* Free-text country name override (migration 107). When
|
||||
left empty the renderer falls back to the COUNTRY_NAMES
|
||||
lookup on the abbreviation. */}
|
||||
<Input label={t('businessProfile.field.countryName', 'Country (full name)') as string}
|
||||
value={profile.countryName || ''}
|
||||
placeholder="Liechtenstein"
|
||||
onChange={(e) => setProfile({ ...profile, countryName: e.target.value })} />
|
||||
<CountrySelect label={t('businessProfile.field.countryCode', 'Country') as string}
|
||||
value={profile.countryCode || ''}
|
||||
onChange={(code) => setProfile({ ...profile, countryCode: code })} />
|
||||
{/* The free-text "Country (full name)" override (migration 107) was
|
||||
removed as redundant — the picker stores the ISO code and the PDF
|
||||
renderer derives the localized full name from it
|
||||
(pdfService.countryName). The DB column + `country_name ||`
|
||||
fallback remain, so any legacy override still renders. */}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -134,6 +133,30 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
<Input type="number" step="0.01" label={t('businessProfile.field.vatRateDefault', 'Default VAT rate %') as string}
|
||||
value={profile.vatRateDefault ?? 0}
|
||||
onChange={(e) => setProfile({ ...profile, vatRateDefault: Number(e.target.value) })} />
|
||||
{/* Install-wide fallback hourly rate (migration 113). Stored in
|
||||
minor units; entered here in major units. Blank = no global
|
||||
default, so hours-logging then needs a per-customer or
|
||||
per-entry rate. Comma-tolerant via DecimalInput. */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{t('businessProfile.field.defaultHourlyRate', 'Default hourly rate')}
|
||||
</label>
|
||||
<DecimalInput
|
||||
value={profile.defaultHourlyRateMinor != null ? profile.defaultHourlyRateMinor / 100 : NaN}
|
||||
fractionDigits={2}
|
||||
onChange={(n) => setProfile({
|
||||
...profile,
|
||||
defaultHourlyRateMinor: Number.isFinite(n) ? Math.max(0, Math.round(n * 100)) : null,
|
||||
})}
|
||||
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
|
||||
placeholder={t('businessProfile.field.defaultHourlyRatePlaceholder', 'e.g. 120.00') as string}
|
||||
/>
|
||||
<p className="text-xs text-muted-theme mt-1">
|
||||
{t('businessProfile.field.defaultHourlyRateHint',
|
||||
'Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.',
|
||||
{ currency: profile.defaultCurrency || 'CHF' })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('businessProfile.field.defaultQrFormat', 'Default invoice QR')}</label>
|
||||
<select value={profile.defaultQrFormat} onChange={(e) => setProfile({ ...profile, defaultQrFormat: e.target.value as QrFormat })}
|
||||
@@ -228,6 +251,37 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Business hours (migration 114). Per-weekday opening blocks with
|
||||
lunch-break support, interpreted in the profile timezone above.
|
||||
Drives the scheduled-email floor: an email scheduled outside the
|
||||
open blocks is held until the next opening. */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Clock className="w-5 h-5 text-neutral-500" />
|
||||
<h3 className="font-semibold">{t('businessProfile.businessHours.title', 'Business hours')}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('businessProfile.businessHours.subtitle',
|
||||
'Set opening hours per weekday — add a second block for a lunch break. Interpreted in the timezone above ({{tz}}).',
|
||||
{ tz: profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone })}
|
||||
</p>
|
||||
|
||||
<BusinessHoursEditor
|
||||
value={profile.businessHours}
|
||||
onChange={(next) => setProfile({ ...profile, businessHours: next })}
|
||||
/>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<PdfToggleRow
|
||||
label={t('businessProfile.businessHours.floorToggle', 'Hold scheduled emails until business hours') as string}
|
||||
description={t('businessProfile.businessHours.floorToggleHelp',
|
||||
'When on, an automated email scheduled outside the hours above is delivered at the next opening instead of at an odd hour. When off, scheduled emails send at their exact time.') as string}
|
||||
enabled={profile.scheduledEmailFloorEnabled}
|
||||
onChange={(v) => setProfile({ ...profile, scheduledEmailFloorEnabled: v })}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Disclaimer banner for QR-bill / IBAN data. picpeak renders
|
||||
what the operator types — it cannot validate IBAN/BIC, QR-IID
|
||||
or scan-compatibility with any specific bank's e-banking app.
|
||||
@@ -284,6 +338,143 @@ const PdfToggleRow: React.FC<PdfToggleRowProps> = ({ label, description, enabled
|
||||
</label>
|
||||
);
|
||||
|
||||
/**
|
||||
* Per-weekday business-hours editor (migration 114). Google-style: each
|
||||
* weekday holds zero or more {start,end} blocks, so a day can be closed
|
||||
* (no blocks), open all day (one block), or have a lunch break (two).
|
||||
* Edits the parent's `businessHours` object directly; the page-level Save
|
||||
* persists it. ISO weekday keys "1".."7" (1=Mon … 7=Sun).
|
||||
*/
|
||||
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7];
|
||||
|
||||
const BusinessHoursEditor: React.FC<{
|
||||
value: BusinessHours | null;
|
||||
onChange: (next: BusinessHours) => void;
|
||||
}> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Always work with a fully-populated 7-day object so toggling a day on
|
||||
// and off doesn't drop sibling keys.
|
||||
const full: BusinessHours = {};
|
||||
for (const iso of WEEKDAYS) {
|
||||
const blocks = value?.[String(iso)];
|
||||
full[String(iso)] = Array.isArray(blocks) ? blocks : [];
|
||||
}
|
||||
|
||||
const setDay = (iso: number, blocks: BusinessHoursBlock[]) => {
|
||||
onChange({ ...full, [String(iso)]: blocks });
|
||||
};
|
||||
|
||||
const addBlock = (iso: number) => {
|
||||
const blocks = full[String(iso)];
|
||||
// First block defaults to a full workday; a second one defaults to a
|
||||
// post-lunch afternoon so the common 09–12 / 13–18 split is one click.
|
||||
const next: BusinessHoursBlock = blocks.length === 0
|
||||
? { start: '09:00', end: '17:00' }
|
||||
: { start: '13:00', end: '18:00' };
|
||||
setDay(iso, [...blocks, next]);
|
||||
};
|
||||
|
||||
const updateBlock = (iso: number, idx: number, patch: Partial<BusinessHoursBlock>) => {
|
||||
setDay(iso, full[String(iso)].map((b, i) => (i === idx ? { ...b, ...patch } : b)));
|
||||
};
|
||||
|
||||
const removeBlock = (iso: number, idx: number) => {
|
||||
setDay(iso, full[String(iso)].filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const copyToAll = (iso: number) => {
|
||||
const src = full[String(iso)];
|
||||
const next: BusinessHours = {};
|
||||
for (const d of WEEKDAYS) next[String(d)] = src.map((b) => ({ ...b }));
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{WEEKDAYS.map((iso) => {
|
||||
const blocks = full[String(iso)];
|
||||
const isOpen = blocks.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={iso}
|
||||
className="flex flex-col sm:flex-row sm:items-start gap-2 sm:gap-3 py-2 border-b border-neutral-100 dark:border-neutral-800 last:border-0"
|
||||
>
|
||||
<div className="w-28 shrink-0 pt-2 text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{t(`businessProfile.businessHours.weekday.${iso}`)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2">
|
||||
{!isOpen && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('businessProfile.businessHours.closed', 'Closed')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addBlock(iso)}
|
||||
className="inline-flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('businessProfile.businessHours.addHours', 'Add hours')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{blocks.map((block, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2">
|
||||
<TimeField
|
||||
value={block.start}
|
||||
onChange={(v) => updateBlock(iso, idx, { start: v })}
|
||||
ariaLabel={t('businessProfile.businessHours.startTime', 'Opening time') as string}
|
||||
className="w-32 shrink-0"
|
||||
/>
|
||||
<span className="text-neutral-400">–</span>
|
||||
<TimeField
|
||||
value={block.end}
|
||||
onChange={(v) => updateBlock(iso, idx, { end: v })}
|
||||
ariaLabel={t('businessProfile.businessHours.endTime', 'Closing time') as string}
|
||||
className="w-32 shrink-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeBlock(iso, idx)}
|
||||
aria-label={t('common.remove', 'Remove') as string}
|
||||
className="p-1.5 text-neutral-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
{idx === blocks.length - 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addBlock(iso)}
|
||||
aria-label={t('businessProfile.businessHours.addBlock', 'Add another block') as string}
|
||||
className="p-1.5 text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyToAll(iso)}
|
||||
className="shrink-0 inline-flex items-center gap-1 pt-2 text-xs text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
{t('businessProfile.businessHours.copyToAll', 'Copy to all days')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dedicated PDF letterhead logo uploader. Accepts PNG / JPEG / SVG;
|
||||
* the backend rasterises SVG to PNG via sharp so vector uploads work
|
||||
|
||||
@@ -21,13 +21,14 @@ import { Lock, MapPin, Phone, User as UserIcon, AlertCircle, CheckCircle } from
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { Button, Input, Card, Loading, CountrySelect } from '../../components/common';
|
||||
import {
|
||||
customerService,
|
||||
type CustomerInvitationInfo,
|
||||
type CustomerProfilePrefill,
|
||||
} from '../../services/customer.service';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { usePublicDarkMode } from '../../hooks/usePublicDarkMode';
|
||||
|
||||
interface FormState {
|
||||
display_name: string;
|
||||
@@ -76,8 +77,14 @@ export const CustomerAcceptInvitePage: React.FC = () => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { data: settingsData } = usePublicSettings();
|
||||
// Theme-aware logo: the page renders on the themed customer surface
|
||||
// (dark when branding_force_color_mode is dark / OS dark), so pick the
|
||||
// dark logo variant accordingly. No frame here — logo sits on the page bg.
|
||||
const { isDark } = usePublicDarkMode();
|
||||
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
|
||||
const logoUrl = settingsData?.branding_logo_url?.trim();
|
||||
const lightLogo = settingsData?.branding_logo_url?.trim();
|
||||
const darkLogo = settingsData?.branding_logo_url_dark?.trim();
|
||||
const logoUrl = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
|
||||
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
|
||||
|
||||
// Pre-flight invitation lookup. The response carries any prefill data
|
||||
@@ -386,7 +393,7 @@ export const CustomerAcceptInvitePage: React.FC = () => {
|
||||
onChange={(e) => update('postal_code', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<div className="sm:col-span-4">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-city">
|
||||
{t('customer.profile.field.city', 'City')}
|
||||
</label>
|
||||
@@ -398,20 +405,6 @@ export const CustomerAcceptInvitePage: React.FC = () => {
|
||||
onChange={(e) => update('city', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-1">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-country">
|
||||
{t('customer.profile.field.countryCode', 'Country')}
|
||||
</label>
|
||||
<Input
|
||||
id="invite-country"
|
||||
name="country"
|
||||
autoComplete="billing country"
|
||||
placeholder="DE"
|
||||
maxLength={2}
|
||||
value={form.country_code}
|
||||
onChange={(e) => update('country_code', e.target.value.toUpperCase().slice(0, 2))}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-state">
|
||||
{t('customer.profile.field.state', 'State / region')}
|
||||
@@ -424,6 +417,16 @@ export const CustomerAcceptInvitePage: React.FC = () => {
|
||||
onChange={(e) => update('state', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-3">
|
||||
{/* Country picker (ISO code) — dropdown, mirroring the
|
||||
admin customer / business-profile forms, placed after
|
||||
State / region. Replaces the old free-text 2-char input. */}
|
||||
<CountrySelect
|
||||
label={t('customer.profile.field.countryCode', 'Country') as string}
|
||||
value={form.country_code}
|
||||
onChange={(code) => update('country_code', code)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -65,7 +65,16 @@ export const CustomerLayout: React.FC = () => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
|
||||
const logoUrl = settingsData?.branding_logo_url?.trim();
|
||||
// Theme-aware logo: the customer surface follows branding_force_color_mode
|
||||
// ('auto' → OS preference). 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'
|
||||
&& window.matchMedia?.('(prefers-color-scheme: dark)').matches);
|
||||
const lightLogo = settingsData?.branding_logo_url?.trim();
|
||||
const darkLogo = settingsData?.branding_logo_url_dark?.trim();
|
||||
const logoUrl = customerIsDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
|
||||
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
|
||||
|
||||
// Filter out feature-gated entries the customer can't see. Galleries +
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Button, Input, Card, ReCaptcha } from '../../components/common';
|
||||
import { useCustomerAuth } from '../../contexts/CustomerAuthContext';
|
||||
import { customerService } from '../../services/customer.service';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { usePublicDarkMode } from '../../hooks/usePublicDarkMode';
|
||||
import { resolveLoginLogoClasses } from '../../utils/loginLogoSize';
|
||||
|
||||
export const CustomerLoginPage: React.FC = () => {
|
||||
@@ -28,8 +29,17 @@ export const CustomerLoginPage: React.FC = () => {
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const { data: settingsData } = usePublicSettings();
|
||||
// Customer surface follows branding_force_color_mode (+ OS fallback); isDark
|
||||
// drives the theme-aware logo pick. A framed logo sits on a fixed cream
|
||||
// plate (see render), so the light (dark-ink) logo always reads there;
|
||||
// only the frameless logo sits on the themed (possibly dark) page bg.
|
||||
const { isDark } = usePublicDarkMode();
|
||||
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
|
||||
const logoUrl = settingsData?.branding_logo_url?.trim();
|
||||
const lightLogo = settingsData?.branding_logo_url?.trim();
|
||||
const darkLogo = settingsData?.branding_logo_url_dark?.trim();
|
||||
const loginFrameEnabled = settingsData?.branding_login_logo_frame_enabled !== false;
|
||||
const themedLogo = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
|
||||
const logoUrl = loginFrameEnabled ? (lightLogo || darkLogo) : themedLogo;
|
||||
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
|
||||
|
||||
// After /accept-invite the user is redirected here with ?accepted=1
|
||||
|
||||
@@ -28,7 +28,7 @@ import { toast } from 'react-toastify';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Lock, Save, User as UserIcon, MapPin, Phone, Mail } from 'lucide-react';
|
||||
|
||||
import { Button, Input, Loading } from '../../components/common';
|
||||
import { Button, Input, Loading, CountrySelect } from '../../components/common';
|
||||
|
||||
/**
|
||||
* Inline tile wrapper used in place of <Card> on this page.
|
||||
@@ -415,7 +415,7 @@ export const CustomerProfilePage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<div className="sm:col-span-4">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-city">
|
||||
{t('customer.profile.field.city', 'City')}
|
||||
</label>
|
||||
@@ -428,21 +428,6 @@ export const CustomerProfilePage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-1">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-country">
|
||||
{t('customer.profile.field.countryCode', 'Country')}
|
||||
</label>
|
||||
<Input
|
||||
id="profile-country"
|
||||
name="country"
|
||||
autoComplete="billing country"
|
||||
placeholder="DE"
|
||||
maxLength={2}
|
||||
value={form.countryCode || ''}
|
||||
onChange={(e) => updateField('countryCode', e.target.value.toUpperCase().slice(0, 2))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-state">
|
||||
{t('customer.profile.field.state', 'State / region')}
|
||||
@@ -455,6 +440,17 @@ export const CustomerProfilePage: React.FC = () => {
|
||||
onChange={(e) => updateField('state', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3">
|
||||
{/* Country picker (ISO code) — dropdown, mirroring the admin
|
||||
customer / business-profile forms, placed after State /
|
||||
region. Replaces the old free-text 2-char input. */}
|
||||
<CountrySelect
|
||||
label={t('customer.profile.field.countryCode', 'Country') as string}
|
||||
value={form.countryCode || ''}
|
||||
onChange={(code) => updateField('countryCode', code)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ProfileTile>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { customerService } from '../../services/customer.service';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { usePublicDarkMode } from '../../hooks/usePublicDarkMode';
|
||||
|
||||
export const CustomerResetPasswordPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -33,8 +34,14 @@ export const CustomerResetPasswordPage: React.FC = () => {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { data: settingsData } = usePublicSettings();
|
||||
// Theme-aware logo: page renders on the themed customer surface (dark when
|
||||
// branding_force_color_mode is dark / OS dark). No frame — logo sits on the
|
||||
// page bg, so pick the dark variant when dark.
|
||||
const { isDark } = usePublicDarkMode();
|
||||
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
|
||||
const logoUrl = settingsData?.branding_logo_url?.trim();
|
||||
const lightLogo = settingsData?.branding_logo_url?.trim();
|
||||
const darkLogo = settingsData?.branding_logo_url_dark?.trim();
|
||||
const logoUrl = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
|
||||
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
|
||||
|
||||
// Pre-flight token validation. Same pattern as the invite page — if the
|
||||
|
||||
@@ -81,8 +81,9 @@ export const ContractResponsePage: React.FC = () => {
|
||||
// Honour branding dark/light mode the same way QuoteResponsePage
|
||||
// does — without this the page renders in light regardless of admin
|
||||
// settings. The wrapper styling below still has `dark:` variants
|
||||
// so the page reads cleanly in either mode.
|
||||
usePublicDarkMode();
|
||||
// so the page reads cleanly in either mode. `isDark` drives the
|
||||
// theme-aware logo pick in the header.
|
||||
const { isDark } = usePublicDarkMode();
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const padRef = useRef<SignaturePad | null>(null);
|
||||
@@ -233,6 +234,18 @@ export const ContractResponsePage: React.FC = () => {
|
||||
<div className="max-w-3xl mx-auto py-8 px-4">
|
||||
{/* Issuer header — same shape as QuoteResponsePage. */}
|
||||
<div className="text-center mb-6">
|
||||
{(() => {
|
||||
const logo = isDark
|
||||
? (c.issuer?.logoUrlDark || c.issuer?.logoUrl)
|
||||
: (c.issuer?.logoUrl || c.issuer?.logoUrlDark);
|
||||
return logo ? (
|
||||
<img
|
||||
src={logo}
|
||||
alt={c.issuer?.companyName || 'Logo'}
|
||||
className="mx-auto mb-3 h-16 object-contain"
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
{c.issuer?.companyName && (
|
||||
<h2 className="text-xl font-bold">{c.issuer.companyName}</h2>
|
||||
)}
|
||||
|
||||
@@ -260,12 +260,16 @@ export const PaymentCheckPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const BrandingHeader: React.FC<{ issuer: PaymentCheckIssuer | null }> = ({ issuer }) => {
|
||||
if (!issuer || (!issuer.logoUrl && !issuer.companyName)) return null;
|
||||
const { isDark } = usePublicDarkMode();
|
||||
if (!issuer || (!issuer.logoUrl && !issuer.logoUrlDark && !issuer.companyName)) return null;
|
||||
const logo = isDark
|
||||
? (issuer.logoUrlDark || issuer.logoUrl)
|
||||
: (issuer.logoUrl || issuer.logoUrlDark);
|
||||
return (
|
||||
<header className="text-center mb-8">
|
||||
{issuer.logoUrl && (
|
||||
{logo && (
|
||||
<img
|
||||
src={issuer.logoUrl}
|
||||
src={logo}
|
||||
alt={issuer.companyName || 'Logo'}
|
||||
className="mx-auto h-16 w-auto object-contain mb-3"
|
||||
/>
|
||||
|
||||
@@ -31,7 +31,7 @@ import { formatShortDate } from '../../utils/dateShort';
|
||||
|
||||
export const QuoteResponsePage: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
const { formatDateTime: fmtDateTime, formatTime: fmtTime } = useLocalizedDate();
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -39,7 +39,8 @@ export const QuoteResponsePage: React.FC = () => {
|
||||
// Apply dark mode per the branding settings (forced dark/light)
|
||||
// or fall back to the OS preference. Without this the public
|
||||
// quote page renders in light mode regardless of admin settings.
|
||||
usePublicDarkMode();
|
||||
// `isDark` drives the theme-aware logo pick below.
|
||||
const { isDark } = usePublicDarkMode();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['public-quote', token],
|
||||
@@ -153,13 +154,18 @@ export const QuoteResponsePage: React.FC = () => {
|
||||
the backend storage directory directly. */}
|
||||
{quote.issuer && (
|
||||
<div className="text-center mb-6">
|
||||
{quote.issuer.logoUrl && (
|
||||
<img
|
||||
src={quote.issuer.logoUrl}
|
||||
alt={quote.issuer.companyName || 'Logo'}
|
||||
className="mx-auto mb-3 h-16 object-contain"
|
||||
/>
|
||||
)}
|
||||
{(() => {
|
||||
const logo = isDark
|
||||
? (quote.issuer.logoUrlDark || quote.issuer.logoUrl)
|
||||
: (quote.issuer.logoUrl || quote.issuer.logoUrlDark);
|
||||
return logo ? (
|
||||
<img
|
||||
src={logo}
|
||||
alt={quote.issuer.companyName || 'Logo'}
|
||||
className="mx-auto mb-3 h-16 object-contain"
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
<h2 className="text-xl font-bold">{quote.issuer.companyName}</h2>
|
||||
{quote.issuer.website && (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">{quote.issuer.website}</p>
|
||||
@@ -309,7 +315,7 @@ export const QuoteResponsePage: React.FC = () => {
|
||||
? Math.max(0, Math.ceil((lockAt.getTime() - Date.now()) / 60000))
|
||||
: 0;
|
||||
return {
|
||||
at: lockAt ? lockAt.toLocaleTimeString() : '',
|
||||
at: lockAt ? fmtTime(lockAt) : '',
|
||||
minutes,
|
||||
};
|
||||
})())
|
||||
|
||||
@@ -19,9 +19,10 @@ export type InvoiceStatus = 'scheduled' | 'sent' | 'paid' | 'overdue' | 'cancell
|
||||
export type InvoiceKind = 'invoice' | 'storno';
|
||||
export type InvoiceSort =
|
||||
| 'newest' | 'oldest'
|
||||
| 'issue_asc' | 'issue_desc'
|
||||
| 'due_asc' | 'due_desc'
|
||||
| 'value_asc' | 'value_desc'
|
||||
| 'customer_asc';
|
||||
| 'customer_asc' | 'customer_desc';
|
||||
|
||||
export type InvoiceQrFormat = 'swiss' | 'epc' | 'none';
|
||||
|
||||
@@ -330,6 +331,8 @@ export const billsService = {
|
||||
async importHistorical(payload: {
|
||||
customerAccountId: number;
|
||||
invoiceNumber: string;
|
||||
eventName?: string;
|
||||
eventDate?: string;
|
||||
issueDate: string;
|
||||
dueDate?: string;
|
||||
totalAmountMinor: number;
|
||||
@@ -343,6 +346,8 @@ export const billsService = {
|
||||
form.append('pdf', payload.file);
|
||||
form.append('customerAccountId', String(payload.customerAccountId));
|
||||
form.append('invoiceNumber', payload.invoiceNumber);
|
||||
if (payload.eventName) form.append('eventName', payload.eventName);
|
||||
if (payload.eventDate) form.append('eventDate', payload.eventDate);
|
||||
form.append('issueDate', payload.issueDate);
|
||||
if (payload.dueDate) form.append('dueDate', payload.dueDate);
|
||||
form.append('totalAmountMinor', String(payload.totalAmountMinor));
|
||||
|
||||
@@ -34,6 +34,11 @@ export interface BusinessProfile {
|
||||
taxId: string;
|
||||
vatLabel: string;
|
||||
vatRateDefault: number | null;
|
||||
/** Install-wide fallback hourly rate in MINOR units (migration 113).
|
||||
* Last link in the hour-entry rate chain after the per-entry
|
||||
* override and the per-customer default. null = no global default;
|
||||
* the hours page then requires a per-customer or per-entry rate. */
|
||||
defaultHourlyRateMinor: number | null;
|
||||
defaultCurrency: string;
|
||||
defaultLocale: string;
|
||||
defaultQrFormat: QrFormat;
|
||||
@@ -79,10 +84,28 @@ export interface BusinessProfile {
|
||||
* via publicSettings. When null/empty, the calendar UI falls back
|
||||
* to the browser's `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
|
||||
timezone: string | null;
|
||||
/** Per-ISO-weekday opening hours (migration 114). Keyed "1".."7"
|
||||
* (1=Mon … 7=Sun); each value is a list of {start,end} "HH:MM" blocks,
|
||||
* so a day can carry a lunch break or differ from its neighbours. A day
|
||||
* with no blocks is closed. null = no hours configured. Interpreted in
|
||||
* `timezone`. Drives the scheduled-email business-hours floor. */
|
||||
businessHours: BusinessHours | null;
|
||||
/** Master switch for the scheduled-email business-hours floor
|
||||
* (migration 114). Defaults true. When off, scheduled emails send at
|
||||
* their requested instant regardless of `businessHours`. */
|
||||
scheduledEmailFloorEnabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface BusinessHoursBlock {
|
||||
start: string; // "HH:MM"
|
||||
end: string; // "HH:MM"
|
||||
}
|
||||
|
||||
/** ISO-weekday-keyed ("1".."7") opening blocks. */
|
||||
export type BusinessHours = Record<string, BusinessHoursBlock[]>;
|
||||
|
||||
export interface BankAccount {
|
||||
id: number;
|
||||
label: string;
|
||||
|
||||
@@ -43,7 +43,10 @@ export type ContractStatus =
|
||||
| 'fully_signed'
|
||||
| 'cancelled';
|
||||
|
||||
export type ContractSort = 'newest' | 'oldest' | 'customer_asc';
|
||||
export type ContractSort =
|
||||
| 'newest' | 'oldest'
|
||||
| 'issue_asc' | 'issue_desc'
|
||||
| 'customer_asc' | 'customer_desc';
|
||||
|
||||
/** Canonical section enum kept in sync with backend SECTIONS_ORDER
|
||||
* and contractBlocksService.ALLOWED_SECTIONS. Renaming any value
|
||||
@@ -426,6 +429,9 @@ export interface PublicContractView {
|
||||
city: string | null;
|
||||
email: string | null;
|
||||
website: string | null;
|
||||
/** Light + dark branding logo URLs; the page picks per its colour mode. */
|
||||
logoUrl?: string | null;
|
||||
logoUrlDark?: string | null;
|
||||
} | null;
|
||||
/** Admin-set behaviour flags surfaced for the public sign page.
|
||||
* Server re-enforces both — these only drive the UI. */
|
||||
|
||||
@@ -58,8 +58,12 @@ export interface CustomerAccountDetail extends CustomerAccountSummary {
|
||||
* - 'monthly' / 'quarterly': snap every scheduled invoice to
|
||||
* `billingCycleDay` of the next period.
|
||||
*/
|
||||
billingCadence?: 'per_event' | 'monthly' | 'quarterly';
|
||||
billingCadence?: 'per_event' | 'monthly' | 'quarterly' | 'manual';
|
||||
billingCycleDay?: number;
|
||||
/** Per-customer Skonto opt-out (migration 112). When true, none of
|
||||
* this customer's invoices qualify for an early-payment discount,
|
||||
* regardless of template / global defaults. */
|
||||
skontoDisabled?: boolean;
|
||||
notes: string | null;
|
||||
events: Array<{
|
||||
id: number;
|
||||
@@ -158,6 +162,8 @@ export const customerAdminService = {
|
||||
// CRM billing cadence (migration 102 + 128).
|
||||
billingCadence: 'billing_cadence',
|
||||
billingCycleDay: 'billing_cycle_day',
|
||||
// Per-customer Skonto opt-out (migration 112).
|
||||
skontoDisabled: 'skonto_disabled',
|
||||
};
|
||||
for (const [k, v] of Object.entries(payload)) {
|
||||
if (k in map) snake[map[k]] = v;
|
||||
@@ -334,6 +340,14 @@ export const customerAdminService = {
|
||||
return (response.data as any).data ?? response.data;
|
||||
},
|
||||
|
||||
/** Landing aggregate for /admin/clients/hours — every customer that
|
||||
* currently carries unbilled hour entries, with open hours + open
|
||||
* amount (install default currency). Sorted by open amount desc. */
|
||||
async getUnbilledHoursSummary(): Promise<UnbilledHoursSummaryRow[]> {
|
||||
const response = await api.get(`/admin/customers/hour-entries/unbilled-summary`);
|
||||
return ((response.data as any).data?.summary ?? (response.data as any).summary) || [];
|
||||
},
|
||||
|
||||
/** Admin override — issue the customer's running monthly draft now,
|
||||
* bypassing the cadence-day wait. 409 when no draft exists or the
|
||||
* draft is empty. Returns the issued invoice id + number. */
|
||||
@@ -354,16 +368,18 @@ export const customerAdminService = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Open monthly bill accumulator preview (migration 128). One row in
|
||||
/** Open bill accumulator preview (migration 128). One row in
|
||||
* the invoices table with is_monthly_draft=true that gathers every
|
||||
* invoice line created for this customer during the current period;
|
||||
* ships on the cadence day or via triggerMonthlyBill. */
|
||||
* ships on the cadence day or via triggerMonthlyBill. Manual-cadence
|
||||
* drafts carry no period (periodStart/End null) and ship only on the
|
||||
* admin trigger. */
|
||||
export interface MonthlyDraftPreview {
|
||||
id: number;
|
||||
invoiceNumber: string;
|
||||
currency: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
periodStart: string | null;
|
||||
periodEnd: string | null;
|
||||
netAmountMinor: number;
|
||||
vatRate: number | null;
|
||||
vatAmountMinor: number;
|
||||
@@ -411,6 +427,24 @@ export interface HourEntry {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UnbilledHoursSummaryRow {
|
||||
customerAccountId: number;
|
||||
companyName: string | null;
|
||||
displayName: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email: string | null;
|
||||
isPassive: boolean;
|
||||
billingCadence: string | null;
|
||||
entryCount: number;
|
||||
totalMinutes: number;
|
||||
openAmountMinor: number;
|
||||
/** false when at least one entry has no resolvable rate (no override,
|
||||
* no customer rate, no install default) — its amount is excluded from
|
||||
* openAmountMinor and the UI prompts to set a rate. */
|
||||
rateResolvable: boolean;
|
||||
}
|
||||
|
||||
export interface HourEntryCreatePayload {
|
||||
entryDate: string; // YYYY-MM-DD
|
||||
startTime: string; // HH:MM
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export type EmailQueueStatus = 'pending' | 'sent' | 'failed';
|
||||
|
||||
export interface EmailQueueItem {
|
||||
id: number;
|
||||
recipientEmail: string;
|
||||
emailType: string;
|
||||
status: EmailQueueStatus;
|
||||
createdAt: string;
|
||||
scheduledAt: string | null;
|
||||
sentAt: string | null;
|
||||
errorMessage: string | null;
|
||||
retryCount: number;
|
||||
eventId: number | null;
|
||||
eventName: string | null;
|
||||
eventSlug: string | null;
|
||||
}
|
||||
|
||||
export interface EmailQueueListResponse {
|
||||
items: EmailQueueItem[];
|
||||
pagination: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}
|
||||
|
||||
export interface EmailConfig {
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
@@ -74,6 +96,31 @@ export const emailService = {
|
||||
await api.post('/admin/email/test', { test_email: testEmail });
|
||||
},
|
||||
|
||||
/** Flush the email queue immediately. Sends every pending email now,
|
||||
* bypassing the business-hours floor — the escape hatch for draining
|
||||
* the queue before maintenance/updates. */
|
||||
async flushQueue(): Promise<{ processed: number; sent: number; failed: number }> {
|
||||
const response = await api.post<{ processed: number; sent: number; failed: number }>(
|
||||
'/admin/email/flush-queue'
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** Read-only "Sent emails" feed — paginated view of the email_queue
|
||||
* table with filters. email_data is never returned. */
|
||||
async listQueue(params: {
|
||||
status?: EmailQueueStatus;
|
||||
emailType?: string;
|
||||
q?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {}): Promise<EmailQueueListResponse> {
|
||||
const response = await api.get<EmailQueueListResponse>('/admin/email/queue', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get all email templates
|
||||
async getTemplates(): Promise<EmailTemplate[]> {
|
||||
const response = await api.get<EmailTemplate[]>('/admin/email/templates');
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface PaymentCheckIssuer {
|
||||
email?: string;
|
||||
website?: string;
|
||||
logoUrl: string | null;
|
||||
/** Dark-mode branding logo; the page picks per its colour mode. */
|
||||
logoUrlDark?: string | null;
|
||||
}
|
||||
export interface PaymentCheckResponse {
|
||||
invoice: PaymentCheckView;
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface PublicSettings {
|
||||
branding_watermark_size: number;
|
||||
branding_favicon_url: string;
|
||||
branding_logo_url: string;
|
||||
branding_logo_url_dark?: string;
|
||||
branding_logo_size?: string;
|
||||
branding_logo_max_height?: number;
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export type QuoteStatus = 'draft' | 'sent' | 'accepted' | 'declined' | 'expired' | 'converted';
|
||||
export type QuoteSort = 'newest' | 'oldest' | 'customer_asc' | 'value_asc' | 'value_desc';
|
||||
export type QuoteSort =
|
||||
| 'newest' | 'oldest'
|
||||
| 'issue_asc' | 'issue_desc'
|
||||
| 'customer_asc' | 'customer_desc'
|
||||
| 'value_asc' | 'value_desc';
|
||||
|
||||
export interface QuoteLineItem {
|
||||
id?: number;
|
||||
@@ -94,6 +98,10 @@ export interface QuoteDetail extends QuoteSummary {
|
||||
ccPdfEmail: string | null;
|
||||
respondedAt: string | null;
|
||||
responseLockedAt: string | null;
|
||||
/** Free-text reason captured when an admin declines on the customer's
|
||||
* behalf (migration 115). Null for customer-side declines + non-declined
|
||||
* quotes. */
|
||||
declineReason: string | null;
|
||||
pdfPath: string | null;
|
||||
businessBankAccountId: number | null;
|
||||
}
|
||||
@@ -248,6 +256,14 @@ export const quotesService = {
|
||||
return data.data || data;
|
||||
},
|
||||
|
||||
/** Admin decline-on-behalf — flips the quote to `declined` without the
|
||||
* customer's public response page. Optional free-text reason. Used
|
||||
* when the customer says no by phone/email. */
|
||||
async declineOnBehalf(id: number, reason?: string): Promise<{ status: string; declinedAt: string }> {
|
||||
const { data } = await api.post(`/admin/quotes/${id}/decline`, reason ? { reason } : {});
|
||||
return data.data || data;
|
||||
},
|
||||
|
||||
async convert(id: number): Promise<{ eventId: number; alreadyConverted: boolean }> {
|
||||
const { data } = await api.post(`/admin/quotes/${id}/convert`);
|
||||
return data.data || data;
|
||||
@@ -374,6 +390,8 @@ export interface PublicQuoteView {
|
||||
footerLine: string;
|
||||
/** Absolute or /uploads/-prefixed URL set by the public route. */
|
||||
logoUrl?: string | null;
|
||||
/** Dark-mode branding logo; the page picks per its colour mode. */
|
||||
logoUrlDark?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -221,13 +221,14 @@ export const settingsService = {
|
||||
});
|
||||
},
|
||||
|
||||
// Upload logo
|
||||
async uploadLogo(file: File): Promise<string> {
|
||||
// Upload logo. Pass variant='dark' to store the dark-mode logo
|
||||
// (branding_logo_url_dark); default stores the light logo.
|
||||
async uploadLogo(file: File, variant?: 'dark'): Promise<string> {
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
|
||||
|
||||
const response = await api.post<{ logoUrl: string }>(
|
||||
'/admin/settings/logo',
|
||||
`/admin/settings/logo${variant === 'dark' ? '?variant=dark' : ''}`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
@@ -235,10 +236,15 @@ export const settingsService = {
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
return response.data.logoUrl;
|
||||
},
|
||||
|
||||
// Remove a logo (variant='dark' clears the dark-mode logo).
|
||||
async removeLogo(variant?: 'dark'): Promise<void> {
|
||||
await api.delete(`/admin/settings/logo${variant === 'dark' ? '?variant=dark' : ''}`);
|
||||
},
|
||||
|
||||
// Upload favicon
|
||||
async uploadFavicon(file: File): Promise<string> {
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Admin → System health. Surfaces background failures (v1: stuck/failed
|
||||
* outbound emails) so they don't sit unnoticed, with retry/dismiss.
|
||||
*/
|
||||
import { api } from '../config/api';
|
||||
|
||||
export interface StuckEmail {
|
||||
id: number;
|
||||
recipientEmail: string;
|
||||
emailType: string;
|
||||
status: 'pending' | 'failed';
|
||||
retryCount: number;
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SystemHealthFailures {
|
||||
stuckEmails: StuckEmail[];
|
||||
counts: { stuckEmails: number };
|
||||
}
|
||||
|
||||
export const systemHealthService = {
|
||||
async getFailures(): Promise<SystemHealthFailures> {
|
||||
const { data } = await api.get('/admin/system-health/failures');
|
||||
return data.data || data;
|
||||
},
|
||||
|
||||
async retryEmail(id: number): Promise<void> {
|
||||
await api.post(`/admin/system-health/failures/email/${id}/retry`);
|
||||
},
|
||||
|
||||
async dismissEmail(id: number): Promise<void> {
|
||||
await api.delete(`/admin/system-health/failures/email/${id}`);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user