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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user