Addresses the maintainer's non-blocking review items + the Outlook email bug:
- invoice create: verify the chosen event belongs to the customer (only when
the event has assignments; legacy unassigned events pass through).
- mark-paid + import: bound paidAt to [2000-01-01, now+30d] so a typo'd year
can't silently drop a payment out of every cash-basis revenue window.
- customer routes: country_code now {min:2,max:2}+isAlpha+uppercase-normalize
(was isString/max:2 — allowed '', '1', '!@'), matching the business-profile
route.
- email transporter: close the previous instance before re-init (leak guard
for a future pooled transport).
- scheduled-email tz: warn loudly when business_hours is set but the profile
timezone is blank (was silently using the server/UTC tz).
- wrapEmailHtml: rebuild the chrome as inline-styled tables + bgcolor and
inline the themed CTA button, so the design survives Outlook/Apple Mail
stripping the head <style> (kept the <style> as progressive enhancement).
The /favicon.ico + /apple-touch-icon routes stream the file directly,
bypassing the secureStatic middleware that locks down served SVGs. An
admin-uploaded SVG favicon with <script> would then run at the top-level
origin (stored XSS). Re-apply the same CSP (default-src 'none') + nosniff
for .svg here, mirroring secureStatic.js. Reported in the #603 review.
Two complaints in Rekoo-PS's 3.60.1-beta.0 follow-up screenshots:
1. "Logo took some time to load" — header appeared empty for the
~hundreds-of-ms window between admin mount and `usePublicSettings()`
resolving. The previous code rendered the static fallback
`/picpeak-kamera-transparent.png` during that window, which often
either 404'd or loaded after the rest of the chrome, and because the
wordmark is `hidden sm:inline` whenever a logo is intended to be
shown, phone-width admins saw an empty left cluster instead of
anything.
Cure: render a small pulsing skeleton block (h-8 w-8 on <sm, w-32
on sm+) while `brandingLoading === true`. Same h-8 footprint as the
real logo image so there's no layout shift when the real payload
arrives. Once the public-settings query settles, the normal brand
block renders against known state.
2. "Moving the languages inside the profile tab" — Rekoo-PS argues
language is set-once and shouldn't occupy permanent header real
estate on mobile (4 widgets in the right cluster on phone is
crowded). I agree.
On <sm: header LanguageSelector is hidden (`hidden sm:block` wrapper
around the existing component). A collapsible Language section is
added at the top of the user-menu dropdown showing the current
flag/name + chevron-down. Expanding shows the 8 supported languages
as inline rows highlighting the active one. Picking a language fires
i18n.changeLanguage and closes the menu.
On sm+: header LanguageSelector stays where it was. The user-menu
Language section is suppressed (`sm:hidden`) so the same control
isn't surfaced twice.
Also: `useOnClickOutside(userMenuRef, …)` and the in-menu action
handlers now route through a shared `closeUserMenu()` helper that
also resets the lang sub-section state, so re-opening the menu
doesn't surprise the user with the language list still expanded.
`SUPPORTED_LANGUAGES` re-exported from `components/common` so
AdminHeader doesn't reach into `LanguageSelector.tsx` directly.
No behaviour change on `sm+` — pure phone-view layout fix +
loading-state polish. Locales unaffected (uses the already-existing
language names from SUPPORTED_LANGUAGES).
Mirror the onboarding fix on the customer profile (Rechnungsadresse): replace
the free-text 2-char Country input with the CountrySelect dropdown and move it
below State/region. Grid reflowed: Postal+City row, then State+Country row.
The accept-invite (onboarding) address form used a free-text 2-char Country
input sitting above State/region. Replace it with the CountrySelect dropdown
(same component as the admin customer + business-profile forms) and move it
below State/region. Grid reflowed: Postal+City row, then State+Country row.
The test-email, save-config, and flush mutations all showed the generic
'Failed to save changes' toast on error, hiding the actual backend reason —
so a failing test email looked like a save failure and gave no diagnosis.
Show response.data.error / .details (SMTP auth/connection failure, masked
password, private-host rejection, …) with the generic string as fallback.
Two gaps left emails stuck 'pending' after (re)configuring SMTP:
1. Saving the email config never re-initialised the transporter. The queue
processor only re-inits when its cached transporter is null, so a changed
SMTP account had no effect until a backend restart. Now call
initializeTransporter(true) after save (it self-catches; invalid config
just leaves it null, surfaced via the Test-email button).
2. The manual 'send now' flush (ignoreSchedule) still enforced retry_count<3,
so emails that failed 3× while SMTP was broken could never be retried from
the UI. Move the retry-cap (and schedule gate) to automatic runs only;
a manual flush forces a retry of every pending email.
The customer detail + business profile forms showed both a Country picker
(stores the ISO code) and a free-text 'Country (full name)' override
(migration 107). Now that the picker offers the full ISO list and the PDF
renderer derives the localized full name from the code (pdfService.countryName,
used as 'country_name || derive' for both issuer and recipient), the free-text
field is redundant. Remove the input from both forms. The DB column + the
fallback stay, so any legacy override still renders.
index.html hardcoded <link rel=icon href=/favicon-32x32.png>. When the HTML
declares a favicon link, the browser uses it and NEVER requests /favicon.ico
— so Safari showed the bundled default and our dynamic backend route was
never hit (direct /favicon.ico was correct, but the tab wasn't). DynamicFavicon's
JS swap is exactly what Safari ignores.
Point the link at /favicon.ico (backend dynamic route) + add apple-touch-icon,
no type/sizes so the response content-type wins. Now the configured favicon
shows from first paint in every browser, Safari included.
The /favicon.ico route 302-redirected to the uploaded file. Firefox/Chrome
follow that, but Safari does NOT reliably follow a redirect for favicon
requests — it falls back to the HTML <link>, i.e. the bundled picpeak
default. Stream the file bytes directly for local /uploads favicons (with a
path-containment guard); only external URLs and the missing-favicon fallback
still redirect. sendFile sets the content-type from the extension.
Per decision: keep dashboard revenue windows on pure cash basis (recognise
by paid_at for ALL invoices) and give the admin control over paid_at.
- adminDashboard: revert the imported-vs-native split; winSum is paid_at >=
cutoff for every paid invoice again (clean cash basis).
- BillDetailPage mark-paid dialog: add an optional 'Payment date' field
(LocalizedDateInput, defaults to today) so a payment can be backdated to
when it actually arrived. Backend already accepted paidAt end-to-end
(route validator + markPaid service + payment-log) — only the UI was
missing. EN/DE 'bills.payment.date' added.
This fixes the collapsed 30=90=365 windows (they were collapsing because
many invoices were marked paid in one session, all stamped 'now').
The historical-invoice import (and any form whose date field has a non-empty
default like today) lost a typed date: the value was only pushed to the parent
on blur, so submitting while the field was focused — or before React
re-rendered after the blur-time setState — sent the stale default. Issued/
event dates came out as 'today' instead of the entered date.
Now commit as soon as a complete, valid date is entered (toIso returns '' for
partial input, so intermediate keystrokes emit nothing); blur still normalises
display + handles clearing. Applies to every LocalizedDateInput consumer.
Safari requests /favicon.ico and /apple-touch-icon*.png at the site root and
is unreliable about honouring JS-injected <link rel=icon>, so an admin-set
favicon never showed there (index.html only ships /favicon-32x32.png; a bare
/favicon.ico 404'd).
- Backend: GET /favicon.ico + /apple-touch-icon(.png|-precomposed.png) resolve
the configured branding_favicon_url (redirect to its /uploads path or the
absolute URL), falling back to the bundled /favicon-32x32.png.
- nginx: exact-match (=) locations proxy those paths to the backend, winning
over the static-asset regex that previously served them from the build dir.
- DynamicFavicon also emits an apple-touch-icon link (belt-and-braces).
Requires a frontend image REBUILD (nginx.conf change) in addition to backend.
The dashboard revenue windows (30/90/365 days) keyed purely on paid_at.
Imported historical invoices therefore landed in the recent window whenever
their paid_at sat there — notably legacy rows imported before commit c6b8cc9
began anchoring an import's paid_at to its issue_date, which still carry an
import-time paid_at. Recognise imported invoices (imported_pdf_path NOT NULL)
on their issue_date instead; native invoices keep cash-basis paid_at. No data
migration needed — fixes already-imported year-old invoices too.
Extends the dark-logo fix to the customer login, customer accept-invite,
customer reset-password, and gallery client-access pages — they all rendered
only the light logo on the themed (possibly dark) surface.
Also makes the login-page pick frame-aware: a framed login logo sits on a
fixed cream plate, so the light (dark-ink) logo always reads there; only the
frameless logo sits on the themed page background and uses the dark variant.
This corrects the admin login too (was unconditionally swapping when dark).
Customer/gallery pages read isDark from usePublicDarkMode (branding_force_
color_mode + OS fallback), matching CustomerLayout.
The public quote, contract-signing, and payment-check pages baked a single
light logo (the contract page showed none), so the dark page rendered a
dark-text logo on a dark background.
- usePublicDarkMode now returns { isDark } (reactive) alongside applying
the .dark class, so pages can pick a theme-aware asset.
- The three public routes now surface both branding logo URLs (logoUrl +
logoUrlDark) in the issuer block; the contract issuer gains a logo too.
- QuoteResponsePage, ContractResponsePage, and the payment-check
BrandingHeader pick the dark variant when isDark, falling back to
whichever exists. Covers the accept/accepted states of each page.
The SVG->PNG cache was keyed only by source path + mtime + size, so an
override logo rasterised once WITHOUT fonts (text -> tofu) stayed cached
after the font fix - the source SVG was unchanged, so the stale tofu PNG
kept being served. Add a RASTER_VERSION component to the cache key; bumping
it (v2-fonts) invalidates every prior rasterisation without clearing the
cache dir by hand.
The favicon upload allowed only PNG/ICO, so an SVG favicon was rejected.
Accept image/svg+xml (.svg) too - DynamicFavicon already emits the right
MIME type and served SVGs are CSP-locked (render-only) by secureStatic.
Update the EN/DE help text accordingly.
The login page only ever rendered branding_logo_url (the light logo), so a
dark-text logo sat on the dark background in dark mode. Pick the dark
variant via useAdminDarkMode (honouring branding_force_color_mode too),
mirroring AdminHeader/AdminSidebar, with a fallback to whichever exists.
The runtime image (node:22-alpine) shipped without any fonts, so when
sharp/librsvg rasterised an SVG logo containing live <text> for the CRM
PDFs, the vector artwork drew but the text rendered as tofu boxes - a
'corrupted' logo on invoices/quotes.
- Add fontconfig + DejaVu/Liberation (broad Unicode fallback) and refresh
the font cache.
- Register picpeak's own bundled brand fonts (assets/fonts/<Family>/*.ttf -
the same files PDFKit and the web UI already use) with fontconfig via a
conf.d <dir> entry + fc-cache, so the logo's text renders in its ACTUAL
brand typeface rather than a generic fallback.
The upload never enforced 32x32 - only the help text recommended it,
which misled admins. Update the EN/DE guidance to recommend a larger
square image (512x512) and raise the favicon upload cap 1MB -> 2MB so
high-resolution PNGs fit comfortably.
The link was an <a target="_blank"> doing a hard SPA boot in a new tab,
which tripped the error boundary. Switch to a react-router <Link> so it
opens Settings -> CRM the same way the sidebar nav does (known-good path).
The country dropdown was a curated 22-entry European subset; expand it to
the complete ISO 3166-1 alpha-2 set so customers from any country can be
selected. Labels are still derived from Intl.DisplayNames and sorted by
localized name at render time, so no translation map is needed.
GalleryPreview used only branding.logo_url, so the Live Preview kept the
light logo when previewing a dark theme. Pick the logo by theme.colorMode
(symmetric fallback) and pass logo_url_dark through from BrandingPage.
The sidebar brand row (logo_position=sidepanel) + collapsed rail used
the light logo unconditionally. Make it theme-aware via useAdminDarkMode
with the same symmetric fallback as the header (dark uses dark||light,
light uses light||dark). This was the missing admin surface — the header
already switched.
The whole contracts.detail.* namespace was English-fallback-only, so the
contract detail page rendered English in German. Add all 64 keys to en +
de (native German), covering actions, signing/counter-sign, audit trail,
convert, and PDF flows. Also dedupe a duplicate events.createInvoice key
(identical value).
Serve uploaded SVGs (admin logos etc.) with a restrictive
Content-Security-Policy (default-src 'none'; style-src 'unsafe-inline';
img-src 'self' data:) + X-Content-Type-Options: nosniff in secureStatic.
The browser still renders the vector, but any embedded <script>/on*
handler can't execute if the SVG is opened directly — keeps real SVGs
(scalable) instead of rasterising them. Applies to all secureStatic
mounts (uploads/photos/thumbnails/fonts); only SVGs get the header.
After 'Create draft invoice' mints the single scheduled invoice from a
per-event customer's unbilled hours, navigate to the bill editor so the
admin can add other line items before it ships (invoice is already
status='scheduled' + editable). Updated the per-event hint copy.
New /admin/system-health page (sidebar entry, settings.view) that lists
emails the queue gave up on (status='failed' or pending+retry>=3) with
retry (re-queue) and dismiss (delete) actions. Backend adds /failures,
/failures/email/:id/retry and DELETE on adminSystemHealth. First source
is email failures (the original trigger — quote_sent template errors
left invoices unsent for 14h with no signal); more sources can be added.
DynamicFavicon hardcoded link.type='image/png', so an SVG/.ico favicon
was declared as PNG and browsers ignored it. Derive the type from the
file extension instead. (Sidebar icon already uses <img> which renders
SVG fine.)
Pick the logo by the active color mode with a symmetric fallback: a
single uploaded logo serves both modes (dark uses dark||light, light
uses light||dark). Apply to admin header, customer gallery, and the
customer portal (follows branding_force_color_mode). PDFs already use
the light branding logo with the business-profile PDF logo as override
(resolveLogoFile) — unchanged.
Add an optional dark-mode logo (branding_logo_url_dark) alongside the
main logo. Upload/remove via the logo endpoint (?variant=dark) on the
Branding settings page. Admin header (admin dark mode) and the public
gallery (dark themes) pick the dark logo when active, falling back to
the light logo when unset. PDFs keep using the light logo.
Add a 'Configure defaults in Settings' link (opens Settings → CRM in a
new tab) under the payment-conditions section of the quote and invoice
editors, so the admin can jump to the payment-term / Skonto / numbering
defaults without hunting for the settings page.
Add a 'Preview PDF' button on draft contracts that renders a fresh PDF
via the existing no-write /preview endpoint, so the admin can check
layout + signature blocks before sending (no audit trail created).
LocalizedDateInput.toDisplay only matched a bare yyyy-MM-dd, but Postgres
serializes DATE columns as a full ISO datetime, so the field printed the
raw "2026-…T…Z" string (SQLite returned a bare date, hiding it). Match the
leading yyyy-MM-dd of any ISO value and slice the hidden native picker's
value to 10 chars. Fixes ISO-form dates on customer/event/passive-create.
Add a bills-gated button that opens the bill editor pre-filled with the
event (eventId FK + name/date snapshot) and the linked customer (when
exactly one). BillEditorPage gains eventId state + query-param prefill +
sends eventId on create; backend validates eventId (already forwarded +
persisted). Reuses the editor — no empty drafts. Does not auto-pull hours.
queueEmail gains options.respectBusinessHours: snaps the send time to the
next open business-hours block (from now), only deferring when it actually
falls outside hours. Applied to dunning reminders + gallery-expiry warnings;
transactional/admin-initiated mail stays immediate. No-op until business
hours are configured.
Native <input type="time"> ignores general_time_format (browser-locale
controlled; lang hint failed in Safari and Ralf's Chrome for both en-GB
and de-DE). Replace with a custom TimeField text input that displays per
general_time_format (24h "13:00" / 12h "01:00 PM") and stores canonical
HH:MM, parsing tolerant free-text on blur. Keeps the fixed-width
alignment fix.
Business-hours time pickers used lang="en-GB", which didn't render 24h in
Chrome. Switch to lang={timeFormat==='12h'?'en-US':'de-DE'} — the same hint
HoursSection/Quote/Bill/Contract editors use — so the picker shows 24h in
Chrome/Edge. Keep the fixed-width plain <input> for column alignment.
The shared Input wraps fields in a w-full div, so two per flex row split
the width and the trailing +/trash buttons knocked columns out of
alignment. Use a plain fixed-width <input> for the start/end time fields.
Route 10 surfaces that hardcoded 12-hour date-fns patterns
('h:mm a', 'PPp', 'p', toLocaleTimeString) through useLocalizedDate's
formatDateTime/formatTime so they respect general_date_format +
general_time_format: backup/restore, archives, photo viewer, feedback,
event details, gallery timeline, public quote page, CMS save indicator.
Also pin a lang hint on the business-hours native time inputs (Chrome/Edge
render 24h). Drops now-unused date-fns imports.
Native <input type="time"> renders AM/PM from the browser locale, ignoring
general_time_format. Pin a lang hint (en-GB for 24h, en-US for 12h) so
Chrome/Edge render the admin's chosen format. Display-only; the stored
value was already 24h HH:MM.