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