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:
Paul Nothaft
2026-06-04 22:09:08 +02:00
committed by GitHub
104 changed files with 4685 additions and 629 deletions
+18 -1
View File
@@ -43,7 +43,16 @@ RUN npm install -g npm@10
# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
# pipeline calls via fluent-ffmpeg.ffprobe()).
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec
# fontconfig is required so `sharp` (librsvg) can rasterise SVG logos that
# contain live <text> for the CRM PDFs. Without any font installed, librsvg
# renders text as tofu boxes (□) while the vector artwork still draws — i.e.
# a "corrupted" logo on invoices/quotes. DejaVu/Liberation provide a broad
# Unicode fallback; picpeak's own brand fonts (assets/fonts/, the same files
# PDFKit + the web UI use) are registered with fontconfig further down so the
# logo's text renders in its actual typeface, not a fallback.
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
fontconfig ttf-dejavu ttf-liberation && \
fc-cache -f
# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
@@ -55,6 +64,14 @@ COPY --chown=nodejs:nodejs . .
# Ensure all source files are readable and wait script is executable
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
# Register picpeak's bundled brand fonts (assets/fonts/<Family>/*.ttf — the
# same files PDFKit and the web UI use) with fontconfig, so when sharp/librsvg
# rasterises an SVG logo its <text> renders in the actual brand typeface
# rather than a DejaVu/Liberation fallback. fontconfig indexes by each font's
# internal family name and recurses into the per-family subdirectories.
RUN printf '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "fonts.dtd">\n<fontconfig>\n <dir>/app/assets/fonts</dir>\n</fontconfig>\n' > /etc/fonts/conf.d/99-picpeak-fonts.conf && \
fc-cache -f /app/assets/fonts
# Create necessary directories
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
chown -R nodejs:nodejs storage data logs
@@ -58,13 +58,30 @@ describe('resolveEffectiveRate', () => {
)).toBe(15000);
});
it('throws when both override and customer rate are unset', () => {
it('throws when override, customer rate, AND install default are all unset', () => {
expect(() => resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: null },
null,
)).toThrow(/No hourly rate/);
});
it('falls back to the install-wide default when override + customer rate are unset', () => {
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: null },
12000,
)).toBe(12000);
});
it('customer rate wins over the install-wide default', () => {
expect(resolveEffectiveRate(
{ hourly_rate_minor_override: null },
{ hourly_rate_minor: 15000 },
12000,
)).toBe(15000);
});
it('treats override=0 as "explicitly zero" (not null)', () => {
// Override === 0 is unusual but legal — pro bono blocks, internal
// tracking. Must NOT fall through to the customer default.
@@ -0,0 +1,238 @@
/**
* Unit tests for the per-weekday business-hours floor (migration 114).
* Exercises the pure snap logic against a fixed IANA zone so the results
* don't drift with the CI box's local timezone.
*
* All scenarios use Europe/Zurich (the regulatory-scope default).
*/
const {
snapToBusinessHours,
parseHHMM,
minutesToHHMM,
normaliseSchedule,
hasAnyBlocks,
_internal,
} = require('../../src/utils/businessHours');
const TZ = 'Europe/Zurich';
// MonFri 09:0018:00, weekend closed. Plain string-block storage shape.
const STANDARD = {
'1': [{ start: '09:00', end: '18:00' }],
'2': [{ start: '09:00', end: '18:00' }],
'3': [{ start: '09:00', end: '18:00' }],
'4': [{ start: '09:00', end: '18:00' }],
'5': [{ start: '09:00', end: '18:00' }],
'6': [],
'7': [],
};
// MonFri with a lunch break (09:0012:00, 13:0018:00).
const LUNCH = {
'1': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'2': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'3': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'4': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'5': [{ start: '09:00', end: '12:00' }, { start: '13:00', end: '18:00' }],
'6': [],
'7': [],
};
const cfg = (schedule, overrides = {}) => ({
enabled: true,
timezone: TZ,
schedule,
...overrides,
});
// Build a UTC instant from a Zurich wall-clock so the assertions read in
// local terms. Reuses the module's own converter (covered separately).
function zurich(y, mo, d, hh, mi) {
return _internal.zonedWallClockToUtc(y, mo, d, hh, mi, TZ);
}
function partsOf(date) {
const p = _internal.getZonedParts(date, TZ);
return [p.y, p.mo, p.d, p.hh, p.mi];
}
describe('parseHHMM / minutesToHHMM', () => {
it('parses valid times to minutes', () => {
expect(parseHHMM('09:00')).toBe(540);
expect(parseHHMM('00:00')).toBe(0);
expect(parseHHMM('23:59')).toBe(1439);
});
it('rejects malformed input', () => {
expect(parseHHMM('9:00')).toBeNull();
expect(parseHHMM('24:00')).toBeNull();
expect(parseHHMM('12:60')).toBeNull();
expect(parseHHMM('')).toBeNull();
expect(parseHHMM(null)).toBeNull();
});
it('round-trips minutesToHHMM', () => {
expect(minutesToHHMM(540)).toBe('09:00');
expect(minutesToHHMM(0)).toBe('00:00');
expect(minutesToHHMM(1439)).toBe('23:59');
});
});
describe('normaliseSchedule', () => {
it('parses, sorts, and drops invalid blocks', () => {
const out = normaliseSchedule({
'1': [{ start: '13:00', end: '18:00' }, { start: '09:00', end: '12:00' }],
'2': [{ start: '18:00', end: '09:00' }], // end<=start → dropped
'3': [{ start: 'bad', end: '18:00' }], // malformed → dropped
});
expect(out['1']).toEqual([
{ start: '09:00', end: '12:00' },
{ start: '13:00', end: '18:00' },
]);
expect(out['2']).toEqual([]);
expect(out['3']).toEqual([]);
expect(out['7']).toEqual([]);
});
it('accepts [start,end] pair blocks and a JSON string', () => {
const out = normaliseSchedule(JSON.stringify({ '4': [['09:00', '17:00']] }));
expect(out['4']).toEqual([{ start: '09:00', end: '17:00' }]);
});
it('garbage input → all-empty week', () => {
expect(hasAnyBlocks(normaliseSchedule('not json'))).toBe(false);
expect(hasAnyBlocks(normaliseSchedule(null))).toBe(false);
});
});
describe('snapToBusinessHours — single window (MonFri 09:0018:00)', () => {
it('weekday before open (Tue 02:11) → SAME day 09:00', () => {
// 2026-06-02 is a Tuesday.
const out = snapToBusinessHours(zurich(2026, 6, 2, 2, 11), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
});
it('weekday inside window (Tue 10:30) → unchanged', () => {
const input = zurich(2026, 6, 2, 10, 30);
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
});
it('weekday after close (Tue 20:00) → next business day 09:00 (Wed)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 20, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
it('Sunday 14:00 → Monday 09:00', () => {
// 2026-06-07 is a Sunday; 2026-06-08 is the Monday.
const out = snapToBusinessHours(zurich(2026, 6, 7, 14, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('Saturday before open (Sat 02:11) → Monday 09:00 (closed day, not same-day)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 6, 2, 11), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('Friday after close (Fri 19:30) → Monday 09:00 (skips weekend)', () => {
// 2026-06-05 is a Friday.
const out = snapToBusinessHours(zurich(2026, 6, 5, 19, 30), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 8, 9, 0]);
});
it('exactly at open (Tue 09:00) → unchanged (inclusive lower bound)', () => {
const input = zurich(2026, 6, 2, 9, 0);
expect(snapToBusinessHours(input, cfg(STANDARD)).getTime()).toBe(input.getTime());
});
it('exactly at close (Tue 18:00) → next business day 09:00 (exclusive upper bound)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 18, 0), cfg(STANDARD));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
});
describe('snapToBusinessHours — lunch break (09:0012:00, 13:0018:00)', () => {
it('morning block (Tue 10:30) → unchanged', () => {
const input = zurich(2026, 6, 2, 10, 30);
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
});
it('during lunch (Tue 12:30) → SAME day 13:00 (next block open)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 30), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
});
it('exactly at lunch start (Tue 12:00) → 13:00 (block end is exclusive)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 12, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 13, 0]);
});
it('afternoon block (Tue 17:59) → unchanged', () => {
const input = zurich(2026, 6, 2, 17, 59);
expect(snapToBusinessHours(input, cfg(LUNCH)).getTime()).toBe(input.getTime());
});
it('before open (Tue 07:00) → SAME day 09:00 (first block)', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 7, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 2, 9, 0]);
});
it('after close (Tue 19:00) → next day 09:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 19, 0), cfg(LUNCH));
expect(partsOf(out)).toEqual([2026, 6, 3, 9, 0]);
});
});
describe('snapToBusinessHours — per-day differing hours', () => {
const PERDAY = {
'1': [{ start: '08:00', end: '12:00' }], // Mon morning only
'2': [], // Tue closed
'3': [{ start: '14:00', end: '20:00' }], // Wed afternoon/evening
'4': [], '5': [], '6': [], '7': [],
};
it('Mon after its noon close (Mon 13:00) → skips closed Tue → Wed 14:00', () => {
// 2026-06-01 is a Monday; 2026-06-03 is the Wednesday.
const out = snapToBusinessHours(zurich(2026, 6, 1, 13, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
it('closed Tuesday (Tue 10:00) → Wed 14:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 2, 10, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
it('Wed before its 14:00 open (Wed 09:00) → SAME day 14:00', () => {
const out = snapToBusinessHours(zurich(2026, 6, 3, 9, 0), cfg(PERDAY));
expect(partsOf(out)).toEqual([2026, 6, 3, 14, 0]);
});
});
describe('snapToBusinessHours — passthrough cases', () => {
it('floor disabled → unchanged even when outside hours', () => {
const input = zurich(2026, 6, 2, 2, 11);
expect(snapToBusinessHours(input, cfg(STANDARD, { enabled: false })).getTime())
.toBe(input.getTime());
});
it('empty schedule → unchanged (nothing to snap to)', () => {
const empty = normaliseSchedule(null);
const input = zurich(2026, 6, 7, 14, 0);
expect(snapToBusinessHours(input, cfg(empty)).getTime()).toBe(input.getTime());
});
it('non-Date / invalid input is passed through untouched', () => {
expect(snapToBusinessHours(null, cfg(STANDARD))).toBeNull();
const bad = new Date('not-a-date');
expect(Number.isNaN(snapToBusinessHours(bad, cfg(STANDARD)).getTime())).toBe(true);
});
});
describe('_internal round-trips', () => {
it('zonedWallClockToUtc → getZonedParts reconstructs the wall-clock', () => {
const d = _internal.zonedWallClockToUtc(2026, 6, 2, 9, 0, TZ);
const p = _internal.getZonedParts(d, TZ);
expect([p.y, p.mo, p.d, p.hh, p.mi]).toEqual([2026, 6, 2, 9, 0]);
});
it('isoWeekday: 2026-06-07 is Sunday (7), 2026-06-08 is Monday (1)', () => {
expect(_internal.isoWeekday(2026, 6, 7)).toBe(7);
expect(_internal.isoWeekday(2026, 6, 8)).toBe(1);
});
});
@@ -0,0 +1,40 @@
/**
* Migration: normalize the Liechtenstein country code from the
* colloquial vehicle-plate code `FL` to the ISO 3166-1 alpha-2 code
* `LI`.
*
* Background: the customer create/edit UI used to accept a free-text
* 2-char country code and the placeholder suggested `FL` for
* Liechtenstein. That code isn't ISO — the PDF renderer's locale-aware
* lookup (services/pdfService.js countryName) and the new country
* dropdown both key on ISO, so `FL` rows render as the bare code
* instead of "Liechtenstein". The dropdown now stores `LI`; this
* migration brings existing rows in line so they display correctly and
* match new records.
*
* Scope: customer_accounts.country_code and business_profile.country_code.
* Case-insensitive so a hand-entered `fl` is caught too. The free-text
* country_name override column is left untouched — it exists precisely
* for operators who want a custom display string.
*
* Idempotent: re-runs only touch rows still holding FL, so a second run
* is a no-op.
*/
async function normalizeColumn(knex, table) {
if (!(await knex.schema.hasTable(table))) return;
if (!(await knex.schema.hasColumn(table, 'country_code'))) return;
await knex(table)
.whereRaw('UPPER(country_code) = ?', ['FL'])
.update({ country_code: 'LI' });
}
exports.up = async function(knex) {
await normalizeColumn(knex, 'customer_accounts');
await normalizeColumn(knex, 'business_profile');
};
// Irreversible by design: once normalized to the ISO code there's no
// way to know which `LI` rows were originally `FL`, and reverting would
// reintroduce the non-ISO value the rest of the system can't read.
exports.down = async function() {};
@@ -0,0 +1,47 @@
/**
* Migration: backfill historical send/payment dates on already-imported
* invoices.
*
* Background: the invoice-import endpoint (POST /admin/invoices/import)
* used to stamp `sent_at` and `paid_at` with the moment of import
* (`new Date()`) rather than the document's own historical dates. The
* CRM dashboard's "Revenue · last 30 days" card keys on `paid_at`, so a
* year-old paid invoice imported today wrongly counted toward the
* rolling window. The route now anchors both timestamps to `issue_date`
* (with an optional explicit `paidAt`); this migration brings the rows
* imported under the old behaviour in line.
*
* Scope: rows with `imported_pdf_path` set — i.e. historical documents,
* never invoices issued by picpeak itself. For those, no real payment
* date was ever captured (the column held the import timestamp), so the
* issue date is the best available anchor. Note: `paid_at`/`sent_at` are
* operational timestamps, not part of the invoice's immutable legal
* content — correcting an import-time bug on them doesn't alter the
* issued document.
*
* Idempotent: re-runs just re-assign the same issue_date value.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('invoices'))) return;
const cols = ['imported_pdf_path', 'issue_date', 'sent_at', 'paid_at'];
for (const c of cols) {
if (!(await knex.schema.hasColumn('invoices', c))) return;
}
// sent_at → issue_date for every imported row that has one.
await knex('invoices')
.whereNotNull('imported_pdf_path')
.whereNotNull('sent_at')
.update({ sent_at: knex.ref('issue_date') });
// paid_at → issue_date for imported rows that recorded a payment.
await knex('invoices')
.whereNotNull('imported_pdf_path')
.whereNotNull('paid_at')
.update({ paid_at: knex.ref('issue_date') });
};
// Irreversible by design: the original import-time stamps were wrong
// data, and there's no record of them to restore.
exports.down = async function() {};
@@ -0,0 +1,34 @@
/**
* Migration: per-customer Skonto opt-out.
*
* Background: invoices already carry a per-invoice `skonto_disabled`
* flag (migration 126). For B2B customers who negotiated "no early-
* payment discount" as a standing contract term, the admin had to tick
* that toggle on every single invoice. This adds a customer-level flag
* so the opt-out is set once and applies to all of that customer's
* invoices. The resolver chain becomes:
* customer.skonto_disabled → invoice.skonto_disabled →
* invoice snapshot → source-quote snapshot → global default.
*
* Default false so existing customers keep inheriting whatever Skonto
* the template / global default offers — no behaviour change on upgrade
* (see migration-preserve-existing-state guidance).
*
* Idempotent: guarded by hasColumn so a re-run is a no-op.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('customer_accounts'))) return;
if (await knex.schema.hasColumn('customer_accounts', 'skonto_disabled')) return;
await knex.schema.alterTable('customer_accounts', (table) => {
table.boolean('skonto_disabled').notNullable().defaultTo(false);
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('customer_accounts'))) return;
if (!(await knex.schema.hasColumn('customer_accounts', 'skonto_disabled'))) return;
await knex.schema.alterTable('customer_accounts', (table) => {
table.dropColumn('skonto_disabled');
});
};
@@ -0,0 +1,35 @@
/**
* Migration: install-wide default hourly rate.
*
* Background: hour entries resolve a billing rate through
* entry.hourly_rate_minor_override → customer.hourly_rate_minor.
* When a customer had neither set, saving an entry hard-failed with
* HOURLY_RATE_REQUIRED — a confusing save-time error on the hours page.
* This adds an install-wide fallback so a single global rate covers
* every customer who hasn't been given an individual one. The chain
* becomes:
* entry override → customer rate → business_profile default → (CTA).
*
* Stored in minor units (matches customer_accounts.hourly_rate_minor).
* Nullable, default NULL: existing installs keep today's behaviour
* (no implicit rate) until the admin sets one — no surprise rate gets
* applied on upgrade (migration-preserve-existing-state guidance).
*
* Idempotent: guarded by hasColumn so a re-run is a no-op.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('business_profile'))) return;
if (await knex.schema.hasColumn('business_profile', 'default_hourly_rate_minor')) return;
await knex.schema.alterTable('business_profile', (table) => {
table.bigInteger('default_hourly_rate_minor');
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('business_profile'))) return;
if (!(await knex.schema.hasColumn('business_profile', 'default_hourly_rate_minor'))) return;
await knex.schema.alterTable('business_profile', (table) => {
table.dropColumn('default_hourly_rate_minor');
});
};
@@ -0,0 +1,61 @@
/**
* Migration: configurable business hours on the business profile.
*
* Adds two columns to the singleton business_profile row (id=1):
*
* business_hours TEXT — JSON, per-ISO-weekday opening
* blocks. Shape:
* {"1":[{"start":"09:00","end":"12:00"},
* {"start":"13:00","end":"18:00"}],
* ...,"7":[]}
* Keys are ISO weekdays 1=Mon … 7=Sun;
* a day with no blocks is closed.
* Multiple blocks per day model lunch
* breaks (Google-style).
* scheduled_email_floor_enabled BOOLEAN default TRUE — master switch for
* holding scheduled emails until the
* next open block.
*
* business_hours defaults to NULL (no hours configured). A null / empty
* schedule makes the scheduled-email floor a no-op, so existing installs
* keep today's behaviour — emails send at their requested instant until
* the admin actually defines opening hours (migration-preserve-state).
*
* The timezone the blocks are interpreted in is the EXISTING
* business_profile.timezone column (added earlier for the admin calendar)
* — no new tz column. The whole business-hours definition lives on the
* business profile, which is where the admin edits it.
*
* Idempotent: each column guarded by hasColumn so a re-run is a no-op.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('business_profile'))) return;
if (!(await knex.schema.hasColumn('business_profile', 'business_hours'))) {
await knex.schema.alterTable('business_profile', (table) => {
table.text('business_hours');
});
}
if (!(await knex.schema.hasColumn('business_profile', 'scheduled_email_floor_enabled'))) {
await knex.schema.alterTable('business_profile', (table) => {
table.boolean('scheduled_email_floor_enabled').notNullable().defaultTo(true);
});
}
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('business_profile'))) return;
if (await knex.schema.hasColumn('business_profile', 'scheduled_email_floor_enabled')) {
await knex.schema.alterTable('business_profile', (table) => {
table.dropColumn('scheduled_email_floor_enabled');
});
}
if (await knex.schema.hasColumn('business_profile', 'business_hours')) {
await knex.schema.alterTable('business_profile', (table) => {
table.dropColumn('business_hours');
});
}
};
@@ -0,0 +1,31 @@
/**
* Migration: admin decline-quote reason.
*
* Background: admins can now decline a quote on the customer's behalf
* ("customer told us by phone they're not going ahead") instead of
* waiting for the public response link. This stores an optional free-text
* reason alongside the existing `declined_at` timestamp so the quote
* detail page can show WHY it was declined.
*
* Nullable, no default — existing declined rows simply carry no reason,
* which is exactly how customer-side declines already look. No behaviour
* change on upgrade.
*
* Idempotent: guarded by hasColumn so a re-run is a no-op.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (await knex.schema.hasColumn('quotes', 'decline_reason')) return;
await knex.schema.alterTable('quotes', (table) => {
table.text('decline_reason');
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('quotes'))) return;
if (!(await knex.schema.hasColumn('quotes', 'decline_reason'))) return;
await knex.schema.alterTable('quotes', (table) => {
table.dropColumn('decline_reason');
});
};
@@ -0,0 +1,37 @@
/**
* Migration: backfill paid_amount_minor for imported PAID invoices that
* stored 0.
*
* Background: the historical-invoice import only sent paidAmountMinor when
* the admin separately filled the "paid amount" field. Left blank (easy to
* miss — the total was already entered), it stored paid_amount_minor = 0
* even with status='paid'. The dashboard revenue windows sum
* paid_amount_minor (not total), so those paid imports contributed NOTHING
* to revenue. The import route now defaults a blank paid amount to the
* total; this fixes the rows already created before that change.
*
* Scope: imported (imported_pdf_path set) + status='paid' + paid_amount_minor
* 0/null → set paid_amount_minor = total_amount_minor (fully paid). Operational
* payment field, not immutable legal content (same reasoning as migration 111).
*
* Idempotent: re-running sets the same value; rows already > 0 are untouched.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('invoices'))) return;
if (!(await knex.schema.hasColumn('invoices', 'imported_pdf_path'))) return;
if (!(await knex.schema.hasColumn('invoices', 'paid_amount_minor'))) return;
await knex('invoices')
.whereNotNull('imported_pdf_path')
.where('status', 'paid')
.andWhere(function() {
this.where('paid_amount_minor', 0).orWhereNull('paid_amount_minor');
})
.update({ paid_amount_minor: knex.raw('total_amount_minor') });
};
exports.down = async function() {
// Irreversible data backfill — we can't tell which rows we changed apart
// from legitimately-full payments. No-op.
};
+46
View File
@@ -559,6 +559,52 @@ app.get('/robots.txt', async (req, res) => {
}
});
// Dynamic favicon endpoints. Browsers — notably Safari — request
// /favicon.ico and /apple-touch-icon*.png directly at the site root and are
// unreliable about honouring JS-injected <link rel="icon"> tags. Serving the
// admin's configured branding favicon here makes it work without client-side
// JS (and survive aggressive favicon caches). Falls back to the bundled asset
// shipped with the frontend build when no custom favicon is set.
app.get(
['/favicon.ico', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png'],
async (req, res) => {
try {
const { getAppSetting } = require('./src/utils/appSettings');
const raw = await getAppSetting('branding_favicon_url', null);
const url = (raw && String(raw).trim()) || null;
if (url) {
// External URL — can't stream the bytes, so redirect (best effort).
if (/^https?:\/\//i.test(url)) return res.redirect(302, url);
// Local upload → stream the file bytes DIRECTLY rather than 302'ing.
// Safari does NOT reliably follow a redirect for favicon requests
// (it falls back to the HTML <link>, i.e. the bundled default),
// whereas Firefox/Chrome do — so a 302 worked everywhere except
// Safari. sendFile sets the right content-type from the extension.
const rel = String(url).replace(/^\/+/, '').replace(/^uploads\//, '');
const uploadsRoot = path.resolve(path.join(storagePath, 'uploads'));
const resolved = path.resolve(path.join(uploadsRoot, rel));
// Path containment — never serve outside the uploads dir.
if (resolved.startsWith(uploadsRoot + path.sep) && fs.existsSync(resolved)) {
// This route streams the file directly, bypassing the secureStatic
// middleware — so re-apply its SVG hardening here. An admin-uploaded
// SVG favicon could contain <script>; served at the top-level
// /favicon.ico origin without CSP that would be stored XSS. Keep in
// sync with secureStatic.js.
if (/\.svg$/i.test(resolved)) {
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:");
res.setHeader('X-Content-Type-Options', 'nosniff');
}
res.setHeader('Cache-Control', 'public, max-age=86400');
return res.sendFile(resolved);
}
}
} catch (error) {
logger.warn('Favicon lookup failed; serving bundled default', { error: error.message });
}
return res.redirect(302, '/favicon-32x32.png');
}
);
// Health check endpoint. `pid` + `uptime` let monitors (and the local E2E
// watchdog) detect a silent process restart between two checks.
app.get('/health', async (req, res) => {
+16 -1
View File
@@ -31,7 +31,22 @@ function secureStatic(basePath, options = {}) {
// Disable directory listing for security
index: false,
// Don't allow dotfiles
dotfiles: 'deny'
dotfiles: 'deny',
setHeaders: (resp, filePath) => {
// Preserve any caller-provided header logic (e.g. font caching).
if (typeof options.setHeaders === 'function') options.setHeaders(resp, filePath);
// SVGs are admin-uploadable (logos, favicon). Served from our
// own origin, a malicious SVG opened directly could run embedded
// <script>/on* handlers (stored XSS). A restrictive CSP lets the
// browser RENDER the vector but blocks all script execution —
// so we keep real SVGs (scalable) instead of rasterising them.
// `default-src 'none'` already implies script-src 'none';
// style-src + img-src(data:) keep normal SVG rendering working.
if (/\.svg$/i.test(filePath)) {
resp.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:");
resp.setHeader('X-Content-Type-Options', 'nosniff');
}
}
});
return staticMiddleware(req, res, next);
@@ -142,6 +142,10 @@ function transformProfile(p) {
taxId: p.tax_id || '',
vatLabel: p.vat_label || 'MwSt.',
vatRateDefault: p.vat_rate_default == null ? null : Number(p.vat_rate_default),
// Install-wide fallback hourly rate (migration 113), minor units.
// null = no global default; the hours page then requires a per-
// customer or per-entry rate.
defaultHourlyRateMinor: p.default_hourly_rate_minor == null ? null : Number(p.default_hourly_rate_minor),
defaultCurrency: p.default_currency || 'CHF',
defaultLocale: p.default_locale || 'de',
defaultQrFormat: p.default_qr_format || 'none',
@@ -163,11 +167,34 @@ function transformProfile(p) {
// Migration 137 — IANA timezone for the admin calendar. Null when
// the admin hasn't picked one; frontend falls back to the browser.
timezone: p.timezone || null,
// Migration 114 — per-ISO-weekday opening hours (object keyed
// "1".."7"). Stored as JSON TEXT; parse to an object for the API.
// null/blank = no hours configured.
businessHours: parseBusinessHours(p.business_hours),
// Migration 114 — master switch for the scheduled-email floor.
// Defaults true (column is NOT NULL default true).
scheduledEmailFloorEnabled: p.scheduled_email_floor_enabled == null
? true
: (p.scheduled_email_floor_enabled === true
|| p.scheduled_email_floor_enabled === 1
|| p.scheduled_email_floor_enabled === '1'),
createdAt: p.created_at,
updatedAt: p.updated_at,
};
}
/** Parse the stored business_hours JSON to an object, or null. */
function parseBusinessHours(raw) {
if (raw == null || raw === '') return null;
if (typeof raw === 'object') return raw; // pg jsonb path (column is text today)
try {
const obj = JSON.parse(raw);
return obj && typeof obj === 'object' ? obj : null;
} catch (_) {
return null;
}
}
function transformBank(b) {
if (!b) return null;
return {
@@ -346,6 +373,11 @@ router.put(
body('taxId').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
body('vatLabel').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
body('vatRateDefault').optional({ values: 'falsy' }).isFloat({ min: 0, max: 100 }),
// Migration 113 — install-wide default hourly rate, minor units.
// nullable so the admin can clear it; values: 'falsy' would drop a
// legitimate 0 (which we treat as "explicitly free"), so use the
// nullable form and let the service coerce.
body('defaultHourlyRateMinor').optional({ nullable: true }).isInt({ min: 0 }),
body('defaultCurrency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
body('defaultLocale').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
body('defaultQrFormat').optional({ values: 'falsy' }).isIn(['swiss', 'epc', 'none']),
@@ -371,6 +403,15 @@ router.put(
// "Europe/Zurich"). Free-text; backend stores up to 64 chars.
// Frontend falls back to browser Intl when this is blank.
body('timezone').optional({ values: 'falsy', nullable: true }).isString().isLength({ max: 64 }),
// Migration 114 — per-weekday opening hours. Object keyed "1".."7" or
// null to clear. Shape is validated + sanitised in the service layer
// (normaliseSchedule); here we only reject obviously-wrong types.
body('businessHours').optional({ nullable: true }).custom((v) => {
if (v === null || typeof v === 'object') return true;
throw new Error('businessHours must be an object or null');
}),
// Migration 114 — scheduled-email floor master switch.
body('scheduledEmailFloorEnabled').optional().isBoolean(),
],
handleAsync(async (req, res) => {
validateRequest(req);
@@ -393,6 +434,7 @@ router.put(
taxId: 'tax_id',
vatLabel: 'vat_label',
vatRateDefault: 'vat_rate_default',
defaultHourlyRateMinor: 'default_hourly_rate_minor',
defaultCurrency: 'default_currency',
defaultLocale: 'default_locale',
defaultQrFormat: 'default_qr_format',
@@ -408,6 +450,9 @@ router.put(
pdfQuoteShowSkonto: 'pdf_quote_show_skonto',
// Migration 137 — admin calendar timezone.
timezone: 'timezone',
// Migration 114 — business hours + scheduled-email floor switch.
businessHours: 'business_hours',
scheduledEmailFloorEnabled: 'scheduled_email_floor_enabled',
};
for (const [api, db] of Object.entries(map)) {
if (Object.prototype.hasOwnProperty.call(req.body, api)) {
+2 -2
View File
@@ -286,7 +286,7 @@ router.get(
query('status').optional().isString(),
query('customerAccountId').optional().isInt({ min: 1 }),
query('q').optional().isString(),
query('sort').optional().isIn(['newest', 'oldest', 'customer_asc']),
query('sort').optional().isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'customer_asc', 'customer_desc']),
query('page').optional().isInt({ min: 1 }),
query('pageSize').optional().isInt({ min: 1, max: 200 }),
],
@@ -302,7 +302,7 @@ router.get(
const pageSize = parseInt(req.query.pageSize, 10) || 25;
const result = await contractService.listContracts({
filters,
sort: req.query.sort || 'newest',
sort: req.query.sort || 'issue_desc',
page,
pageSize,
});
+30 -1
View File
@@ -67,6 +67,10 @@ function transformCustomer(c) {
// per-entry override on every logged block.
featureHoursLogging: c.feature_hours_logging === true || c.feature_hours_logging === 1,
hourlyRateMinor: c.hourly_rate_minor != null ? Number(c.hourly_rate_minor) : null,
// Per-customer Skonto opt-out (migration 112). When true, none of
// this customer's invoices qualify for an early-payment discount,
// regardless of template / global defaults.
skontoDisabled: c.skonto_disabled === true || c.skonto_disabled === 1,
lastLogin: c.last_login,
createdAt: c.created_at,
updatedAt: c.updated_at,
@@ -245,6 +249,17 @@ router.post('/', [
body('prefill.country_code').optional({ nullable: true }).isString().isLength({ max: 2 }),
body('prefill.country_name').optional({ nullable: true }).isString().isLength({ max: 120 }),
body('prefill.preferred_language').optional({ nullable: true }).isString().isLength({ min: 2, max: 8 }),
// At least one human-readable identifier so the record isn't a
// nameless row that's impossible to recognise in lists later.
body('prefill').custom((prefill) => {
const p = prefill || {};
const hasName = ['company_name', 'display_name', 'first_name', 'last_name']
.some((k) => typeof p[k] === 'string' && p[k].trim());
if (!hasName) {
throw new Error('At least a company name or a contact name is required');
}
return true;
}),
], handleAsync(async (req, res) => {
validateRequest(req);
const { id } = await customerAccountsService.createDirect({
@@ -380,9 +395,11 @@ router.put('/:id', [
// generated invoice to billing_cycle_day of the next period.
// Cycle day spans -15..-1 (days before month end) and 1..28
// (day of month) per migration 128 + service-layer clamp.
body('billing_cadence').optional().isIn(['per_event', 'monthly', 'quarterly']),
body('billing_cadence').optional().isIn(['per_event', 'monthly', 'quarterly', 'manual']),
body('billing_cycle_day').optional().isInt({ min: -15, max: 28 })
.withMessage('billing_cycle_day must be -15..-1 (days before month end) or 1..28 (day of month)'),
// Per-customer Skonto opt-out (migration 112).
body('skonto_disabled').optional().isBoolean(),
], handleAsync(async (req, res) => {
validateRequest(req);
const customer = await customerAccountsService.updateCustomer(
@@ -518,6 +535,18 @@ router.put('/:id/events', [
// surface.
// ---------------------------------------------------------------------
// Aggregate landing view for /admin/clients/hours — every customer with
// open (unbilled) hours + the open monetary amount. Registered before
// the /:id/hour-entries routes; the literal first segment ("hour-entries")
// can't collide with the int-validated :id pattern.
router.get('/hour-entries/unbilled-summary', [
adminAuth,
requirePermission('customers.view'),
], handleAsync(async (req, res) => {
const summary = await customerHoursService.getUnbilledSummaryByCustomer();
successResponse(res, { summary });
}));
router.get('/:id/hour-entries', [
adminAuth,
requirePermission('customers.view'),
+12 -5
View File
@@ -429,11 +429,18 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
if (r.status in invoiceCounts) invoiceCounts[r.status] = Number(r.count) || 0;
}
// Revenue windows: sum of `paid_amount_minor` for invoices
// marked PAID where paid_at falls inside the window. Using
// paid_amount (not total) so partial payments are tracked
// accurately. Stornos excluded — they're never status='paid'
// in normal flow but the guard is defensive.
// Revenue windows: sum of `paid_amount_minor` for invoices marked
// PAID whose payment date (`paid_at`) falls inside the window
// cash-basis recognition (revenue counts when the money arrives).
// Using paid_amount (not total) so partial payments are tracked
// accurately. Stornos excluded — never status='paid' in normal flow,
// the guard is defensive.
//
// `paid_at` is admin-controllable, so an old/imported invoice lands
// in the right window without any special-casing here: the mark-paid
// dialog takes an optional payment date (backdate to when the money
// actually arrived) and the historical-import route anchors paid_at
// to the invoice's issue_date.
const winSum = async (cutoff) => {
const row = await db('invoices')
.where('status', 'paid')
+120 -2
View File
@@ -1,10 +1,10 @@
const express = require('express');
const nodemailer = require('nodemailer');
const { body, validationResult } = require('express-validator');
const { body, query, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { wrapEmailHtml } = require('../services/emailProcessor');
const { wrapEmailHtml, processEmailQueue } = require('../services/emailProcessor');
const router = express.Router();
// Get email configuration
@@ -94,6 +94,16 @@ router.post('/config', [
await db('email_configs').insert(configData);
}
// Refresh the cached transporter so the new SMTP settings take effect
// immediately. Without this, a previously-initialised transporter stays
// cached (the queue processor only re-inits when it's null), so changing
// the email account had no effect until a backend restart — emails kept
// failing against the old/empty config. initializeTransporter catches its
// own errors and returns null, so this never throws; an invalid config
// simply leaves the transporter null (surfaced via the Test-email button).
const { initializeTransporter } = require('../services/emailProcessor');
await initializeTransporter(true);
// Log activity
await logActivity('email_config_updated',
{ smtp_host, from_email },
@@ -248,6 +258,114 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
}
});
// Flush the email queue now. Sends every pending email immediately,
// bypassing the business-hours floor (`scheduled_at`) — the escape hatch
// for "drain the queue before I take the server down for an update".
router.post('/flush-queue', adminAuth, requirePermission('email.send'), async (req, res) => {
try {
const summary = await processEmailQueue({ ignoreSchedule: true, limit: 1000 });
try {
await logActivity('email_queue_flushed',
{ processed: summary.processed, sent: summary.sent, failed: summary.failed },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
} catch (_) { /* activity logging is best-effort */ }
res.json({ message: 'Email queue flushed', ...summary });
} catch (error) {
console.error('Flush email queue error:', error);
res.status(500).json({ error: 'Failed to flush email queue', details: error.message });
}
});
// Read-only "Sent emails" feed — paginated view of email_queue with
// filters (status, type, recipient search, date range). email_data is
// deliberately NOT returned (it can carry attachment paths / PII); the
// list only needs the envelope + delivery state. event_id is joined to
// events so the UI can link back to the source gallery when present.
router.get('/queue', adminAuth, requirePermission('email.view'), [
query('status').optional({ values: 'falsy' }).isIn(['pending', 'sent', 'failed']),
query('emailType').optional({ values: 'falsy' }).isString().isLength({ max: 64 }),
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
query('from').optional({ values: 'falsy' }).isISO8601(),
query('to').optional({ values: 'falsy' }).isISO8601(),
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const page = req.query.page ? parseInt(req.query.page, 10) : 1;
const pageSize = req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25;
const applyFilters = (qb) => {
if (req.query.status) qb.where('email_queue.status', req.query.status);
if (req.query.emailType) qb.where('email_queue.email_type', req.query.emailType);
if (req.query.from) qb.where('email_queue.created_at', '>=', new Date(req.query.from));
if (req.query.to) qb.where('email_queue.created_at', '<=', new Date(req.query.to));
if (req.query.q) {
const term = `%${String(req.query.q).trim()}%`;
qb.where(function () {
this.where('email_queue.recipient_email', 'like', term)
.orWhere('email_queue.email_type', 'like', term);
});
}
return qb;
};
const [{ count }] = await applyFilters(db('email_queue')).count({ count: '*' });
const total = parseInt(count, 10) || 0;
const rows = await applyFilters(
db('email_queue')
.leftJoin('events', 'events.id', 'email_queue.event_id')
.select(
'email_queue.id',
'email_queue.recipient_email',
'email_queue.email_type',
'email_queue.status',
'email_queue.created_at',
'email_queue.scheduled_at',
'email_queue.sent_at',
'email_queue.error_message',
'email_queue.retry_count',
'email_queue.event_id',
'events.event_name as event_name',
'events.slug as event_slug'
)
)
.orderBy('email_queue.created_at', 'desc')
.limit(pageSize)
.offset((page - 1) * pageSize);
const items = rows.map((r) => ({
id: r.id,
recipientEmail: r.recipient_email,
emailType: r.email_type,
status: r.status,
createdAt: r.created_at,
scheduledAt: r.scheduled_at,
sentAt: r.sent_at,
errorMessage: r.error_message,
retryCount: r.retry_count,
eventId: r.event_id,
eventName: r.event_name || null,
eventSlug: r.event_slug || null,
}));
res.json({
items,
pagination: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) || 1 },
});
} catch (error) {
console.error('List email queue error:', error);
res.status(500).json({ error: 'Failed to load email queue', details: error.message });
}
});
// Helper: parse variables JSON safely
function parseVariables(template) {
try {
+32 -5
View File
@@ -240,6 +240,9 @@ const INVOICE_BODY_VALIDATORS = [
body('skontoDisabled').optional().isBoolean(),
// Inline event snapshot (migration 123). Mirrors quotes — kept
// optional because standalone invoices may not have an event yet.
// eventId links the invoice to a gallery event (FK) when created from
// the event detail page; persisted by createInvoice (event_id).
body('eventId').optional({ values: 'falsy' }).isInt({ min: 1 }),
body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('eventDate').optional({ values: 'falsy' }).isISO8601(),
body('eventTimeStart').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
@@ -319,7 +322,7 @@ router.get(
query('sourceQuoteId').optional({ values: 'falsy' }).isInt({ min: 1 }),
query('unpaidOnly').optional({ values: 'falsy' }).isBoolean(),
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'due_asc', 'due_desc', 'value_asc', 'value_desc', 'customer_asc']),
query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'due_asc', 'due_desc', 'value_asc', 'value_desc', 'customer_asc', 'customer_desc']),
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }),
],
@@ -336,7 +339,7 @@ router.get(
unpaidOnly: req.query.unpaidOnly === 'true' || req.query.unpaidOnly === true,
q: req.query.q,
},
sort: req.query.sort || 'newest',
sort: req.query.sort || 'issue_desc',
page: req.query.page ? parseInt(req.query.page, 10) : 1,
pageSize: req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25,
});
@@ -417,6 +420,8 @@ router.post(
// currency 3-letter ISO (optional, default profile/CHF)
// status 'sent' | 'paid' | 'overdue' (default 'sent')
// paidAmountMinor int (optional, for status='paid')
// paidAt ISO date (optional — the real historical payment
// date; defaults to issueDate, never import time)
// language string (optional, default 'de')
router.post(
'/import',
@@ -425,12 +430,15 @@ router.post(
[
body('customerAccountId').isInt({ min: 1 }),
body('invoiceNumber').isString().isLength({ min: 1, max: 64 }),
body('eventName').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
body('eventDate').optional({ values: 'falsy' }).isISO8601(),
body('issueDate').isISO8601(),
body('dueDate').optional({ values: 'falsy' }).isISO8601(),
body('totalAmountMinor').isInt({ min: 0 }),
body('currency').optional({ values: 'falsy' }).isString().isLength({ min: 3, max: 3 }),
body('status').optional({ values: 'falsy' }).isIn(['sent', 'paid', 'overdue']),
body('paidAmountMinor').optional({ values: 'falsy' }).isInt({ min: 0 }),
body('paidAt').optional({ values: 'falsy' }).isISO8601(),
body('language').optional({ values: 'falsy' }).isString().isLength({ max: 8 }),
],
handleAsync(async (req, res) => {
@@ -466,10 +474,24 @@ router.post(
}
const totalMinor = parseInt(req.body.totalAmountMinor, 10);
const paidMinor = parseInt(req.body.paidAmountMinor || '0', 10) || 0;
const status = req.body.status || 'sent';
// A paid import with no explicit paid amount means FULLY paid — default
// paid_amount_minor to the total. The dashboard revenue windows sum
// paid_amount_minor (not total), so a blank paid amount used to store 0
// and the paid invoice contributed nothing to revenue.
const explicitPaid = req.body.paidAmountMinor != null && String(req.body.paidAmountMinor) !== '';
const paidMinor = explicitPaid
? (parseInt(req.body.paidAmountMinor, 10) || 0)
: (status === 'paid' ? totalMinor : 0);
const issueDate = req.body.issueDate;
const dueDate = req.body.dueDate || issueDate;
// Imported docs are historical: their real send/payment dates are
// the document's own dates, NOT the moment of import. Stamping
// import-time here put year-old paid invoices inside the dashboard's
// rolling "Revenue · last 30 days" window (which keys on paid_at).
// Anchor to the historical date; let the admin override paid_at when
// they know the exact payment date.
const paidAt = req.body.paidAt || issueDate;
const currency = (req.body.currency || customer.preferred_currency || 'CHF').toUpperCase();
const language = req.body.language || customer.preferred_language || 'de';
@@ -477,7 +499,12 @@ router.post(
invoice_number: req.body.invoiceNumber,
customer_account_id: customer.id,
source_quote_id: null,
// No FK link on import — the event may predate picpeak. Store the
// free-text snapshot only, mirroring createInvoice's event_name /
// event_date columns (migration 107).
event_id: null,
event_name: req.body.eventName || null,
event_date: req.body.eventDate || null,
language,
currency,
issue_date: issueDate,
@@ -488,14 +515,14 @@ router.post(
installment_trigger: null,
status,
scheduled_send_at: null,
sent_at: status !== 'scheduled' ? new Date() : null,
sent_at: new Date(issueDate),
net_amount_minor: totalMinor, // imported docs lack a breakdown
vat_rate: 0, // VAT info lives in the imported PDF
vat_amount_minor: 0,
shipping_amount_minor: 0,
total_amount_minor: totalMinor,
paid_amount_minor: paidMinor,
paid_at: status === 'paid' ? new Date() : null,
paid_at: status === 'paid' ? new Date(paidAt) : null,
// Store the path RELATIVE to STORAGE_PATH so the value survives
// a host migration (Docker volume remount on a new host with a
// different absolute path).
+21 -2
View File
@@ -105,6 +105,7 @@ function transformQuote(q) {
responseLockedAt: q.response_locked_at,
acceptedAt: q.accepted_at,
declinedAt: q.declined_at,
declineReason: q.decline_reason ?? null,
convertedEventId: q.converted_event_id,
// Migration 130 lineage. Null until quoteService.createFromQuote
// sets it. Surfaced so QuoteDetailPage can render a "Linked
@@ -257,7 +258,7 @@ router.get(
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 255 }),
query('from').optional({ values: 'falsy' }).isISO8601(),
query('to').optional({ values: 'falsy' }).isISO8601(),
query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'customer_asc', 'value_asc', 'value_desc']),
query('sort').optional({ values: 'falsy' }).isIn(['newest', 'oldest', 'issue_asc', 'issue_desc', 'customer_asc', 'customer_desc', 'value_asc', 'value_desc']),
query('page').optional({ values: 'falsy' }).isInt({ min: 1 }),
query('pageSize').optional({ values: 'falsy' }).isInt({ min: 1, max: 100 }),
],
@@ -272,7 +273,7 @@ router.get(
customerAccountId: req.query.customerAccountId ? parseInt(req.query.customerAccountId, 10) : null,
from: req.query.from, to: req.query.to, q: req.query.q,
},
sort: req.query.sort || 'newest',
sort: req.query.sort || 'issue_desc',
page: req.query.page ? parseInt(req.query.page, 10) : 1,
pageSize: req.query.pageSize ? parseInt(req.query.pageSize, 10) : 25,
});
@@ -450,6 +451,24 @@ router.post(
})
);
// Admin "decline on behalf" — flips a draft/sent/expired quote to
// `declined` without the customer's public link. For "they said no by
// phone" workflows. Optional free-text reason persisted on the row.
router.post(
'/:id/decline',
requirePermission('quotes.manage'),
[
param('id').isInt({ min: 1 }),
body('reason').optional({ values: 'falsy' }).isString().isLength({ max: 5000 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
const id = parseInt(req.params.id, 10);
const result = await quoteService.adminDeclineQuote(id, req.admin.id, req.body.reason);
return successResponse(res, result, 200, 'Quote declined');
})
);
router.post(
'/:id/convert',
requirePermission('quotes.manage'),
+51 -9
View File
@@ -73,10 +73,11 @@ const faviconStorage = multer.diskStorage({
const faviconUpload = multer({
storage: faviconStorage,
limits: { fileSize: 1 * 1024 * 1024 }, // 1MB
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB — roomy enough for a 512×512+ square PNG
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'];
const name = file.originalname.toLowerCase();
// For ICO files, we can't use the standard validateFileType
if (file.mimetype === 'image/png') {
if (validateFileType(file.originalname, file.mimetype, ['image/png'])) {
@@ -84,12 +85,16 @@ const faviconUpload = multer({
} else {
cb(new Error('Invalid PNG file'));
}
} else if (allowedMimeTypes.includes(file.mimetype) &&
(file.originalname.toLowerCase().endsWith('.ico') ||
file.originalname.toLowerCase().endsWith('.png'))) {
} else if (file.mimetype === 'image/svg+xml' && name.endsWith('.svg')) {
// SVG favicons are supported by modern browsers and are crisp at any
// size. Served SVGs are CSP-locked (no script execution) by the
// secureStatic middleware, so an admin-uploaded SVG is render-only.
cb(null, true);
} else if (allowedMimeTypes.includes(file.mimetype) &&
(name.endsWith('.ico') || name.endsWith('.png'))) {
cb(null, true);
} else {
cb(new Error('Favicon must be PNG or ICO format'));
cb(new Error('Favicon must be PNG, ICO, or SVG format'));
}
}
});
@@ -544,9 +549,16 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
return res.status(400).json({ error: 'No logo file uploaded' });
}
// ?variant=dark stores a separate dark-mode logo (branding_logo_*_dark);
// anything else is the default (light) logo. Consumers pick the dark
// variant when the active theme is dark, falling back to the light one.
const isDark = req.query.variant === 'dark' || req.body.variant === 'dark';
const pathKey = isDark ? 'branding_logo_path_dark' : 'branding_logo_path';
const urlKey = isDark ? 'branding_logo_url_dark' : 'branding_logo_url';
// Get old logo to delete
const oldLogoSetting = await db('app_settings')
.where('setting_key', 'branding_logo_path')
.where('setting_key', pathKey)
.first();
if (oldLogoSetting && oldLogoSetting.setting_value) {
@@ -568,7 +580,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
await db('app_settings')
.insert({
setting_key: 'branding_logo_path',
setting_key: pathKey,
setting_value: JSON.stringify(logoPath),
setting_type: 'branding',
updated_at: new Date()
@@ -582,7 +594,7 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
// Save public URL
await db('app_settings')
.insert({
setting_key: 'branding_logo_url',
setting_key: urlKey,
setting_value: JSON.stringify(publicPath),
setting_type: 'branding',
updated_at: new Date()
@@ -603,6 +615,36 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl
}
});
// Remove a logo. ?variant=dark clears the dark-mode logo
// (branding_logo_*_dark); otherwise the default logo. Best-effort file
// unlink, then blanks the url + path settings.
router.delete('/logo', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const isDark = req.query.variant === 'dark';
const pathKey = isDark ? 'branding_logo_path_dark' : 'branding_logo_path';
const urlKey = isDark ? 'branding_logo_url_dark' : 'branding_logo_url';
const pathSetting = await db('app_settings').where('setting_key', pathKey).first();
if (pathSetting && pathSetting.setting_value) {
try {
let p = pathSetting.setting_value;
if (p.startsWith('"')) p = JSON.parse(p);
await fs.unlink(p);
} catch (error) {
console.error('Failed to delete logo file:', error);
}
}
await db('app_settings')
.whereIn('setting_key', [pathKey, urlKey])
.update({ setting_value: JSON.stringify(''), updated_at: new Date() });
res.json({ message: 'Logo removed' });
} catch (error) {
console.error('Logo delete error:', error);
res.status(500).json({ error: 'Failed to remove logo' });
}
});
// Upload watermark logo
router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.edit'), upload.single('watermarkLogo'), async (req, res) => {
try {
+78
View File
@@ -23,6 +23,7 @@ const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { verifyDocumentArtefacts } = require('../services/backupIntegrityService');
const { getCoverageReport } = require('../services/backupCoverageService');
const { db } = require('../database/db');
const router = express.Router();
@@ -84,4 +85,81 @@ router.get(
}),
);
/**
* GET /api/admin/system-health/failures
*
* Surfaces background failures that would otherwise go unnoticed. v1
* covers stuck/failed outbound emails: rows the queue processor has
* given up on (status='failed') or exhausted its retries on
* (status='pending' AND retry_count >= 3 the processor only picks up
* retry_count < 3). Trigger: a 14h window where 'quote_sent' template
* errors left invoices unsent with no admin-visible signal.
*/
router.get(
'/failures',
requirePermission('settings.view'),
handleAsync(async (req, res) => {
const stuckEmails = await db('email_queue')
.where(function () {
this.where('status', 'failed')
.orWhere(function () {
this.where('status', 'pending').andWhere('retry_count', '>=', 3);
});
})
.orderBy('created_at', 'desc')
.limit(200)
.select('id', 'recipient_email', 'email_type', 'status', 'retry_count', 'error_message', 'created_at');
return successResponse(res, {
stuckEmails: stuckEmails.map((r) => ({
id: r.id,
recipientEmail: r.recipient_email,
emailType: r.email_type,
status: r.status,
retryCount: r.retry_count,
errorMessage: r.error_message,
createdAt: r.created_at,
})),
counts: { stuckEmails: stuckEmails.length },
});
}),
);
/**
* POST /failures/email/:id/retry re-queue a stuck email (status back to
* pending, retry_count reset, error cleared, scheduled_at cleared so the
* 60s processor picks it up on its next pass).
*/
router.post(
'/failures/email/:id/retry',
requirePermission('settings.edit'),
handleAsync(async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
const updated = await db('email_queue').where({ id }).update({
status: 'pending',
retry_count: 0,
error_message: null,
scheduled_at: null,
});
if (!updated) return res.status(404).json({ error: 'Email not found' });
return successResponse(res, { retried: true });
}),
);
/**
* DELETE /failures/email/:id dismiss a stuck email (remove the row so
* it stops surfacing). Use when the failure is understood + won't be sent.
*/
router.delete(
'/failures/email/:id',
requirePermission('settings.edit'),
handleAsync(async (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id <= 0) return res.status(400).json({ error: 'Invalid id' });
await db('email_queue').where({ id }).del();
return successResponse(res, { dismissed: true });
}),
);
module.exports = router;
+20 -1
View File
@@ -24,6 +24,15 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout
const { validateFileType } = require('../utils/fileSecurityUtils');
const contractService = require('../services/contractService');
const { getAppSetting } = require('../utils/appSettings');
// Normalise a Settings → Branding logo value (absolute URL, /-rooted path,
// or bare `uploads/...` filename) into a URL the public page can load.
function normalizeBrandingLogoUrl(raw) {
const value = (raw && String(raw).trim()) || null;
if (!value) return null;
if (value.startsWith('/') || /^https?:\/\//i.test(value)) return value;
return `/uploads/${value.replace(/^uploads\//, '')}`;
}
const { clientIpForAudit } = require('../utils/clientIp');
const { loadActionToken, preMulterTokenGuard } = require('../utils/publicTokenGuards');
const { db } = require('../database/db');
@@ -69,7 +78,7 @@ const signedPdfUpload = multer({
* The IP / signature image paths are NEVER exposed publicly even after
* signing they're audit evidence.
*/
function publicContractView(contract, inclusions, customer, profile, locale) {
function publicContractView(contract, inclusions, customer, profile, locale, brandingLogoUrl, brandingLogoUrlDark) {
const orderedSections = ['basics', 'scope', 'privacy', 'commercial', 'nda', 'closing'];
const blocksBySection = {};
for (const s of orderedSections) blocksBySection[s] = [];
@@ -143,6 +152,12 @@ function publicContractView(contract, inclusions, customer, profile, locale) {
city: profile.city,
email: profile.email,
website: profile.website,
// Light + dark branding logos (Settings → Branding), so the public
// sign page renders the logo that reads in its resolved colour mode.
// Mirrors publicQuotes — the print-only business_profile.logo_path is
// intentionally NOT used here.
logoUrl: normalizeBrandingLogoUrl(brandingLogoUrl),
logoUrlDark: normalizeBrandingLogoUrl(brandingLogoUrlDark),
} : null,
};
}
@@ -168,12 +183,16 @@ router.get(
// re-enforces both, so client tampering only changes the UX.
const allowPdfUpload = (await getAppSetting('crm_contracts_allow_pdf_upload')) !== false;
const requireDrawnSignature = (await getAppSetting('crm_contracts_require_drawn_signature')) === true;
const brandingLogoUrl = await getAppSetting('branding_logo_url', null);
const brandingLogoUrlDark = await getAppSetting('branding_logo_url_dark', null);
const view = publicContractView(
data.contract,
data.inclusions,
customer,
profile,
data.contract.language || 'de',
brandingLogoUrl,
brandingLogoUrlDark,
);
view.allowPdfUpload = allowPdfUpload;
view.requireDrawnSignature = requireDrawnSignature;
+10 -6
View File
@@ -50,16 +50,20 @@ router.get(
const { getAppSetting } = require('../utils/appSettings');
const profile = await db('business_profile').where({ id: 1 }).first();
const brandingLogoUrl = await getAppSetting('branding_logo_url', null);
const brandingLogoUrlDark = await getAppSetting('branding_logo_url_dark', null);
const toUrl = (raw) => {
const value = (raw && String(raw).trim()) || null;
if (!value) return null;
if (value.startsWith('/') || /^https?:\/\//i.test(value)) return value;
return `/uploads/${value.replace(/^uploads\//, '')}`;
};
const issuer = profile ? {
companyName: profile.company_name || '',
email: profile.email || '',
website: profile.website || '',
logoUrl: (() => {
const raw = (brandingLogoUrl && String(brandingLogoUrl).trim()) || null;
if (!raw) return null;
if (raw.startsWith('/') || /^https?:\/\//i.test(raw)) return raw;
return `/uploads/${raw.replace(/^uploads\//, '')}`;
})(),
// Light + dark branding logos — the page picks per its colour mode.
logoUrl: toUrl(brandingLogoUrl),
logoUrlDark: toUrl(brandingLogoUrlDark),
} : null;
return successResponse(res, { invoice: view, issuer });
+17 -9
View File
@@ -23,6 +23,15 @@ const { loadActionToken } = require('../utils/publicTokenGuards');
const router = express.Router();
// Normalise a Settings → Branding logo value (absolute URL, /-rooted path,
// or bare `uploads/...` filename) into a URL the public page can load.
function normalizeBrandingLogoUrl(raw) {
const value = (raw && String(raw).trim()) || null;
if (!value) return null;
if (value.startsWith('/') || /^https?:\/\//i.test(value)) return value;
return `/uploads/${value.replace(/^uploads\//, '')}`;
}
// Rate-limit: 30 token previews per IP per minute, 10 responses.
const previewLimiter = rateLimit({
windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false,
@@ -31,7 +40,7 @@ const respondLimiter = rateLimit({
windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false,
});
function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl) {
function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl, brandingLogoUrlDark) {
return {
quoteNumber: quote.quote_number,
status: quote.status,
@@ -98,13 +107,11 @@ function publicQuoteView(quote, lineItems, customer, profile, tosRequired, tosTe
// there). On the web page the existing site branding already
// serves both light + dark modes correctly, so falling back
// to a PDF-only image would override that with a light
// version that doesn't read in dark mode.
logoUrl: (() => {
const raw = (brandingLogoUrl && String(brandingLogoUrl).trim()) || null;
if (!raw) return null;
if (raw.startsWith('/') || /^https?:\/\//i.test(raw)) return raw;
return `/uploads/${raw.replace(/^uploads\//, '')}`;
})(),
// version that doesn't read in dark mode. Both light + dark
// branding URLs are surfaced so the page can pick the one that
// matches its resolved colour mode (see usePublicDarkMode).
logoUrl: normalizeBrandingLogoUrl(brandingLogoUrl),
logoUrlDark: normalizeBrandingLogoUrl(brandingLogoUrlDark),
} : null,
};
}
@@ -137,9 +144,10 @@ router.get(
// — admins typically upload one logo via Settings → Branding and
// expect it to flow through the customer-facing pages too.
const brandingLogoUrl = await getAppSetting('branding_logo_url', null);
const brandingLogoUrlDark = await getAppSetting('branding_logo_url_dark', null);
return successResponse(res, {
quote: publicQuoteView(data.quote, data.lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl),
quote: publicQuoteView(data.quote, data.lineItems, customer, profile, tosRequired, tosText, tosUrl, brandingLogoUrl, brandingLogoUrlDark),
});
})
);
+1
View File
@@ -59,6 +59,7 @@ router.get('/', async (req, res) => {
branding_watermark_size: settingsObject.branding_watermark_size || 15,
branding_favicon_url: settingsObject.branding_favicon_url || '',
branding_logo_url: settingsObject.branding_logo_url || '',
branding_logo_url_dark: settingsObject.branding_logo_url_dark || '',
branding_logo_size: settingsObject.branding_logo_size || 'medium',
branding_logo_max_height: settingsObject.branding_logo_max_height || 48,
branding_logo_position: settingsObject.branding_logo_position || 'left',
@@ -17,6 +17,7 @@ const { db, withRetry } = require('../database/db');
const logger = require('../utils/logger');
const { AppError } = require('../utils/errors');
const { formatBoolean } = require('../utils/dbCompat');
const { normaliseSchedule } = require('../utils/businessHours');
const ALLOWED_PROFILE_FIELDS = [
'company_name',
@@ -41,6 +42,10 @@ const ALLOWED_PROFILE_FIELDS = [
'tax_id',
'vat_label',
'vat_rate_default',
// Install-wide fallback hourly rate (migration 113), minor units.
// Last link in the hour-entry rate chain after the per-entry
// override and the per-customer default.
'default_hourly_rate_minor',
'default_currency',
'default_locale',
'default_qr_format',
@@ -75,6 +80,11 @@ const ALLOWED_PROFILE_FIELDS = [
// by the calendar UI to render timed blocks in the operator's
// working tz. Admin-only; never exposed via publicSettings.
'timezone',
// Per-ISO-weekday opening hours (migration 114). JSON TEXT; drives the
// scheduled-email floor and is interpreted in `timezone`.
'business_hours',
// Master switch for the scheduled-email business-hours floor (mig 114).
'scheduled_email_floor_enabled',
];
const ALLOWED_BANK_FIELDS = [
@@ -161,6 +171,32 @@ function sanitiseProfilePayload(payload) {
? Math.max(24, Math.min(200, n))
: 56;
}
// Install-wide default hourly rate (minor units). Empty / null clears
// it back to "no global default"; otherwise coerce to a non-negative
// integer so a stray decimal can't land sub-cent values in the column.
if (updates.default_hourly_rate_minor !== undefined) {
if (updates.default_hourly_rate_minor === null || updates.default_hourly_rate_minor === '') {
updates.default_hourly_rate_minor = null;
} else {
const n = parseInt(updates.default_hourly_rate_minor, 10);
updates.default_hourly_rate_minor = Number.isFinite(n) && n >= 0 ? n : null;
}
}
// Per-weekday opening hours. Accept the API object (or a JSON string),
// run it through the shared validator (drops bad blocks, sorts, fills
// all 7 days), and persist the canonical JSON string. An explicit
// null / '' clears the schedule back to "no hours configured".
if (updates.business_hours !== undefined) {
if (updates.business_hours === null || updates.business_hours === '') {
updates.business_hours = null;
} else {
updates.business_hours = JSON.stringify(normaliseSchedule(updates.business_hours));
}
}
if (updates.scheduled_email_floor_enabled !== undefined) {
updates.scheduled_email_floor_enabled = formatBoolean(Boolean(updates.scheduled_email_floor_enabled));
}
return updates;
}
+12 -1
View File
@@ -621,7 +621,7 @@ async function buildRenderContext(contract, inclusions) {
// Public API
// ---------------------------------------------------------------------
async function listContracts({ filters = {}, sort = 'newest', page = 1, pageSize = 25 } = {}) {
async function listContracts({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) {
return await withRetry(async () => {
let query = db('contracts')
.leftJoin('customer_accounts', 'contracts.customer_account_id', 'customer_accounts.id')
@@ -658,11 +658,22 @@ async function listContracts({ filters = {}, sort = 'newest', page = 1, pageSize
case 'oldest':
query = query.orderBy('contracts.created_at', 'asc').orderBy('contracts.id', 'asc');
break;
case 'issue_asc':
query = query.orderBy('contracts.issue_date', 'asc').orderBy('contracts.id', 'asc');
break;
case 'issue_desc':
query = query.orderBy('contracts.issue_date', 'desc').orderBy('contracts.id', 'desc');
break;
case 'customer_asc':
query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
.orderBy('contracts.id', 'desc');
break;
case 'customer_desc':
query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
.orderBy('contracts.id', 'desc');
break;
case 'newest':
default:
query = query.orderBy('contracts.created_at', 'desc').orderBy('contracts.id', 'desc');
@@ -555,6 +555,9 @@ async function updateCustomer(id, updates, updatedByAdminId) {
// Hour-logging default rate (migration 129). Minor units; null
// means admin must enter a per-entry override on every entry.
'hourly_rate_minor',
// Per-customer Skonto opt-out (migration 112). Boolean, coerced
// via formatBoolean below for SQLite compatibility.
'skonto_disabled',
];
for (const f of fields) {
if (updates[f] !== undefined) {
@@ -567,6 +570,7 @@ async function updateCustomer(id, updates, updatedByAdminId) {
} else if (
f === 'feature_calendar' || f === 'feature_quotes'
|| f === 'feature_bills' || f === 'feature_hours_logging'
|| f === 'skonto_disabled'
) {
allowed[f] = formatBoolean(updates[f]);
} else if (f === 'hourly_rate_minor') {
+128 -16
View File
@@ -25,6 +25,7 @@
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { AppError } = require('../utils/errors');
const { hasColumnCached } = require('../utils/schemaCache');
const logger = require('../utils/logger');
const invoiceService = require('./invoiceService');
@@ -50,24 +51,52 @@ function computeDurationMinutes(start, end) {
}
/**
* Resolve the rate this entry should bill at. Override on the entry
* wins; otherwise we fall back to the customer's default rate. If
* neither is set we throw saves can't go through without a rate.
* Resolve the rate this entry should bill at. Resolution chain:
* 1. per-entry override
* 2. per-customer default rate
* 3. install-wide default rate (business_profile, migration 113)
* Only when all three are unset do we throw the hours UI surfaces a
* "set a rate" CTA off the back of HOURLY_RATE_REQUIRED rather than a
* raw error. `installDefaultMinor` is loaded once per request by the
* caller (see getInstallDefaultRateMinor) and passed in so this stays
* a pure function.
*/
function resolveEffectiveRate(entry, customer) {
function resolveEffectiveRate(entry, customer, installDefaultMinor = null) {
if (entry.hourly_rate_minor_override != null) {
return Number(entry.hourly_rate_minor_override);
}
if (customer.hourly_rate_minor != null) {
return Number(customer.hourly_rate_minor);
}
if (installDefaultMinor != null) {
return Number(installDefaultMinor);
}
throw new AppError(
'No hourly rate: set a per-entry override or a customer default.',
'No hourly rate: set a per-entry override, a customer default, or an install-wide default rate.',
400,
'HOURLY_RATE_REQUIRED',
);
}
/**
* Read the install-wide default hourly rate (minor units) off the
* singleton business_profile row. Returns null when unset OR when the
* column doesn't exist yet (pre-migration-113 install) callers then
* fall through to the HOURLY_RATE_REQUIRED path. Accepts an optional
* transaction so it joins the caller's atomic unit.
*/
async function getInstallDefaultRateMinor(trx) {
const conn = trx || db;
if (!(await hasColumnCached('business_profile', 'default_hourly_rate_minor'))) {
return null;
}
const row = await conn('business_profile').where({ id: 1 })
.first('default_hourly_rate_minor');
return row && row.default_hourly_rate_minor != null
? Number(row.default_hourly_rate_minor)
: null;
}
/**
* Decide whether an entry is still editable. Pure function callers
* pass the loaded entry + (optionally) its current invoice row.
@@ -181,9 +210,14 @@ async function createEntry(customerId, payload, adminId) {
}
const description = payload.description ? String(payload.description).slice(0, 1000) : null;
// Install-wide fallback rate (migration 113) — the last link in the
// resolution chain. Loaded once and reused for the pre-validate and
// the accumulator append below.
const installDefaultMinor = await getInstallDefaultRateMinor();
// Pre-validate the rate resolves to something — fail before insert
// if neither override nor customer default is set.
resolveEffectiveRate({ hourly_rate_minor_override: override }, customer);
// if neither override, customer default, nor install default is set.
resolveEffectiveRate({ hourly_rate_minor_override: override }, customer, installDefaultMinor);
return await db.transaction(async (trx) => {
const row = {
@@ -202,10 +236,12 @@ async function createEntry(customerId, payload, adminId) {
const inserted = await trx('customer_hour_entries').insert(row).returning('id');
const entryId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
// Monthly-mode customers get the auto-append treatment.
if (customer.billing_cadence === 'monthly') {
// Accumulator-mode customers (monthly + manual) get the auto-append
// treatment — the entry lands on the running draft instead of staying
// unbilled. Manual differs only in that its draft never auto-flushes.
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
const fullEntry = { ...row, id: entryId };
const rate = resolveEffectiveRate(fullEntry, customer);
const rate = resolveEffectiveRate(fullEntry, customer, installDefaultMinor);
const lineItem = buildLineItemFromEntry(fullEntry, rate);
const { invoiceId, lineItemId } = await invoiceService.appendOneLineItemToMonthlyDraft(
customer, lineItem, adminId, trx,
@@ -286,7 +322,8 @@ async function updateEntry(entryId, payload, adminId) {
// Recompute the linked line item if the entry is billed (on a
// draft — the lock check above already proved it's mutable).
if (entry.invoice_id && entry.invoice_line_item_id) {
const rate = resolveEffectiveRate(next, customer);
const installDefaultMinor = await getInstallDefaultRateMinor(trx);
const rate = resolveEffectiveRate(next, customer, installDefaultMinor);
const newLineItem = buildLineItemFromEntry(next, rate);
await trx('invoice_line_items').where({ id: entry.invoice_line_item_id }).update({
description: newLineItem.description,
@@ -387,15 +424,16 @@ async function deleteEntry(entryId, adminId) {
/**
* Per-event flow: mint a standalone invoice from all unbilled entries
* for this customer, one line per entry. Refuses when the customer is
* monthly-mode (those entries auto-billed on save, so there should be
* no unbilled rows). Returns the new invoice id.
* in an accumulator mode (monthly / manual) those entries auto-billed
* onto the running draft on save, so there should be no unbilled rows.
* Returns the new invoice id.
*/
async function billUnbilledEntries(customerId, adminId) {
const customer = await db('customer_accounts').where({ id: customerId }).first();
if (!customer) throw new AppError('Customer not found', 404);
if (customer.billing_cadence === 'monthly') {
if (customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual') {
throw new AppError(
'Monthly-mode customers auto-append entries to the running draft; "Bill these hours" is for per-event customers.',
'Accumulator-mode customers (monthly / manual) auto-append entries to the running draft; "Bill these hours" is for per-event customers.',
409,
'CADENCE_MISMATCH',
);
@@ -409,8 +447,9 @@ async function billUnbilledEntries(customerId, adminId) {
throw new AppError('No unbilled entries to bill', 409, 'NO_UNBILLED');
}
const installDefaultMinor = await getInstallDefaultRateMinor(trx);
const lineItems = unbilled.map((entry, idx) => {
const rate = resolveEffectiveRate(entry, customer);
const rate = resolveEffectiveRate(entry, customer, installDefaultMinor);
const li = buildLineItemFromEntry(entry, rate);
return { ...li, position: idx + 1 };
});
@@ -456,12 +495,85 @@ async function billUnbilledEntries(customerId, adminId) {
});
}
/**
* Landing aggregate for /admin/clients/hours: one row per customer that
* currently carries unbilled hour entries, with the open hours + open
* monetary amount. In practice only per-event customers surface here
* monthly/manual cadences auto-append each entry onto the running draft
* at save time (status flips straight to 'billed'), so they never leave
* unbilled rows behind. Each entry's amount resolves through the usual
* override customer-rate install-default chain; if an entry has no
* resolvable rate it still counts toward hours/entries but the row is
* flagged rateResolvable=false so the UI can prompt for a rate rather
* than silently undercounting. Sorted by open amount desc.
*/
async function getUnbilledSummaryByCustomer() {
const installDefaultMinor = await getInstallDefaultRateMinor();
const rows = await db('customer_hour_entries as h')
.join('customer_accounts as c', 'h.customer_account_id', 'c.id')
.where('h.status', 'unbilled')
.select(
'h.customer_account_id',
'h.duration_minutes',
'h.hourly_rate_minor_override',
'c.hourly_rate_minor as customer_hourly_rate_minor',
'c.company_name',
'c.display_name',
'c.first_name',
'c.last_name',
'c.email',
'c.password_hash',
'c.billing_cadence',
);
const byCustomer = new Map();
for (const r of rows) {
let agg = byCustomer.get(r.customer_account_id);
if (!agg) {
agg = {
customerAccountId: r.customer_account_id,
companyName: r.company_name || null,
displayName: r.display_name || null,
firstName: r.first_name || null,
lastName: r.last_name || null,
email: r.email || null,
// passive = no portal password set, same rule as the customer
// list / picker (adminCustomers transform).
isPassive: r.password_hash == null,
billingCadence: r.billing_cadence || null,
entryCount: 0,
totalMinutes: 0,
openAmountMinor: 0,
rateResolvable: true,
};
byCustomer.set(r.customer_account_id, agg);
}
agg.entryCount += 1;
const minutes = Number(r.duration_minutes || 0);
agg.totalMinutes += minutes;
let rateMinor = null;
if (r.hourly_rate_minor_override != null) rateMinor = Number(r.hourly_rate_minor_override);
else if (r.customer_hourly_rate_minor != null) rateMinor = Number(r.customer_hourly_rate_minor);
else if (installDefaultMinor != null) rateMinor = installDefaultMinor;
if (rateMinor == null) {
agg.rateResolvable = false;
} else {
agg.openAmountMinor += Math.round((minutes / 60) * rateMinor);
}
}
return Array.from(byCustomer.values())
.sort((a, b) => b.openAmountMinor - a.openAmountMinor);
}
module.exports = {
listEntries,
getUnbilledSummaryByCustomer,
createEntry,
updateEntry,
deleteEntry,
billUnbilledEntries,
getInstallDefaultRateMinor,
_internal: {
computeDurationMinutes,
resolveEffectiveRate,
+124 -21
View File
@@ -2,6 +2,11 @@ const nodemailer = require('nodemailer');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const {
snapToBusinessHours,
normaliseSchedule,
} = require('../utils/businessHours');
const { hasColumnCached } = require('../utils/schemaCache');
let transporter = null;
let lastConfigHash = null;
@@ -735,10 +740,22 @@ async function sendTemplateEmail(to, templateKey, variables) {
}
}
// Process email queue
async function processEmailQueue() {
// Process email queue.
//
// Options:
// ignoreSchedule when true, send every pending email regardless of its
// `scheduled_at` floor (used by the admin "send now" flush
// before maintenance/updates). The scheduled interval run
// leaves it false so future-dated emails keep waiting.
// limit max emails per pass. The flush raises this to drain the
// whole queue in a single pass (no re-query, so a failing
// email isn't retried in a tight loop within one flush).
//
// Returns { processed, sent, failed }.
async function processEmailQueue({ ignoreSchedule = false, limit = 10 } = {}) {
logger.info('Email queue processor: Checking for pending emails...');
const result = { processed: 0, sent: 0, failed: 0 };
try {
// Try to initialize transporter if it's null (in case it failed at startup)
if (!transporter) {
@@ -746,37 +763,46 @@ async function processEmailQueue() {
transporter = await initializeTransporter();
if (!transporter) {
logger.warn('Email transporter could not be initialized, skipping queue processing');
return;
return result;
}
}
let pendingEmails = [];
try {
// Pick up emails that are pending AND either have no `scheduled_at`
// or whose scheduled_at is in the past. Used by CRM invoices to
// queue split-payment emails relative to the event date.
const now = new Date();
pendingEmails = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '<', 3)
.andWhere(function() {
const query = db('email_queue')
.where('status', 'pending');
if (!ignoreSchedule) {
// Automatic runs: respect the retry cap (don't hammer a failing
// address) AND the schedule (business-hours floor / future send).
query.where('retry_count', '<', 3).andWhere(function() {
this.whereNull('scheduled_at').orWhere('scheduled_at', '<=', now);
})
});
}
// A manual "send now" (ignoreSchedule) deliberately bypasses BOTH the
// schedule and the retry cap: the admin is forcing a retry, typically
// right after fixing SMTP. Without this, emails that failed 3× during
// an SMTP outage are stuck "pending" forever with no way to resend.
pendingEmails = await query
.orderBy('scheduled_at', 'asc')
.orderBy('created_at', 'asc')
.limit(10);
.limit(limit);
} catch (dbError) {
logger.error('Failed to query email queue:', dbError);
return;
return result;
}
if (pendingEmails.length === 0) {
logger.info('Email queue processor: No pending emails found');
return;
return result;
}
logger.info(`Processing ${pendingEmails.length} emails from queue`);
result.processed = pendingEmails.length;
for (const email of pendingEmails) {
try {
const emailData = typeof email.email_data === 'string'
@@ -796,9 +822,11 @@ async function processEmailQueue() {
status: 'sent',
sent_at: new Date()
});
result.sent += 1;
logger.info(`Email ${email.id} sent successfully`);
} catch (error) {
result.failed += 1;
// Increment retry count
try {
await db('email_queue')
@@ -825,12 +853,66 @@ async function processEmailQueue() {
} catch (error) {
logger.error('Error processing email queue:', error);
}
return result;
}
// Load + normalise the business-hours config used by queueEmail. The
// definition lives on the singleton business_profile row (migration 114):
// business_hours JSON, per-ISO-weekday opening blocks
// scheduled_email_floor_enabled master on/off switch
// timezone IANA zone the blocks are read in
// Any failure (column missing on a half-migrated install, bad data) or an
// unconfigured schedule degrades to `enabled: false` so a queued email is
// never lost — it just sends at its original time.
async function getScheduledEmailConfig() {
try {
if (!(await hasColumnCached('business_profile', 'business_hours'))) {
return { enabled: false };
}
const hasToggle = await hasColumnCached('business_profile', 'scheduled_email_floor_enabled');
const cols = ['business_hours', 'timezone'];
if (hasToggle) cols.push('scheduled_email_floor_enabled');
const profile = await db('business_profile').where({ id: 1 }).first(cols);
if (!profile) return { enabled: false };
const enabled = hasToggle
? (profile.scheduled_email_floor_enabled === true
|| profile.scheduled_email_floor_enabled === 1
|| profile.scheduled_email_floor_enabled === '1')
: true;
if (!enabled) return { enabled: false };
const schedule = normaliseSchedule(profile.business_hours);
let timezone = (profile.timezone || '').trim();
if (!timezone) timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
// Reject a bogus tz before it reaches Intl in the snap helper.
try {
new Intl.DateTimeFormat('en-US', { timeZone: timezone });
} catch (_) {
timezone = 'UTC';
}
return { enabled: true, timezone, schedule };
} catch (err) {
logger.warn(`Business-hours config unavailable, skipping email floor: ${err.message}`);
return { enabled: false };
}
}
// Queue an email for sending. Optionally takes a 5th `options` arg:
// options.scheduledAt — Date | ISO string; row only picks up once
// this moment has passed (used by CRM split-
// payment invoices). NULL = send immediately.
// options.respectBusinessHours — when true, snap the send time to the
// next open business-hours block (from "now").
// Use for automated/relationship mail (dunning
// reminders, gallery-expiry warnings) so we don't
// ping customers overnight. No-op when the floor
// is off / business hours unconfigured / already
// inside a block. Leave it off for transactional
// + admin-initiated mail so those send instantly.
// Attachments + cc travel inside `emailData` (keys: attachments, cc)
// so callers don't need a new signature for every email shape.
async function queueEmail(eventId, recipientEmail, emailType, emailData, options = {}) {
@@ -846,15 +928,36 @@ async function queueEmail(eventId, recipientEmail, emailType, emailData, options
retry_count: 0,
created_at: new Date(),
};
if (options.scheduledAt) {
row.scheduled_at = options.scheduledAt instanceof Date
? options.scheduledAt
: new Date(options.scheduledAt);
let snappedFrom = null;
// Base time to schedule from:
// - explicit options.scheduledAt (CRM split-payment invoices), OR
// - "now" when the caller opts into the business-hours floor via
// options.respectBusinessHours — automated / relationship mail
// like dunning reminders + gallery-expiry warnings, so we don't
// ping the customer at 02:00.
// Both snap to the next open business-hours block. No-op when the
// floor is disabled, business hours are unconfigured, or the instant
// already lands inside a block. Transactional / admin-initiated mail
// (invoice_sent, storno, invitations, password resets) passes neither
// option and sends immediately.
const baseTime = options.scheduledAt
? (options.scheduledAt instanceof Date ? options.scheduledAt : new Date(options.scheduledAt))
: (options.respectBusinessHours ? new Date() : null);
if (baseTime) {
const cfg = await getScheduledEmailConfig();
const snapped = snapToBusinessHours(baseTime, cfg);
if (snapped.getTime() !== baseTime.getTime()) snappedFrom = baseTime;
// Persist a future scheduled_at for an explicit scheduledAt always;
// for the respectBusinessHours floor only when it actually moved the
// time forward (inside hours → leave null → processor sends at once).
if (options.scheduledAt || snappedFrom) row.scheduled_at = snapped;
}
await db('email_queue').insert(row);
logger.info(`Email queued: ${emailType} to ${recipientEmail}${
options.scheduledAt ? ` (scheduled ${row.scheduled_at.toISOString()})` : ''
row.scheduled_at ? ` (scheduled ${row.scheduled_at.toISOString()}${
snappedFrom ? `, floored from ${snappedFrom.toISOString()}` : ''
})` : ''
}`);
} catch (error) {
logger.error('Error queueing email:', error);
+2 -1
View File
@@ -85,7 +85,8 @@ async function queueExpirationWarning(event) {
expiry_date: event.expires_at,
gallery_link: shareUrl,
gallery_password: '{{password_security_message}}'
});
// Relationship mail — hold to business hours (no-op unless configured).
}, { respectBusinessHours: true });
logger.info(`Queued expiration warning for event ${event.slug}`);
}
+159 -33
View File
@@ -104,6 +104,65 @@ function computeDueDate(scheduledSendAt, netDays = 30) {
return new Date(scheduledSendAt.getTime() + ensureInt(netDays) * 24 * 60 * 60 * 1000);
}
/**
* Resolve the net-days a new invoice's due date should be anchored to.
* Single source of truth so the editor (split picker), legacy callers,
* and quoteinvoice conversion all land on the same number. Priority:
*
* 1. `payload.netDays` explicit caller override (installment spawn
* passes the snapshot's net_days here).
* 2. Split picker (migration 124): payment_net_days_templates.net_days
* via `payload.paymentNetDaysTemplateId`. This is what the bill
* editor actually sends; the old code only read the legacy FK and
* so silently ignored Net 60 / 90 selections.
* 3. Legacy single FK: payment_term_templates.net_days via
* `payload.paymentTermTemplateId`.
* 4. The `crm_payment_default_net_days` setting (admin-configured).
* 5. 30 historical hard default.
*/
async function resolveNetDays(payload, trx = db) {
if (payload && payload.netDays != null && payload.netDays !== '') {
const n = ensureInt(payload.netDays);
if (n) return n;
}
if (payload && payload.paymentNetDaysTemplateId) {
const probe = await trx('payment_net_days_templates')
.where({ id: payload.paymentNetDaysTemplateId })
.select('net_days')
.first();
if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30;
}
if (payload && payload.paymentTermTemplateId) {
const probe = await trx('payment_term_templates')
.where({ id: payload.paymentTermTemplateId })
.select('net_days')
.first();
if (probe && probe.net_days != null) return ensureInt(probe.net_days) || 30;
}
const setting = ensureInt(await getAppSetting('crm_payment_default_net_days'));
if (setting) return setting;
return 30;
}
/**
* Net-days for an already-persisted invoice row (no payload). Reads the
* snapshot's net_days, then the crm_payment_default_net_days setting,
* then 30. Used at send time to re-anchor the due date when the issue
* date is stamped. Mirrors resolveNetDays' tail.
*/
async function resolveNetDaysForRow(invoice) {
const snap = typeof invoice.payment_term_snapshot === 'string'
? (() => { try { return JSON.parse(invoice.payment_term_snapshot); } catch { return null; } })()
: invoice.payment_term_snapshot;
if (snap && snap.net_days != null) {
const n = ensureInt(snap.net_days);
if (n) return n;
}
const setting = ensureInt(await getAppSetting('crm_payment_default_net_days'));
if (setting) return setting;
return 30;
}
/**
* Resolve the deal_uuid for a new invoice row (migration 140). Priority:
*
@@ -234,6 +293,13 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
const today = new Date();
today.setHours(0, 0, 0, 0);
// Manual cadence has no billing cycle: the draft accumulates
// indefinitely and ships ONLY via the admin "Trigger invoice now"
// gesture, so it carries NO period_end. The scheduler's auto-flush
// filter is `monthly_period_end <= today`, which a NULL period_end
// can never satisfy — keeping manual drafts out of the cron path.
const isManual = customer.billing_cadence === 'manual';
// Resolve period_end: prefer the cadence in the current month, but
// if it has already passed, roll to next month so the new draft
// gathers items toward the NEXT bill.
@@ -243,8 +309,11 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
const nextMonth = today.getMonth() + 1;
target = computeMonthlyCadenceDate(today.getFullYear(), nextMonth, cycleDay);
}
const periodStart = new Date(target.getFullYear(), target.getMonth(), 1);
const periodEnd = target;
const periodStart = isManual ? null : new Date(target.getFullYear(), target.getMonth(), 1);
const periodEnd = isManual ? null : target;
// Placeholder issue/due date for the empty draft row — recomputed at
// issuance time. Manual drafts have no period_end, so fall back to today.
const placeholderDate = (periodEnd || today).toISOString().slice(0, 10);
// Look up any existing open draft for this customer. We deliberately
// do NOT filter by monthly_period_end here — only one draft can be
@@ -282,8 +351,8 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
event_id: null,
language,
currency,
issue_date: periodEnd.toISOString().slice(0, 10),
due_date: periodEnd.toISOString().slice(0, 10), // recomputed at issuance time
issue_date: placeholderDate,
due_date: placeholderDate, // recomputed at issuance time
installment_index: 0,
installment_total: 1,
status: 'scheduled',
@@ -296,8 +365,8 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
business_bank_account_id: bank?.id || null,
qr_format: null,
is_monthly_draft: true,
monthly_period_start: periodStart.toISOString().slice(0, 10),
monthly_period_end: periodEnd.toISOString().slice(0, 10),
monthly_period_start: periodStart ? periodStart.toISOString().slice(0, 10) : null,
monthly_period_end: periodEnd ? periodEnd.toISOString().slice(0, 10) : null,
// Migration 140 — each monthly-draft cycle is its own deal (no
// quote/contract chain). Fresh UUID at creation; subsequent line
// appends just mutate this same row, so the uuid sticks.
@@ -341,7 +410,7 @@ async function getOrCreateMonthlyDraft(customer, adminId, trx) {
// Public API
// ---------------------------------------------------------------------
async function listInvoices({ filters = {}, sort = 'newest', page = 1, pageSize = 25 } = {}) {
async function listInvoices({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) {
return await withRetry(async () => {
let query = db('invoices')
.leftJoin('customer_accounts', 'invoices.customer_account_id', 'customer_accounts.id')
@@ -408,6 +477,8 @@ async function listInvoices({ filters = {}, sort = 'newest', page = 1, pageSize
// reflects when the row landed in the DB. id is the tiebreaker
// for rows that share a created_at second.
case 'oldest': query = query.orderBy('invoices.created_at', 'asc').orderBy('invoices.id', 'asc'); break;
case 'issue_asc': query = query.orderBy('invoices.issue_date', 'asc').orderBy('invoices.id', 'asc'); break;
case 'issue_desc': query = query.orderBy('invoices.issue_date', 'desc').orderBy('invoices.id', 'desc'); break;
case 'due_asc': query = query.orderBy('invoices.due_date', 'asc'); break;
case 'due_desc': query = query.orderBy('invoices.due_date', 'desc'); break;
case 'value_asc': query = query.orderBy('invoices.total_amount_minor', 'asc'); break;
@@ -417,6 +488,11 @@ async function listInvoices({ filters = {}, sort = 'newest', page = 1, pageSize
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
.orderBy('invoices.id', 'desc');
break;
case 'customer_desc':
query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
.orderBy('invoices.id', 'desc');
break;
case 'newest':
default:
query = query.orderBy('invoices.created_at', 'desc').orderBy('invoices.id', 'desc');
@@ -618,15 +694,19 @@ async function createInvoice(payload, adminId, trx = db) {
const customer = await trx('customer_accounts').where({ id: payload.customerAccountId }).first();
ensureCustomerCanBill(customer);
// Monthly-billing intercept (migration 128). For customers in
// billing_cadence='monthly' mode every createInvoice call APPENDS
// line items onto the running monthly-draft instead of minting a
// Accumulator intercept (migration 128). For customers in
// billing_cadence='monthly' OR 'manual' mode every createInvoice call
// APPENDS line items onto a single running draft instead of minting a
// fresh invoice. Admin sees the editor flow exactly as before; the
// returned id is the draft's id so the UI can redirect to the
// accumulator. `_skipMonthlyRouting` is the escape hatch used by
// internal helpers that need to mint a non-draft row (e.g. the
// accumulator itself, or future test fixtures).
if (customer.billing_cadence === 'monthly' && !payload._skipMonthlyRouting) {
// accumulator. The two modes differ only in WHEN the draft ships:
// 'monthly' auto-flushes on the cadence day (scheduler), 'manual'
// never auto-flushes (no period_end) and ships only via the admin
// "Trigger invoice now" gesture. `_skipMonthlyRouting` is the escape
// hatch used by internal helpers that need to mint a non-draft row
// (e.g. the accumulator itself, or future test fixtures).
if ((customer.billing_cadence === 'monthly' || customer.billing_cadence === 'manual')
&& !payload._skipMonthlyRouting) {
const draft = await appendToMonthlyDraft(payload, customer, adminId, trx);
return { invoiceIds: draft?.id ? [draft.id] : [] };
}
@@ -642,18 +722,13 @@ async function createInvoice(payload, adminId, trx = db) {
// used `invoiceNumber` here.
const issueDate = payload.issueDate || new Date().toISOString().slice(0, 10);
const scheduledSendAt = payload.scheduledSendAt ? new Date(payload.scheduledSendAt) : null;
// Resolve the selected payment-term template's net_days BEFORE
// computing the due date so Net 60 / 90 templates actually push
// the due date out. Falls back to 30 when no template is set
// (matches the historical default).
let resolvedNetDays = 30;
if (payload.paymentTermTemplateId) {
const probe = await trx('payment_term_templates')
.where({ id: payload.paymentTermTemplateId })
.select('net_days')
.first();
if (probe && probe.net_days != null) resolvedNetDays = ensureInt(probe.net_days) || 30;
}
// Resolve net_days BEFORE computing the due date so Net 60 / 90
// selections actually push the due date out. resolveNetDays honors
// the split picker FK the editor sends, the legacy single FK, and
// the crm_payment_default_net_days setting (see helper). The clock
// starts on the SEND date when the invoice is scheduled, otherwise
// the issue date — so a future send pushes the due date out too.
const resolvedNetDays = await resolveNetDays(payload, trx);
const dueDate = payload.dueDate || computeDueDate(scheduledSendAt || new Date(issueDate), resolvedNetDays)
.toISOString().slice(0, 10);
@@ -927,10 +1002,13 @@ async function spawnInstallmentInvoices({ trx, eventId, quoteId, customer, curre
}
// netDays drives the due-date offset on every scheduled invoice
// created here. Defaults to 30 when the caller doesn't pass one;
// callers in quoteService now pass the converting quote's
// payment-term net_days so Net 60 / 90 templates flow through.
const resolvedNetDays = ensureInt(netDays) || 30;
// created here. Callers in quoteService pass the converting quote's
// payment-term net_days so Net 60 / 90 templates flow through; when
// absent we fall back to the crm_payment_default_net_days setting
// (then 30) rather than silently using 30, matching createInvoice.
const resolvedNetDays = ensureInt(netDays)
|| ensureInt(await getAppSetting('crm_payment_default_net_days'))
|| 30;
const total = installments.length;
const acceptanceTime = new Date();
const invoiceIds = [];
@@ -1633,8 +1711,10 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
// but still printed the discount row on the PDF. Zero out both
// fields here so pdfService.drawPaymentBlock's
// `paymentTerm?.skontoPercent && paymentTerm?.skontoWithinDays`
// guard suppresses the row.
if (invoice.skonto_disabled) {
// guard suppresses the row. The per-customer opt-out (migration 112)
// is honoured here too — a customer flagged skonto_disabled never
// prints the discount row, mirroring resolveSkontoPercentForInvoice.
if (invoice.skonto_disabled || customer?.skonto_disabled) {
paymentTerm.skontoPercent = null;
paymentTerm.skontoWithinDays = null;
}
@@ -1866,6 +1946,39 @@ async function sendInvoice(id, adminId) {
invoice.language = customer.preferred_language;
}
// Stamp the issue date at the moment the invoice actually goes out.
// A scheduled invoice's issue_date is provisional — set to the
// authoring day at creation — but the legal issue date is when it
// ships. Anchoring it here keeps the printed invoice date, the Skonto
// window (a relative "pay within N working days" counted from that
// date) and the net-days due date all consistent with the send date.
// Only on the first send (status 'scheduled'); 'sent' / 'overdue'
// rows are immutable legal records and keep their stamped date.
if (invoice.status === 'scheduled') {
const sendDateIso = new Date().toISOString().slice(0, 10);
const netDays = await resolveNetDaysForRow(invoice);
// Re-anchor the due date too, but only when it was machine-set: if
// the stored due_date still equals the auto formula off the OLD
// base (scheduled_send_at, else the old issue_date), the admin never
// hand-edited it and we slide it to the new issue date. A divergent
// value means a manual override (the editor's "Override due date"
// toggle) — leave it untouched.
const oldBase = invoice.scheduled_send_at
? new Date(invoice.scheduled_send_at)
: new Date(invoice.issue_date);
const oldAutoDue = computeDueDate(oldBase, netDays).toISOString().slice(0, 10);
const storedDue = invoice.due_date
? new Date(invoice.due_date).toISOString().slice(0, 10)
: null;
const updates = { issue_date: sendDateIso, updated_at: new Date() };
if (storedDue && storedDue === oldAutoDue) {
updates.due_date = computeDueDate(new Date(sendDateIso), netDays).toISOString().slice(0, 10);
}
await db('invoices').where({ id }).update(updates);
invoice.issue_date = updates.issue_date;
if (updates.due_date) invoice.due_date = updates.due_date;
}
const ctx = await buildInvoiceRenderContext(invoice, lineItems);
const buffer = await pdfService.renderInvoiceToBuffer(ctx);
@@ -2537,7 +2650,9 @@ async function applyReminder(invoice, lineItems, level, adminId) {
contentPath: pdfPath,
contentType: 'application/pdf',
}],
});
// Dunning reminders are relationship mail — hold to business hours so
// the customer isn't pinged overnight (no-op unless hours configured).
}, { respectBusinessHours: true });
try {
await logActivity('invoice_reminder_sent', { invoiceId: invoice.id, level, lateFeeMinor },
@@ -2577,6 +2692,17 @@ async function resolveSkontoPercentForInvoice(invoice) {
// installments that shouldn't qualify for the discount even when
// the global default offers it.
if (invoice.skonto_disabled) return null;
// Per-customer opt-out (migration 112) — a customer that negotiated
// "no Skonto" as a contract term never qualifies, so the admin
// doesn't have to tick the per-invoice toggle on every invoice.
// Falls through customer → invoice → snapshot → quote → global.
if (invoice.customer_account_id) {
const cust = await db('customer_accounts')
.where({ id: invoice.customer_account_id })
.select('skonto_disabled')
.first();
if (cust && cust.skonto_disabled) return null;
}
const parseSnap = (raw) => {
if (!raw) return null;
if (typeof raw === 'object') return raw;
+77 -1
View File
@@ -39,6 +39,7 @@ const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
const pdfService = require('./pdfService');
const emailProcessor = require('./emailProcessor');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const { hasColumnCached } = require('../utils/schemaCache');
const fs = require('fs');
const path = require('path');
@@ -333,7 +334,7 @@ function ensureCustomerFeatureEnabled(customer, feature) {
* Filters: { status[], customerAccountId, from, to, q }
* Sort: 'newest' | 'oldest' | 'customer_asc' | 'value_asc' | 'value_desc'
*/
async function listQuotes({ filters = {}, sort = 'newest', page = 1, pageSize = 25 } = {}) {
async function listQuotes({ filters = {}, sort = 'issue_desc', page = 1, pageSize = 25 } = {}) {
return await withRetry(async () => {
let query = db('quotes')
.leftJoin('customer_accounts', 'quotes.customer_account_id', 'customer_accounts.id')
@@ -385,11 +386,22 @@ async function listQuotes({ filters = {}, sort = 'newest', page = 1, pageSize =
case 'oldest':
query = query.orderBy('quotes.created_at', 'asc').orderBy('quotes.id', 'asc');
break;
case 'issue_asc':
query = query.orderBy('quotes.issue_date', 'asc').orderBy('quotes.id', 'asc');
break;
case 'issue_desc':
query = query.orderBy('quotes.issue_date', 'desc').orderBy('quotes.id', 'desc');
break;
case 'customer_asc':
query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) asc')
.orderBy('quotes.id', 'desc');
break;
case 'customer_desc':
query = query
.orderByRaw('COALESCE(customer_accounts.company_name, customer_accounts.last_name, customer_accounts.email) desc')
.orderBy('quotes.id', 'desc');
break;
case 'value_asc':
query = query.orderBy('quotes.total_amount_minor', 'asc');
break;
@@ -1162,6 +1174,69 @@ async function adminAcceptQuote(id, adminId) {
return { status: 'accepted', lockedAt: responseLockedAt };
}
/**
* Admin "decline on behalf of customer" records the quote as
* `declined` directly, bypassing the public token + response window.
* Used when the customer says no by phone/email and the admin wants the
* pipeline reflected without asking them to click the decline link.
*
* Mirrors adminAcceptQuote's guards: refuses quotes that are already
* terminal (`accepted`, `declined`, `converted`) those would overwrite
* history. Allowed from `draft` / `sent` / `expired`.
*
* `reason` is optional free text persisted to `quotes.decline_reason`
* (migration 115) and surfaced on the quote detail page.
*
* Any outstanding accept/decline tokens are invalidated so the customer
* can't flip the quote back to accepted via a still-live emailed link.
*/
async function adminDeclineQuote(id, adminId, reason = null) {
const quote = await db('quotes').where({ id }).first();
if (!quote) throw new AppError('Quote not found', 404);
if (quote.status === 'declined') {
throw new AppError('Quote already declined', 409, 'QUOTE_ALREADY_DECLINED');
}
if (quote.status === 'accepted') {
throw new AppError('Quote already accepted; duplicate it to start a fresh round.', 409, 'QUOTE_ALREADY_ACCEPTED');
}
if (quote.status === 'converted') {
throw new AppError('Quote already converted to an event/invoice', 409, 'QUOTE_CONVERTED');
}
const now = new Date();
const cleanReason = typeof reason === 'string' && reason.trim() ? reason.trim().slice(0, 5000) : null;
const hasReasonColumn = await hasColumnCached('quotes', 'decline_reason');
await db.transaction(async (trx) => {
const updates = {
status: 'declined',
responded_at: quote.responded_at || now,
// Close the public response window immediately so a customer link
// can't toggle the quote afterwards (recordResponse rejects once
// now > response_locked_at).
response_locked_at: now,
declined_at: now,
accepted_at: null,
updated_at: now,
};
if (hasReasonColumn) updates.decline_reason = cleanReason;
await trx('quotes').where({ id }).update(updates);
// Burn any unused tokens for this quote — defense in depth alongside
// the closed response window above.
await trx('quote_action_tokens')
.where({ quote_id: id })
.whereNull('used_at')
.update({ used_at: now, used_action: 'declined' });
});
try {
await logActivity('quote_declined_by_admin', { quoteId: id, reason: cleanReason }, null, `admin:${adminId}`);
} catch (_) {}
return { status: 'declined', declinedAt: now };
}
/**
* Convert an accepted quote to an event + scheduled invoices.
* Wraps everything in a transaction so a half-finished conversion
@@ -1775,6 +1850,7 @@ module.exports = {
duplicateQuote,
recordResponse,
adminAcceptQuote,
adminDeclineQuote,
convertToEvent,
convertToInvoiceOnly,
+237
View File
@@ -0,0 +1,237 @@
/**
* Business hours + scheduled-email floor (migration 114).
*
* Pure, dependency-free time math. picpeak doesn't pull in a date library,
* so timezone handling leans on Intl.DateTimeFormat, which every supported
* Node build ships with full IANA data for.
*
* The schedule is per-ISO-weekday with any number of opening blocks, so a
* day can carry a lunch break (e.g. 09:0012:00 + 13:0018:00) or differ
* from its neighbours the Google-business-hours model. Shape:
*
* { "1": [{ start: "09:00", end: "12:00" }, { start: "13:00", end: "18:00" }],
* "2": [...], ..., "6": [], "7": [] }
*
* ISO weekday numbering throughout: 1=Mon 7=Sun. A weekday with no
* blocks is closed.
*
* Snap rule (nearest upcoming block-open; confirmed with the maintainer):
* - floor disabled / empty schedule unchanged
* - instant falls inside any block unchanged
* - instant before a later block same day that block's open
* (covers before-first-open AND lunch gaps)
* - otherwise first block of the next
* open day, at its open
*/
/**
* Wall-clock components of `date` as observed in IANA zone `tz`.
* Returns { y, mo, d, hh, mi, ss } with mo 1-12, hh 0-23.
*/
function getZonedParts(date, tz) {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const map = {};
for (const part of dtf.formatToParts(date)) {
if (part.type !== 'literal') map[part.type] = part.value;
}
let hh = parseInt(map.hour, 10);
// Some engines render midnight as "24" under hour12:false; normalise.
if (hh === 24) hh = 0;
return {
y: parseInt(map.year, 10),
mo: parseInt(map.month, 10),
d: parseInt(map.day, 10),
hh,
mi: parseInt(map.minute, 10),
ss: parseInt(map.second, 10) || 0,
};
}
/** ISO weekday (1=Mon … 7=Sun) for a plain calendar date. */
function isoWeekday(y, mo, d) {
const dow = new Date(Date.UTC(y, mo - 1, d)).getUTCDay(); // 0=Sun … 6=Sat
return dow === 0 ? 7 : dow;
}
/** Offset (ms) between zone `tz` wall-clock and UTC at instant `utcMs`. */
function tzOffsetMs(utcMs, tz) {
const p = getZonedParts(new Date(utcMs), tz);
const asUtc = Date.UTC(p.y, p.mo - 1, p.d, p.hh, p.mi, p.ss);
return asUtc - utcMs;
}
/**
* Convert a wall-clock time (interpreted in zone `tz`) to a UTC instant.
* Two-pass offset resolution so it stays correct across DST boundaries.
*/
function zonedWallClockToUtc(y, mo, d, hh, mi, tz) {
const naiveUtc = Date.UTC(y, mo - 1, d, hh, mi, 0);
let result = naiveUtc - tzOffsetMs(naiveUtc, tz);
result = naiveUtc - tzOffsetMs(result, tz);
return new Date(result);
}
/** "HH:MM" → minutes from midnight, or null when malformed. */
function parseHHMM(value) {
const m = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(String(value || ''));
if (!m) return null;
return parseInt(m[1], 10) * 60 + parseInt(m[2], 10);
}
/** minutes from midnight → "HH:MM" (24h, zero-padded). */
function minutesToHHMM(minutes) {
const hh = Math.floor(minutes / 60);
const mi = minutes % 60;
return `${String(hh).padStart(2, '0')}:${String(mi).padStart(2, '0')}`;
}
/** Add `n` calendar days to a {y,mo,d}, returning the same shape. */
function addDays(y, mo, d, n) {
const dt = new Date(Date.UTC(y, mo - 1, d + n));
return { y: dt.getUTCFullYear(), mo: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
}
/**
* Validate + clean a raw weekly schedule into the canonical storage shape.
*
* Accepts the JSON object (or a JSON string), keyed by ISO weekday. Each
* day's value is an array of blocks; a block may be {start,end} or a
* [start,end] pair. Invalid blocks (bad HH:MM, end<=start) are dropped;
* blocks are sorted by start. Days are emitted as string keys "1".."7"
* with an array value (possibly empty = closed).
*
* Returns { "1": [{start,end}], ..., "7": [] } never throws.
*/
function normaliseSchedule(raw) {
let obj = raw;
if (typeof raw === 'string') {
try { obj = JSON.parse(raw); } catch (_) { obj = null; }
}
const out = {};
for (let iso = 1; iso <= 7; iso += 1) out[String(iso)] = [];
if (!obj || typeof obj !== 'object') return out;
for (let iso = 1; iso <= 7; iso += 1) {
const dayRaw = obj[String(iso)] !== undefined ? obj[String(iso)] : obj[iso];
if (!Array.isArray(dayRaw)) continue;
const blocks = [];
for (const b of dayRaw) {
let startStr;
let endStr;
if (Array.isArray(b)) {
[startStr, endStr] = b;
} else if (b && typeof b === 'object') {
startStr = b.start;
endStr = b.end;
}
const startMin = parseHHMM(startStr);
const endMin = parseHHMM(endStr);
if (startMin == null || endMin == null || endMin <= startMin) continue;
blocks.push({ start: minutesToHHMM(startMin), end: minutesToHHMM(endMin), startMin, endMin });
}
blocks.sort((a, b) => a.startMin - b.startMin);
out[String(iso)] = blocks.map((b) => ({ start: b.start, end: b.end }));
}
return out;
}
/** True when at least one weekday carries at least one opening block. */
function hasAnyBlocks(schedule) {
if (!schedule || typeof schedule !== 'object') return false;
for (let iso = 1; iso <= 7; iso += 1) {
const day = schedule[String(iso)];
if (Array.isArray(day) && day.length > 0) return true;
}
return false;
}
/** Blocks for an ISO weekday as sorted {startMin,endMin}, parsed fresh. */
function blocksForDay(schedule, iso) {
const day = schedule[String(iso)];
if (!Array.isArray(day)) return [];
const out = [];
for (const b of day) {
const startMin = parseHHMM(b && b.start);
const endMin = parseHHMM(b && b.end);
if (startMin == null || endMin == null || endMin <= startMin) continue;
out.push({ startMin, endMin });
}
out.sort((a, b) => a.startMin - b.startMin);
return out;
}
/**
* Snap a Date to the configured business-hours window.
*
* @param {Date} date the requested send instant
* @param {Object} cfg
* @param {boolean} cfg.enabled
* @param {string} cfg.timezone IANA zone (resolved by caller; never "")
* @param {Object} cfg.schedule per-ISO-weekday blocks (see module docs)
* @returns {Date} the (possibly unchanged) send instant
*/
function snapToBusinessHours(date, cfg) {
if (!cfg || !cfg.enabled) return date;
if (!(date instanceof Date) || Number.isNaN(date.getTime())) return date;
const { timezone, schedule } = cfg;
if (!hasAnyBlocks(schedule)) return date; // nothing to snap to
const p = getZonedParts(date, timezone);
const iso = isoWeekday(p.y, p.mo, p.d);
const tMin = p.hh * 60 + p.mi;
const today = blocksForDay(schedule, iso);
for (const block of today) {
// Inside an open block → leave the instant untouched.
if (tMin >= block.startMin && tMin < block.endMin) return date;
}
// Before a later block today (covers before-first-open AND lunch gaps):
// snap up to the nearest block whose open is still ahead.
for (const block of today) {
if (tMin < block.startMin) {
return zonedWallClockToUtc(
p.y, p.mo, p.d, Math.floor(block.startMin / 60), block.startMin % 60, timezone
);
}
}
// After the last block today, or a closed day: walk forward to the first
// open block of the next open day. Bounded at 14 days as a safety stop;
// hasAnyBlocks above guarantees the loop terminates well within that.
let cur = { y: p.y, mo: p.mo, d: p.d };
for (let i = 1; i <= 14; i += 1) {
cur = addDays(cur.y, cur.mo, cur.d, 1);
const dayBlocks = blocksForDay(schedule, isoWeekday(cur.y, cur.mo, cur.d));
if (dayBlocks.length > 0) {
const open = dayBlocks[0].startMin;
return zonedWallClockToUtc(
cur.y, cur.mo, cur.d, Math.floor(open / 60), open % 60, timezone
);
}
}
return date;
}
module.exports = {
snapToBusinessHours,
parseHHMM,
minutesToHHMM,
normaliseSchedule,
hasAnyBlocks,
_internal: {
getZonedParts,
isoWeekday,
zonedWallClockToUtc,
addDays,
blocksForDay,
},
};
+13 -4
View File
@@ -41,6 +41,12 @@ const { getStoragePath } = require('../config/storage');
const { getAppSetting } = require('./appSettings');
const logger = require('./logger');
// Bump this whenever the SVG→PNG rendering environment changes in a way that
// changes output, to invalidate previously-cached rasterisations.
// v2 (2026-06): backend image now ships fonts (fontconfig + brand fonts),
// so SVG logos with live <text> render their text instead of tofu boxes.
const RASTER_VERSION = 'v2-fonts';
const SUPPORTED_EXT = /\.(png|jpe?g)$/i;
// Formats PDFKit can't embed directly but `sharp` can rasterise into
// PNG for us. We transparently convert + cache.
@@ -96,11 +102,14 @@ async function rasteriseToPng(sourcePath, storageRoot) {
const stat = fs.statSync(sourcePath);
const cacheDir = path.join(storageRoot, 'cache', 'logo-png');
fs.mkdirSync(cacheDir, { recursive: true });
// Content-addressed cache: sha1(src path + mtime ns + size).
// Including mtime means re-uploading the source invalidates the
// cache entry naturally.
// Content-addressed cache: sha1(renderer version + src path + mtime ns
// + size). Including mtime means re-uploading the source invalidates the
// cache entry naturally. RASTER_VERSION is bumped whenever the rendering
// environment changes in a way that affects output (e.g. installing fonts
// so SVG <text> stops rendering as tofu) — bumping it invalidates every
// previously-cached PNG without having to clear the cache dir by hand.
const key = crypto.createHash('sha1')
.update(`${sourcePath}|${stat.mtimeMs}|${stat.size}`)
.update(`${RASTER_VERSION}|${sourcePath}|${stat.mtimeMs}|${stat.size}`)
.digest('hex');
const cachedPath = path.join(cacheDir, `${key}.png`);
if (fs.existsSync(cachedPath)) {
+9 -1
View File
@@ -2,7 +2,15 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<!-- Point the favicon at the backend's dynamic /favicon.ico route so the
admin-configured branding favicon is used from the very first paint,
in every browser. CRITICAL: a hardcoded href here (e.g. the bundled
/favicon-32x32.png) makes the browser use THAT and never request
/favicon.ico — Safari then shows the default and ignores the JS that
DynamicFavicon uses to swap it. No type/sizes so the byte stream
(png/ico/svg) is honoured via the response content-type. -->
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<!--
Static fallback title + Open Graph defaults (#521).
+34
View File
@@ -184,6 +184,40 @@ server {
proxy_set_header X-Forwarded-Proto $real_proto;
}
# Dynamic favicon / apple-touch-icon served by backend (resolves the
# admin-configured branding favicon, falls back to the bundled asset).
# Exact-match (=) wins over the static-asset regex below, so these reach
# the backend instead of the build dir. Browsers (especially Safari)
# request these at the site root regardless of any JS-injected
# <link rel="icon">.
location = /favicon.ico {
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000/favicon.ico;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_proto;
}
location = /apple-touch-icon.png {
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000/apple-touch-icon.png;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_proto;
}
location = /apple-touch-icon-precomposed.png {
set $backend_upstream backend;
proxy_pass http://$backend_upstream:3000/apple-touch-icon-precomposed.png;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $real_proto;
}
# Delegate root requests to backend for public landing page handling
location = / {
# Use variable to force DNS resolution per request (required for Docker Swarm)
+2
View File
@@ -21,6 +21,7 @@ import {
ArchivesPage,
AnalyticsPage,
SettingsPage,
SystemHealthPage,
UserManagementPage,
CustomerManagementPage,
CustomerDetailPage,
@@ -267,6 +268,7 @@ function App() {
<Route path="customers/:id" element={<RedirectCustomerDetail />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="system-health" element={<SystemHealthPage />} />
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
{/* Old top-level routes these surfaces now live as
@@ -35,7 +35,11 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const currentLanguage = SUPPORTED_LANGUAGES.find(lang => lang.code === i18n.language) || SUPPORTED_LANGUAGES[0];
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = brandingSettings?.branding_logo_url?.trim();
// Dark-mode logo variant. Symmetric fallback: if only one logo is set,
// use it for both modes (dark → dark||light, light → light||dark).
const lightLogo = brandingSettings?.branding_logo_url?.trim();
const darkLogo = brandingSettings?.branding_logo_url_dark?.trim();
const logoUrl = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
const logoDisplayMode = brandingSettings?.branding_logo_display_mode || 'logo_and_text';
// Logo placement honours the same Branding > Logo Position setting
// the gallery does. 'sidepanel' moves the logo into the AdminSidebar
@@ -1,6 +1,5 @@
import React, { useState } from 'react';
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'react-toastify';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
@@ -10,6 +9,7 @@ import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../..
import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
type AdminFeedbackResponse = {
feedback: PhotoFeedback[];
@@ -38,6 +38,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
const [showCategoryMenu, setShowCategoryMenu] = useState(false);
const [expandedComments, setExpandedComments] = useState(false);
const queryClient = useQueryClient();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const currentPhoto = photos[currentIndex];
const isVideo = currentPhoto
@@ -312,7 +313,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
Uploaded
</span>
<p className="text-white">
{format(new Date(currentPhoto.uploaded_at), 'MMM d, yyyy h:mm a')}
{fmtDateTime(currentPhoto.uploaded_at)}
</p>
</div>
@@ -408,7 +409,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
{comment.guest_name || 'Anonymous'}
</p>
<p className="text-xs text-neutral-400">
{format(new Date(comment.created_at), 'MMM d, yyyy h:mm a')}
{fmtDateTime(comment.created_at)}
</p>
</div>
@@ -6,6 +6,7 @@ import {
Archive,
BarChart3,
Settings,
Activity,
X,
Users,
Briefcase,
@@ -17,6 +18,7 @@ import { useTranslation } from 'react-i18next';
import { settingsService } from '../../services/settings.service';
import { VersionInfo } from './VersionInfo';
import { usePermissions } from '../../contexts/PermissionsContext';
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { buildResourceUrl } from '../../utils/url';
@@ -62,6 +64,7 @@ const navigation: NavItem[] = [
{ nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' },
{ nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
{ nameKey: 'navigation.systemHealth', href: '/admin/system-health', icon: Activity, permission: 'settings.view' },
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view', featureFlag: 'userManagement' },
// Clients section (#354 follow-up) — admin-side surface for the
// CRM-area sub-features. Today this entry leads to /admin/clients
@@ -104,8 +107,12 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, col
// chosen, the logo replaces the "PicPeak Admin" text in the brand
// row, and the favicon takes over in the collapsed icon rail.
const { data: publicSettings } = usePublicSettings();
const { isDark } = useAdminDarkMode();
const logoInSidebar = publicSettings?.branding_logo_position === 'sidepanel';
const rawLogoUrl = publicSettings?.branding_logo_url?.trim();
// Theme-aware logo with symmetric fallback (one logo serves both modes).
const lightLogo = publicSettings?.branding_logo_url?.trim();
const darkLogo = publicSettings?.branding_logo_url_dark?.trim();
const rawLogoUrl = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
const rawFaviconUrl = publicSettings?.branding_favicon_url?.trim();
const resolvedLogoUrl = rawLogoUrl
? (rawLogoUrl.startsWith('http') ? rawLogoUrl : buildResourceUrl(rawLogoUrl))
@@ -8,7 +8,6 @@ import {
CheckCircle,
User
} from 'lucide-react';
import { parseISO } from 'date-fns';
import { toast } from 'react-toastify';
import { Card, Loading, Button } from '../common';
@@ -30,7 +29,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
maxItems = 5
}) => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { formatDateTime } = useLocalizedDate();
const queryClient = useQueryClient();
const [showAll, setShowAll] = useState(false);
@@ -116,12 +115,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
</span>
<span className="text-neutral-500 dark:text-neutral-400"></span>
<span className="text-neutral-500 dark:text-neutral-400">
{format(
typeof item.created_at === 'string'
? parseISO(item.created_at)
: new Date(item.created_at),
'MMM d, h:mm a'
)}
{formatDateTime(item.created_at)}
</span>
</div>
<p className="mt-1 text-sm text-neutral-700">{item.comment_text || item.comment}</p>
@@ -8,6 +8,7 @@ interface GalleryPreviewBranding {
company_name?: string;
company_tagline?: string;
logo_url?: string;
logo_url_dark?: string;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
logo_position?: 'left' | 'center' | 'right';
}
@@ -82,10 +83,13 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
const showText = displayMode === 'text_only' || displayMode === 'logo_and_text';
const brandName = branding?.company_name?.trim() || 'Your Studio';
const brandTagline = branding?.company_tagline?.trim() || '';
const resolvedLogoUrl = showLogo && branding?.logo_url
? (branding.logo_url.startsWith('http')
? branding.logo_url
: buildResourceUrl(branding.logo_url))
// Theme-aware logo with symmetric fallback — mirror the live surfaces
// so the preview reflects what the gallery will actually show.
const previewLogo = theme.colorMode === 'dark'
? (branding?.logo_url_dark || branding?.logo_url)
: (branding?.logo_url || branding?.logo_url_dark);
const resolvedLogoUrl = showLogo && previewLogo
? (previewLogo.startsWith('http') ? previewLogo : buildResourceUrl(previewLogo))
: null;
const logoPosition = branding?.logo_position || 'left';
const brandFlexClass = logoPosition === 'center'
+113 -47
View File
@@ -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>
);
};
+5
View File
@@ -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">
+54
View File
@@ -0,0 +1,54 @@
/**
* Country codes offered in the customer country picker. Stored value is
* always the ISO 3166-1 alpha-2 code; Liechtenstein is `LI` (NOT the
* colloquial `FL` plate code) so it matches ISO + the PDF renderer's
* lookup. Display names are derived at runtime from `Intl.DisplayNames`
* in the active UI language, so the list stays locale-aware without a
* hand-maintained translation map.
*
* The list is the full ISO 3166-1 alpha-2 set so the picker covers every
* country; options are sorted by localized label at render time, so the
* array order here does not affect the UI.
*/
export const COUNTRY_CODES = [
'AD', 'AE', 'AF', 'AG', 'AI', 'AL', 'AM', 'AO', 'AQ', 'AR', 'AS', 'AT',
'AU', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BD', 'BE', 'BF', 'BG', 'BH', 'BI',
'BJ', 'BL', 'BM', 'BN', 'BO', 'BQ', 'BR', 'BS', 'BT', 'BV', 'BW', 'BY',
'BZ', 'CA', 'CC', 'CD', 'CF', 'CG', 'CH', 'CI', 'CK', 'CL', 'CM', 'CN',
'CO', 'CR', 'CU', 'CV', 'CW', 'CX', 'CY', 'CZ', 'DE', 'DJ', 'DK', 'DM',
'DO', 'DZ', 'EC', 'EE', 'EG', 'EH', 'ER', 'ES', 'ET', 'FI', 'FJ', 'FK',
'FM', 'FO', 'FR', 'GA', 'GB', 'GD', 'GE', 'GF', 'GG', 'GH', 'GI', 'GL',
'GM', 'GN', 'GP', 'GQ', 'GR', 'GS', 'GT', 'GU', 'GW', 'GY', 'HK', 'HM',
'HN', 'HR', 'HT', 'HU', 'ID', 'IE', 'IL', 'IM', 'IN', 'IO', 'IQ', 'IR',
'IS', 'IT', 'JE', 'JM', 'JO', 'JP', 'KE', 'KG', 'KH', 'KI', 'KM', 'KN',
'KP', 'KR', 'KW', 'KY', 'KZ', 'LA', 'LB', 'LC', 'LI', 'LK', 'LR', 'LS',
'LT', 'LU', 'LV', 'LY', 'MA', 'MC', 'MD', 'ME', 'MF', 'MG', 'MH', 'MK',
'ML', 'MM', 'MN', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MU', 'MV', 'MW',
'MX', 'MY', 'MZ', 'NA', 'NC', 'NE', 'NF', 'NG', 'NI', 'NL', 'NO', 'NP',
'NR', 'NU', 'NZ', 'OM', 'PA', 'PE', 'PF', 'PG', 'PH', 'PK', 'PL', 'PM',
'PN', 'PR', 'PS', 'PT', 'PW', 'PY', 'QA', 'RE', 'RO', 'RS', 'RU', 'RW',
'SA', 'SB', 'SC', 'SD', 'SE', 'SG', 'SH', 'SI', 'SJ', 'SK', 'SL', 'SM',
'SN', 'SO', 'SR', 'SS', 'ST', 'SV', 'SX', 'SY', 'SZ', 'TC', 'TD', 'TF',
'TG', 'TH', 'TJ', 'TK', 'TL', 'TM', 'TN', 'TO', 'TR', 'TT', 'TV', 'TW',
'TZ', 'UA', 'UG', 'UM', 'US', 'UY', 'UZ', 'VA', 'VC', 'VE', 'VG', 'VI',
'VN', 'VU', 'WF', 'WS', 'YE', 'YT', 'ZA', 'ZM', 'ZW',
] as const;
export type CountryCode = (typeof COUNTRY_CODES)[number];
/** Localized country name for an ISO code, falling back to the code. */
export function countryLabel(code: string, lang: string): string {
if (!code) return '';
const upper = code.trim().toUpperCase();
try {
return new Intl.DisplayNames([lang || 'en'], { type: 'region' }).of(upper) || upper;
} catch {
return upper;
}
}
/** Country codes sorted by their localized label for the active language. */
export function sortedCountryOptions(lang: string): { code: string; label: string }[] {
return COUNTRY_CODES.map((code) => ({ code, label: countryLabel(code, lang) }))
.sort((a, b) => a.label.localeCompare(b.label, lang || 'en'));
}
+19 -6
View File
@@ -14,16 +14,28 @@
* Shared by QuoteResponsePage + PaymentCheckPage. Adding a third
* public-page consumer? Reuse this hook.
*/
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { usePublicSettings } from './usePublicSettings';
export function usePublicDarkMode() {
/**
* Returns `{ isDark }` (reactive) in addition to applying the `.dark`
* class, so callers can pick a theme-aware asset (e.g. the dark-mode
* logo) without re-deriving the mode themselves.
*/
export function usePublicDarkMode(): { isDark: boolean } {
const { data: publicSettings } = usePublicSettings();
const forced = publicSettings?.branding_force_color_mode;
const [isDark, setIsDark] = useState<boolean>(() => {
if (forced === 'dark') return true;
if (forced === 'light') return false;
return typeof window !== 'undefined'
&& window.matchMedia('(prefers-color-scheme: dark)').matches;
});
useEffect(() => {
const root = document.documentElement;
const forced = publicSettings?.branding_force_color_mode;
const apply = (isDark: boolean) => {
if (isDark) root.classList.add('dark');
const apply = (dark: boolean) => {
setIsDark(dark);
if (dark) root.classList.add('dark');
else root.classList.remove('dark');
};
if (forced === 'dark') {
@@ -39,5 +51,6 @@ export function usePublicDarkMode() {
const listener = (e: MediaQueryListEvent) => apply(e.matches);
mql.addEventListener('change', listener);
return () => mql.removeEventListener('change', listener);
}, [publicSettings?.branding_force_color_mode]);
}, [forced]);
return { isDark };
}
+299 -9
View File
@@ -73,15 +73,37 @@
"message": "Sind Sie sicher, dass Sie die Einladung für {{email}} abbrechen möchten?"
}
},
"systemHealth": {
"title": "Systemzustand",
"subtitle": "Hintergrundfehler, die Aufmerksamkeit erfordern.",
"retry": "Erneut versuchen",
"dismiss": "Verwerfen",
"retriedToast": "E-Mail erneut eingereiht.",
"dismissedToast": "Verworfen.",
"stuckEmails": {
"title": "Hängende / fehlgeschlagene E-Mails",
"empty": "Keine hängenden oder fehlgeschlagenen E-Mails — alles in Ordnung.",
"noError": "Versuche aufgebraucht",
"col": {
"recipient": "Empfänger",
"type": "Typ",
"error": "Fehler",
"queued": "Eingereiht",
"actions": "Aktionen"
}
}
},
"common": {
"loading": "Wird geladen...",
"error": "Fehler",
"configureInSettings": "Standards in den Einstellungen anpassen ↗",
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"add": "Hinzufügen",
"sortBy": "Sortieren nach",
"selectCountry": "Land auswählen…",
"yes": "Ja",
"no": "Nein",
"back": "Zurück",
@@ -164,6 +186,7 @@
"dashboard": "Dashboard",
"events": "Veranstaltungen",
"settings": "Einstellungen",
"systemHealth": "Systemzustand",
"archives": "Archive",
"emailSettings": "E-Mail-Einstellungen",
"branding": "Markenidentität",
@@ -817,6 +840,7 @@
"noTemplate": "Keine Vorlage",
"useThemeOnly": "Nur Design-Vorlage verwenden",
"customTemplate": "Benutzerdefinierte Vorlage",
"createInvoice": "Rechnung erstellen",
"title": "Veranstaltungen",
"create": "Veranstaltung erstellen",
"createEvent": "Veranstaltung erstellen",
@@ -1627,6 +1651,21 @@
"clients": {
"title": "CRM",
"description": "Hauptschalter für den CRM-Bereich in der Seitenleiste. Aus blendet den Bereich und alle Unterfunktionen aus, unabhängig von deren individuellen Schaltern beim erneuten Einschalten wird der vorherige Zustand wiederhergestellt."
},
"contracts": {
"title": "Verträge",
"description": "Erstellen Sie Verträge aus einer Bibliothek wiederverwendbarer Bausteine (Bildrechte, Geheimhaltung, Model-Release, Stornierung, Gerichtsstand …) und lassen Sie Kunden im Browser unterschreiben oder eine handsignierte PDF hochladen. Die vorausgefüllten Baustein-Texte sind NUR BEISPIELE — vor dem Versand mit Ihrem Anwalt prüfen.",
"sidebar": "Verträge"
},
"crmDevelopment": {
"title": "CRM-Entwicklerwerkzeuge",
"description": "Interne Helfer zum Prüfen von CRM-Abläufen (z. B. die Admin-Zahlungsprüfungs-E-Mail sofort auslösen, Drosselungen umgehen). Erscheint als Unterreiter „Entwicklung“ unter Kunden. Strikt opt-in — löst echte Seiteneffekte aus, nur mit Testdaten verwenden.",
"sidebar": "Entwicklung"
},
"hoursLogging": {
"title": "Stundenerfassung",
"description": "Zeiterfassung pro Kunde. Admin erfasst Datum + Start-/Endzeit + optionalen Satz-Override + Notiz. Kunden im Monatsmodus akkumulieren Stunden automatisch in den laufenden Monatsentwurf; Kunden pro Anlass sehen eine Schaltfläche „Entwurfsrechnung erstellen“, die eine eigenständige Entwurfsrechnung mit einer Zeile pro Eintrag erzeugt. Unabhängig von Rechnungen — Stunden erfassen, noch bevor die volle Abrechnungsoberfläche aktiviert ist.",
"sidebar": "Stunden"
}
},
"customerSurface": {
@@ -1639,6 +1678,12 @@
"save": "Änderungen speichern",
"saved": "Branding des Kundendashboards gespeichert",
"error": "Einstellungen konnten nicht gespeichert werden"
},
"businessProfile": {
"title": "Geschäftsprofil"
},
"crm": {
"title": "CRM-Verhalten"
}
},
"branding": {
@@ -1657,11 +1702,13 @@
"logo": "Logo",
"uploadLogo": "Logo hochladen",
"logoHelp": "Empfohlene Größe: 200x60px, PNG oder JPEG",
"logoDark": "Logo für Dunkelmodus",
"logoDarkHelp": "Optional. Wird bei dunklen Designs / im Dunkelmodus angezeigt; fällt auf das Hauptlogo zurück, wenn nicht gesetzt.",
"favicon": "Favicon",
"currentFavicon": "Aktuelles Favicon",
"uploadFavicon": "Favicon hochladen",
"removeFavicon": "Favicon entfernen",
"faviconHelp": "PNG- oder ICO-Format, empfohlene Größe: 32x32px",
"faviconHelp": "PNG-, ICO- oder SVG-Format. Bitte ein quadratisches Bild verwenden ein SVG (oder ein 512×512px-PNG) liefert die schärfste Darstellung auf hochauflösenden Bildschirmen; kleinere Größen funktionieren ebenfalls.",
"watermarkSettings": "Wasserzeichen-Einstellungen",
"enableWatermarks": "Wasserzeichen aktivieren",
"watermarkHelp": "Fügen Sie Ihren Firmennamen als Wasserzeichen auf heruntergeladenen Fotos hinzu",
@@ -2385,6 +2432,37 @@
"gmailAppPassword": "Für Gmail verwenden Sie ein App-spezifisches Passwort",
"testEmailAddressLabel": "Test-E-Mail-Adresse",
"sendTestEmailButton": "Test-E-Mail senden",
"flushQueue": {
"title": "Wartende E-Mails jetzt senden",
"help": "Sendet sofort alle ausstehenden E-Mails, unabhängig von den Geschäftszeiten. Nützlich, um die Warteschlange vor Wartungsarbeiten oder Updates zu leeren.",
"button": "Wartende E-Mails jetzt senden",
"success": "Warteschlange geleert {{sent}} gesendet, {{failed}} fehlgeschlagen",
"empty": "Keine ausstehenden E-Mails zum Senden"
},
"sentEmails": {
"tab": "Gesendete E-Mails",
"title": "Gesendete E-Mails",
"subtitle": "Versandstatus aller eingereihten und gesendeten Benachrichtigungen.",
"searchPlaceholder": "Nach Empfänger oder Typ suchen…",
"from": "Von",
"to": "Bis",
"empty": "Keine E-Mails entsprechen diesen Filtern.",
"retries": "{{count}} Versuche",
"pagination": "Seite {{page}} von {{total}} · {{count}} E-Mails",
"status": {
"pending": "Ausstehend",
"sent": "Gesendet",
"failed": "Fehlgeschlagen"
},
"col": {
"recipient": "Empfänger",
"type": "Typ",
"status": "Status",
"created": "Eingereiht",
"sent": "Gesendet",
"event": "Anlass"
}
},
"commonSmtpSettings": "Häufige SMTP-Einstellungen:",
"editTemplate": "Vorlage bearbeiten",
"templateName": "Vorlagenname",
@@ -3028,7 +3106,7 @@
"hours": {
"section": "Stunden",
"monthlyHint": "Einträge werden automatisch dem aktuellen monatlichen Entwurf angehängt. Bearbeiten/Löschen möglich, bis der Scheduler den Versand auslöst.",
"perEventHint": "Erfasste Einträge bleiben unverrechnet, bis Sie auf „Rechnungsentwurf erstellen“ klicken — dann wird ein eigenständiger Rechnungsentwurf mit einer Zeile pro Eintrag erzeugt, den Sie vor dem Versand prüfen können.",
"perEventHint": "Erfasste Einträge bleiben unverrechnet, bis Sie auf „Rechnungsentwurf erstellen“ klicken — dann wird eine geplante Rechnung mit einer Zeile pro Eintrag erzeugt und im Editor geöffnet, sodass Sie vor dem Versand weitere Positionen hinzufügen können.",
"form": {
"title": "Neuen Eintrag erfassen",
"date": "Datum",
@@ -3040,7 +3118,8 @@
"rateOverride": "Satz-Override",
"note": "Notiz / Beschreibung",
"notePlaceholder": "Was wurde gearbeitet?",
"save": "Eintrag hinzufügen"
"save": "Eintrag hinzufügen",
"needRate": "Satz festlegen oder Override eingeben, um Zeit zu erfassen."
},
"col": {
"date": "Datum",
@@ -3065,6 +3144,20 @@
"created": "Eintrag erfasst",
"deleted": "Eintrag gelöscht",
"billed": "Stunden verrechnet"
},
"noRate": {
"title": "Kein Stundensatz hinterlegt",
"body": "Für die Erfassung wird ein Satz benötigt. Legen Sie einen für diesen Kunden fest, geben Sie unten einen Override pro Eintrag ein oder konfigurieren Sie einen installationsweiten Standardsatz.",
"setForCustomer": "Satz für diesen Kunden festlegen",
"setInstallDefault": "Installationsweiten Standard festlegen"
},
"rateSource": {
"customer": "von diesem Kunden",
"installDefault": "installationsweiter Standard"
},
"error": {
"noRate": "Für diesen Kunden ist kein Stundensatz hinterlegt. Geben Sie einen Satz-Override ein, hinterlegen Sie einen Satz beim Kunden oder konfigurieren Sie in den Einstellungen einen installationsweiten Standardsatz.",
"createFailed": "Eintrag konnte nicht erfasst werden"
}
},
"create": {
@@ -3078,7 +3171,8 @@
"savedPassiveToast": "Passiver Kunde erstellt.",
"savedActiveToast": "Kunde erstellt und Portal-Einladung gesendet.",
"inviteFailedToast": "Kunde gespeichert (passiv). Einladungs-E-Mail fehlgeschlagen — bitte aus dem Kundendetail erneut versuchen.",
"emailRequired": "Eine gültige E-Mail-Adresse ist erforderlich."
"emailRequired": "Eine gültige E-Mail-Adresse ist erforderlich.",
"nameRequired": "Geben Sie mindestens einen Firmennamen oder einen Ansprechpartner an."
},
"passive": {
"badge": "Passiv — nur Admin",
@@ -3183,6 +3277,7 @@
"city": "Stadt",
"state": "Bundesland / Region",
"countryCode": "Land (ISO-2)",
"country": "Land",
"countryName": "Land (vollständiger Name)",
"notesHint": "Nur für Administratoren sichtbar. Wird dem Kunden nie gezeigt.",
"featuresSection": "Kundenfunktionen",
@@ -3205,20 +3300,26 @@
},
"billing": {
"section": "Abrechnungsrhythmus",
"hint": "Per-Event (Standard): jede Rechnung wird einzeln versendet. Monatlich: alle Rechnungen einer Periode werden zu einer Sammelrechnung gebündelt, die am konfigurierten Stichtag ausgelöst wird.",
"hint": "Per-Event (Standard): jede Rechnung wird einzeln versendet. Monatlich: alle Rechnungen einer Periode werden zu einer Sammelrechnung gebündelt, die am konfigurierten Stichtag ausgelöst wird. Manuell: Positionen sammeln sich genauso, aber die Rechnung wird erst versendet, wenn Sie sie auslösen.",
"cadence": "Abrechnungsrhythmus",
"perEvent": "Per Event",
"monthly": "Monatlich",
"quarterly": "Quartalsweise",
"manual": "Manuell (nur auf Auslösung)",
"cycleDay": "Stichtag",
"cycleDayHint": "1..28 = Tag im Monat. Negativ -1..-15 für „N Tage vor Monatsende“ (so löst -3 in einem 31-Tage-Monat am 28. aus).",
"skontoDisabled": "Kein Skonto für diesen Kunden",
"skontoDisabledHint": "Deaktiviert den Skonto-Abzug auf allen Rechnungen dieses Kunden unabhängig von Vorlage oder globalen Standardwerten.",
"triggerNow": "Rechnung jetzt ausstellen",
"triggerConfirm": "Monatsrechnung für diesen Kunden jetzt ausstellen? Der Kunde erhält die E-Mail sofort.",
"triggerConfirmManual": "Gesammelte Rechnung für diesen Kunden jetzt ausstellen? Der Kunde erhält die E-Mail sofort.",
"triggerHint": "Überspringt den Stichtag und stellt den aktuellen Entwurf sofort aus. Wird abgelehnt, wenn für die aktuelle Periode nichts erfasst wurde.",
"triggerHintManual": "Stellt den aktuellen Entwurf sofort aus. Entwürfe mit manuellem Rhythmus werden nie automatisch versendet dies ist der einzige Weg, sie zu versenden. Wird abgelehnt, wenn nichts erfasst wurde.",
"triggered": "Monatsrechnung ausgestellt: {{number}}",
"triggerError": "Monatsrechnung konnte nicht ausgelöst werden.",
"draftPreview": {
"title": "Offen für die Rechnung dieses Monats",
"titleManual": "Offen wird auf manuelle Auslösung versendet",
"periodRange": "{{number}} · {{from}} {{to}}"
}
},
@@ -3325,7 +3426,16 @@
"pickPlaceholder": "— Kunde wählen —",
"emptyList": "Noch keine Kunden mit aktivierter Stundenerfassung. Aktivieren Sie „Stundenerfassung“ zuerst auf der Kundendetailseite.",
"searchPlaceholder": "Nach E-Mail oder Firma suchen…",
"customerLoggingDisabled": "Bei diesem Kunden ist die Stundenerfassung deaktiviert. Aktiviere sie auf der Kundendetailseite, um Stunden zu erfassen."
"customerLoggingDisabled": "Bei diesem Kunden ist die Stundenerfassung deaktiviert. Aktiviere sie auf der Kundendetailseite, um Stunden zu erfassen.",
"openHours": {
"title": "Offene Stunden über alle Kunden",
"subtitle": "Noch nicht abgerechnete Zeitblöcke. Oben einen Kunden wählen oder auf eine Zeile klicken, um Details zu öffnen.",
"empty": "Aktuell keine offenen Stunden alles abgerechnet oder noch keine Zeit erfasst.",
"entryLine_one": "{{count}} Eintrag · {{hours}} Std.",
"entryLine_other": "{{count}} Einträge · {{hours}} Std.",
"passive": "Passiv",
"needsRate": "Kein Satz hinterlegt"
}
},
"crmDev": {
"title": "CRM-Entwicklung",
@@ -3479,6 +3589,9 @@
"send": "Senden",
"resend": "Erneut senden",
"convert": "In Anlass umwandeln",
"declineOnBehalf": "Im Namen ablehnen",
"declineReasonPrompt": "Dieses Angebot im Namen des Kunden als abgelehnt markieren? Optional einen Grund angeben (leer lassen zum Überspringen).",
"declinedOnBehalfToast": "Angebot als abgelehnt markiert.",
"field": {
"issueDate": "Ausgestellt am",
"validUntil": "Gültig bis",
@@ -3487,6 +3600,7 @@
"sentAt": "Gesendet am",
"acceptedAt": "Angenommen am",
"declinedAt": "Abgelehnt am",
"declineReason": "Ablehnungsgrund",
"responseWindow": "Antwortfrist",
"eventTimeStart": "Startzeit",
"eventTimeEnd": "Endzeit",
@@ -3588,8 +3702,9 @@
"status": "Status",
"dueDate": "Fällig",
"total": "Gesamt",
"sourceQuote": "Vom Angebot",
"issueDate": "Ausgestellt am",
"dueDateOverrideOn": "Manuelles Fälligkeitsdatum — Häkchen entfernen, um es automatisch aus Versanddatum + Zahlungsziel zu berechnen",
"dueDateOverrideOff": "Automatisch aus Versanddatum + Zahlungsziel — ankreuzen, um es manuell zu setzen",
"scheduledSendAt": "Geplanter Versand (optional)",
"installment": "Rate",
"paid": "Bezahlt",
@@ -3611,6 +3726,7 @@
"selectTiming": "— Zahlungsablauf wählen —",
"eventName": "Anlass",
"eventDate": "Anlassdatum",
"eventNamePlaceholder": "z.B. Hochzeit Schmidt 2024",
"eventTimeStart": "Startzeit",
"eventTimeEnd": "Endzeit",
"customer": "Kunde",
@@ -3651,6 +3767,7 @@
"customer": "Kunde",
"event": "Anlass",
"installment": "Rate",
"issueDate": "Ausgestellt",
"dueDate": "Fällig",
"total": "Gesamt",
"status": "Status"
@@ -3701,6 +3818,7 @@
"payment": {
"paidAt": "Bezahlt am",
"amount": "Betrag",
"date": "Zahlungsdatum",
"method": "Methode",
"reference": "Referenz",
"notes": "Notizen",
@@ -3730,6 +3848,27 @@
"savedToast": "Geschäftsprofil gespeichert.",
"title": "Geschäftsprofil",
"subtitle": "Briefkopf, Kontaktdaten und Standardwerte für Angebote und Rechnungen.",
"businessHours": {
"title": "Geschäftszeiten",
"subtitle": "Öffnungszeiten je Wochentag festlegen — für eine Mittagspause einfach einen zweiten Block hinzufügen. Werden in der oben gewählten Zeitzone interpretiert ({{tz}}).",
"closed": "Geschlossen",
"addHours": "Zeiten hinzufügen",
"addBlock": "Weiteren Block hinzufügen",
"copyToAll": "Auf alle Tage übertragen",
"startTime": "Öffnungszeit",
"endTime": "Schließzeit",
"floorToggle": "Geplante E-Mails bis zu den Geschäftszeiten zurückhalten",
"floorToggleHelp": "Wenn aktiv, wird eine automatische E-Mail, die außerhalb der obigen Zeiten geplant ist, erst zur nächsten Öffnungszeit zugestellt statt zu einer ungünstigen Uhrzeit. Wenn aus, werden geplante E-Mails exakt zum geplanten Zeitpunkt versendet.",
"weekday": {
"1": "Montag",
"2": "Dienstag",
"3": "Mittwoch",
"4": "Donnerstag",
"5": "Freitag",
"6": "Samstag",
"7": "Sonntag"
}
},
"section": {
"company": "Firma",
"contact": "Kontakt",
@@ -3755,6 +3894,9 @@
"timezone": "Zeitzone (IANA)",
"vatLabel": "MwSt-Bezeichnung",
"vatRateDefault": "Standard-MwSt-Satz %",
"defaultHourlyRate": "Standard-Stundensatz",
"defaultHourlyRatePlaceholder": "z. B. 120.00",
"defaultHourlyRateHint": "Fallback, wenn ein Kunde keinen eigenen Satz hat. In {{currency}}, in ganzen Einheiten. Leer lassen, um einen Satz pro Kunde oder pro Eintrag zu verlangen.",
"defaultQrFormat": "Standard-QR-Format",
"footerLine": "Fusszeile"
},
@@ -3785,7 +3927,11 @@
"quotes": "Angebote",
"invoices": "Rechnungen",
"paymentDefaults": "Standard-Zahlungsbedingungen",
"installmentDefaults": "Standard-Trigger für Teilzahlungen"
"installmentDefaults": "Standard-Trigger für Teilzahlungen",
"contracts": "Verträge",
"quotesTos": "AGB-Schritt",
"dashboardOverview": "CRM-Übersicht im Dashboard",
"dashboardOverviewHint": "CRM-Übersichtskacheln im Admin-Dashboard ausblenden. Alle Kacheln werden standardmäßig angezeigt; zum Ausblenden abwählen."
},
"installmentDefaults": {
"help": "Vorbelegung der Trigger für neue Zeilen im Teilzahlungs-Panel. Pro-Dokument-Anpassungen überschreiben; bestehende Dokumente behalten ihren gespeicherten Plan.",
@@ -3860,6 +4006,48 @@
},
"crm_invoices_late_fee_enabled": {
"label": "Mahngebühr aktivieren"
},
"crm_quotes_tos_required": {
"label": "Kunden müssen „Ich akzeptiere die AGB“ ankreuzen, bevor sie annehmen können"
},
"crm_quotes_tos_url": {
"label": "AGB-URL (optional)"
},
"crm_quotes_tos_text": {
"label": "AGB-Text, der auf der Angebotsseite angezeigt wird",
"placeholder": "Fügen Sie hier die Vertragsbedingungen ein. Nur Text. Leer lassen, um nur Häkchen + URL anzuzeigen."
},
"crm_contracts_pdf_attachment_enabled": {
"label": "Vertrags-PDF an E-Mail anhängen"
},
"crm_contracts_require_drawn_signature": {
"label": "Gezeichnete Unterschrift verlangen (getippter Name allein genügt nicht)"
},
"crm_contracts_allow_pdf_upload": {
"label": "Kunden erlauben, eine handsignierte PDF hochzuladen"
},
"crm_contracts_store_ip": {
"label": "IP-Adresse des Unterzeichners speichern (empfohlen — stützendes Beweismittel in Zivilstreitigkeiten)",
"help": "Wenn deaktiviert, wird die IP-Adresse von Kunde und Admin zum Signaturzeitpunkt NICHT in der Vertragszeile oder der öffentlichen Signaturseiten-Bestätigung erfasst. Nach dem DSGVO-Grundsatz der Datenminimierung bevorzugen das manche Betreiber — die IP ist jedoch ein stützendes Identitätsmerkmal, falls der Vertrag angefochten wird, daher empfehlen wir, sie aktiviert zu lassen."
},
"crm_contracts_default_valid_days": {
"label": "Unterzeichnungsfrist (Tage)"
},
"crm_contracts_number_format": {
"label": "Vertragsnummern-Format",
"help": "Unterstützte Platzhalter: {YEAR}, {MONTH}, {SEQ:04d}. Beispiel: LBM-C-{YEAR}-{SEQ:04d} → LBM-C-2026-0001."
},
"crm_overview_show_revenue": {
"label": "Umsatz-Kacheln (30 / 90 / 365 Tage)"
},
"crm_overview_show_outstanding": {
"label": "Kachel offene Zahlungen"
},
"crm_overview_show_quotes": {
"label": "Angebots-Pipeline (nach Status)"
},
"crm_overview_show_invoices": {
"label": "Rechnungs-Pipeline (nach Status)"
}
},
"quoteResponse": {
@@ -3989,6 +4177,7 @@
"new": "Neuer Vertrag"
},
"detail": {
"previewPdf": "PDF-Vorschau",
"integrity": {
"title": "PDF-Integritätsprüfung",
"help": "Berechnet die SHA-256-Hashes der unsignierten und signierten PDFs neu und vergleicht sie mit den beim Ausstellen gespeicherten Werten. Deckt Backup-Beschädigungen oder nachträgliche Änderungen an der Kundenkopie auf.",
@@ -4003,7 +4192,108 @@
"expected": "erwartet",
"actual": "tatsächlich",
"error": "Integritätsprüfung fehlgeschlagen."
}
},
"alreadyEventToast": "Bereits mit einem Anlass verknüpft.",
"auditEmpty": "Noch keine Audit-Log-Einträge.",
"auditTrail": "Audit-Verlauf",
"auditTrailHelp": "Jedes auf diesem Vertrag erfasste Ereignis. Die Liste ist anhängend (append-only) und ist die Quelle der Wahrheit, falls der Vertrag angefochten wird.",
"back": "Zurück zur Liste",
"blocks": "Enthaltene Bausteine",
"cancel": "Stornieren",
"cancelConfirm": "Diesen Vertrag stornieren? Der Signatur-Link des Kunden wird ungültig.",
"cancelError": "Stornierung fehlgeschlagen",
"cancelledToast": "Vertrag storniert.",
"clearSignature": "Löschen",
"confirmConvertEvent": "Diesen Vertrag in einen Anlass + geplante Rechnungen umwandeln?",
"confirmConvertInvoice": "Diesen Vertrag nur in Rechnung(en) umwandeln? Es wird keine Galerie / kein Anlass erstellt.",
"confirmCountersign": "Gegenzeichnen",
"confirmResendSigned": "Das signierte Vertrags-PDF erneut an beide Parteien senden?",
"confirmRestamp": "PDF neu stempeln & neu rendern",
"convertError": "Umwandlung fehlgeschlagen",
"convertToEvent": "In Anlass umwandeln",
"convertToInvoice": "Nur in Rechnung umwandeln",
"convertedToEvent": "In Anlass umgewandelt",
"convertedToEventToast": "Vertrag in Anlass #{{id}} umgewandelt",
"convertedToInvoiceToast": "{{count}} Rechnung(en) aus diesem Vertrag erstellt",
"countersignError": "Gegenzeichnen fehlgeschlagen",
"countersignHelp": "Geben Sie Ihren Namen ein UND zeichnen Sie unten Ihre Unterschrift — beide werden auf das neu gerenderte PDF gestempelt. IP und Zeitstempel werden für das Audit erfasst.",
"countersignSignaturePrompt": "Zeichnen Sie Ihre Unterschrift",
"countersignTitle": "Gegenzeichnen, um den Vertrag bindend zu machen",
"countersignedToast": "Gegengezeichnet.",
"customer": "Kunde",
"dates": "Daten",
"downloadPdf": "PDF herunterladen",
"downloadSignedPdf": "Signiertes PDF herunterladen",
"edit": "Bearbeiten",
"fromQuote": "Aus Angebot",
"issued": "Ausgestellt",
"linkedInvoice": "Rechnung",
"newInvoice": "Neue Rechnung",
"noBlocks": "Keine Bausteine enthalten.",
"noSignatureImage": "Kein Unterschriftsbild erfasst — nutzen Sie unten „Unterschriften neu stempeln“, um eines hinzuzufügen.",
"notFound": "Vertrag nicht gefunden.",
"parties": "Parteien",
"popupBlocked": "Erlauben Sie Pop-ups für diese Seite, um die PDF-Vorschau anzuzeigen.",
"renderFailedBody": "Der Signaturnachweis ist erfasst, aber das gestempelte PDF wurde beim letzten Versuch nicht erzeugt. Klicken Sie oben auf „Signiertes PDF erneut senden“, um es aus dem Originaldokument neu zu stempeln und erneut zu senden.",
"renderFailedTitle": "Stempeln des signierten PDFs fehlgeschlagen — Neustempeln erforderlich",
"resendError": "Erneutes Senden fehlgeschlagen",
"resendSigned": "Signiertes PDF erneut senden",
"resentSignedToast": "Signierter Vertrag erneut an beide Parteien gesendet.",
"restampAdmin": "Admin-Unterschrift",
"restampCustomer": "Kundenunterschrift",
"restampError": "Neustempeln fehlgeschlagen",
"restampHelp": "Bei einer oder beiden Unterschriften wurde kein Bild erfasst. Zeichnen Sie die fehlende(n) Unterschrift(en) hier und wir rendern das PDF neu. Die bereits gespeicherten getippten Namen, Zeitstempel und IPs bleiben unverändert.",
"restampTitle": "Fehlende Unterschriften neu stempeln",
"restampedToast": "Unterschriften neu gestempelt und PDF neu gerendert.",
"send": "An Kunden senden",
"sendError": "Senden fehlgeschlagen",
"sentAt": "Gesendet am",
"sentToast": "Vertrag gesendet.",
"signBy": "Unterzeichnen bis",
"signatures": "Unterschriften",
"signedByAdmin": "Gegengezeichnet",
"signedByCustomer": "Vom Kunden signiert",
"signedNamePlaceholder": "Ihr vollständiger Name",
"uploadError": "Upload fehlgeschlagen",
"uploadSigned": "Signiertes PDF hochladen",
"uploadedToast": "Signiertes PDF hochgeladen."
},
"editor": {
"back": "Zurück zur Liste",
"backToDetail": "Zurück zum Vertrag",
"create": "Entwurf erstellen",
"createdToast": "Vertrag erstellt.",
"customer": "Kunde",
"disclaimerBody": "Die vorausgefüllten Baustein-Texte sind NUR BEISPIELE — vom Maintainer verfasst, nicht von einem Anwalt. Lassen Sie jeden verwendeten Baustein von Ihrem eigenen Anwalt prüfen, bevor Sie den Vertrag versenden. Siehe docs/crm-disclaimers.md.",
"disclaimerTitle": "Anwaltliche Prüfung erforderlich",
"eventDate": "Anlassdatum",
"eventHelp": "Wird auf den Vertrag übernommen und an jeden daraus erzeugten Anlass / jede Rechnung weitergegeben. Setzen Sie dies, damit Kundenportal und Mahn-E-Mails die richtige Bezeichnung \"Hochzeit Doe / Müller\" anzeigen.",
"eventName": "Anlassname",
"eventNamePlaceholder": "z. B. Hochzeit Doe / Müller",
"eventSection": "Anlass (optional)",
"eventTimeEnd": "Ende",
"eventTimeStart": "Beginn",
"intro": "Einleitungstext (optional)",
"issueDate": "Ausstellungsdatum",
"language": "Sprache",
"locked": "Gesendete Verträge können nicht bearbeitet werden. Für Änderungen stornieren und einen neuen erstellen.",
"noBlocksInSection": "Noch keine Bausteine für diesen Abschnitt.",
"outro": "Schlusstext (optional)",
"popupBlocked": "Erlauben Sie Pop-ups für diese Seite, um die PDF-Vorschau anzuzeigen.",
"preview": "PDF-Vorschau",
"previewAfterSave": "Zuerst den Entwurf speichern, dann Vorschau anzeigen.",
"previewError": "Vorschau fehlgeschlagen",
"save": "Speichern",
"saveError": "Speichern fehlgeschlagen",
"savedToast": "Vertrag gespeichert.",
"schriftformWarning": "Signaturtyp: einfache elektronische Signatur (EES). Ausreichend für routinemäßige Fotografieverträge in CH / DE / AT / FL. NICHT ausreichend für Dokumente, die gesetzlich Schriftform / forme qualifiée verlangen: Bürgschaft (DE § 766 BGB), Verbraucherdarlehensvertrag (DE § 492 BGB), befristete Arbeitsverträge (DE § 14 Abs. 4 TzBfG) und Ähnliches. Dafür ist eine qualifizierte elektronische Signatur (QES) eines Vertrauensdiensteanbieters erforderlich — picpeak bietet keine QES.",
"searchCustomer": "Nach E-Mail suchen…",
"systemBadge": "System",
"titleEdit": "Vertrag bearbeiten",
"titleField": "Vertragstitel",
"titleNew": "Neuer Vertrag",
"titlePlaceholder": "z. B. Hochzeitsvertrag Doe / Müller",
"validUntil": "Unterzeichnen bis (optional)"
}
}
}
+299 -8
View File
@@ -73,15 +73,37 @@
"message": "Are you sure you want to cancel the invitation for {{email}}?"
}
},
"systemHealth": {
"title": "System health",
"subtitle": "Background failures that need attention.",
"retry": "Retry",
"dismiss": "Dismiss",
"retriedToast": "Email re-queued.",
"dismissedToast": "Dismissed.",
"stuckEmails": {
"title": "Stuck / failed emails",
"empty": "No stuck or failed emails — all clear.",
"noError": "retries exhausted",
"col": {
"recipient": "Recipient",
"type": "Type",
"error": "Error",
"queued": "Queued",
"actions": "Actions"
}
}
},
"common": {
"loading": "Loading...",
"error": "Error",
"configureInSettings": "Configure defaults in Settings ↗",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"sortBy": "Sort by",
"selectCountry": "Select country…",
"yes": "Yes",
"no": "No",
"back": "Back",
@@ -165,6 +187,7 @@
"events": "Events",
"archives": "Archives",
"settings": "Settings",
"systemHealth": "System health",
"eventTypes": "Event Types",
"branding": "Branding",
"emailSettings": "Email Settings",
@@ -398,6 +421,7 @@
"copyLink": "Copy Link",
"linkCopied": "Link copied!",
"viewGallery": "View Gallery",
"createInvoice": "Create invoice",
"uploadPhotos": "Upload Photos",
"archiveEvent": "Archive Event",
"archiveConfirm": "Are you sure you want to archive this event? This action cannot be undone.",
@@ -1185,6 +1209,21 @@
"clients": {
"title": "CRM",
"description": "Master switch for the CRM sidebar section. Off hides the section and every sub-feature below regardless of their individual toggles — re-enable to restore them to whatever you set last."
},
"contracts": {
"title": "Contracts",
"description": "Compose contracts from a library of reusable blocks (image rights, NDA, model release, cancellation, jurisdiction…) and have customers sign in-browser or upload a wet-signed PDF. Seeded block bodies are EXAMPLES ONLY — review with your lawyer before sending.",
"sidebar": "Contracts"
},
"crmDevelopment": {
"title": "CRM developer tools",
"description": "Internal helpers for verifying CRM flows (e.g. fire the admin payment-check email instantly, bypass throttles). Surfaces as a \"Development\" sub-tab under Clients. Strictly opt-in — fires real side effects, use against test data only.",
"sidebar": "Development"
},
"hoursLogging": {
"title": "Hours logging",
"description": "Per-customer time tracking. Admin logs date + start/end times + optional rate override + note. Monthly-mode customers auto-accumulate hours into the running monthly draft; per-event customers see a \"Create draft invoice\" button that mints a standalone draft invoice with one line per entry. Independent of Bills — log hours even before turning the full billing surface on.",
"sidebar": "Hours"
}
},
"customerSurface": {
@@ -1197,6 +1236,12 @@
"save": "Save changes",
"saved": "Customer dashboard branding saved",
"error": "Could not save settings"
},
"businessProfile": {
"title": "Business profile"
},
"crm": {
"title": "CRM behaviour"
}
},
"analytics": {
@@ -1246,11 +1291,13 @@
"logo": "Logo",
"uploadLogo": "Upload Logo",
"logoHelp": "Recommended size: 200x60px, PNG or JPEG",
"logoDark": "Dark-mode logo",
"logoDarkHelp": "Optional. Shown on dark themes / dark mode; falls back to the main logo when unset.",
"favicon": "Favicon",
"currentFavicon": "Current favicon",
"uploadFavicon": "Upload Favicon",
"removeFavicon": "Remove Favicon",
"faviconHelp": "PNG or ICO format, recommended size: 32x32px",
"faviconHelp": "PNG, ICO, or SVG format. Use a square image — an SVG (or a 512×512px PNG) gives the crispest display on high-resolution screens; smaller sizes work too.",
"watermarkSettings": "Watermark Settings",
"enableWatermarks": "Enable Watermarks",
"watermarkHelp": "Add your company name as a watermark on downloaded photos",
@@ -1958,6 +2005,37 @@
"gmailAppPassword": "For Gmail, use an app-specific password",
"testEmailAddressLabel": "Test Email Address",
"sendTestEmailButton": "Send Test Email",
"flushQueue": {
"title": "Send queued emails now",
"help": "Immediately send every pending email, ignoring the business-hours schedule. Useful for draining the queue before maintenance or updates.",
"button": "Send queued emails now",
"success": "Email queue flushed — {{sent}} sent, {{failed}} failed",
"empty": "No pending emails to send"
},
"sentEmails": {
"tab": "Sent emails",
"title": "Sent emails",
"subtitle": "Delivery status of every queued and sent notification.",
"searchPlaceholder": "Search by recipient or type…",
"from": "From",
"to": "To",
"empty": "No emails match these filters.",
"retries": "{{count}} retries",
"pagination": "Page {{page}} of {{total}} · {{count}} emails",
"status": {
"pending": "Pending",
"sent": "Sent",
"failed": "Failed"
},
"col": {
"recipient": "Recipient",
"type": "Type",
"status": "Status",
"created": "Queued",
"sent": "Sent",
"event": "Event"
}
},
"commonSmtpSettings": "Common SMTP Settings:",
"editTemplate": "Edit Template",
"templateName": "Template Name",
@@ -3028,7 +3106,7 @@
"hours": {
"section": "Hours",
"monthlyHint": "Entries auto-append to the current monthly draft. Edit / delete remains possible until the scheduler arms the draft for send.",
"perEventHint": "Logged entries stay unbilled until you click \"Create draft invoice\" — a standalone draft invoice is generated with one line per entry, ready for you to review before sending.",
"perEventHint": "Logged entries stay unbilled until you click \"Create draft invoice\" — one scheduled invoice is generated with a line per entry and opened in the editor, so you can add other items before it ships.",
"form": {
"title": "Log new entry",
"date": "Date",
@@ -3040,7 +3118,8 @@
"rateOverride": "Rate override",
"note": "Note / description",
"notePlaceholder": "What was worked on?",
"save": "Add entry"
"save": "Add entry",
"needRate": "Set a rate or enter an override to log time."
},
"col": {
"date": "Date",
@@ -3065,6 +3144,20 @@
"created": "Entry logged",
"deleted": "Entry deleted",
"billed": "Hours billed"
},
"noRate": {
"title": "No hourly rate configured",
"body": "Logging needs a rate. Set one for this customer, type a per-entry override below, or configure an install-wide default.",
"setForCustomer": "Set a rate for this customer",
"setInstallDefault": "Set an install-wide default"
},
"rateSource": {
"customer": "from this customer",
"installDefault": "install-wide default"
},
"error": {
"noRate": "No hourly rate set for this customer. Enter a rate override, set a rate on the customer, or configure an install-wide default in Settings.",
"createFailed": "Failed to log entry"
}
},
"create": {
@@ -3078,7 +3171,8 @@
"savedPassiveToast": "Passive customer created.",
"savedActiveToast": "Customer created and portal invitation sent.",
"inviteFailedToast": "Customer saved (passive). Invitation email failed — retry from the customer detail page.",
"emailRequired": "A valid email is required."
"emailRequired": "A valid email is required.",
"nameRequired": "Enter at least a company name or a contact name."
},
"passive": {
"badge": "Passive — admin only",
@@ -3183,6 +3277,7 @@
"city": "City",
"state": "State / region",
"countryCode": "Country (ISO 2)",
"country": "Country",
"countryName": "Country (full name)",
"notesHint": "Visible only to admins. Never shown to the customer.",
"featuresSection": "Customer features",
@@ -3205,20 +3300,26 @@
},
"billing": {
"section": "Billing cadence",
"hint": "Per-event (default): every invoice is sent on its own schedule. Monthly: all invoices issued in the period accumulate into one bill that fires on the configured day.",
"hint": "Per-event (default): every invoice is sent on its own schedule. Monthly: all invoices issued in the period accumulate into one bill that fires on the configured day. Manual: items accumulate the same way, but the bill ships only when you trigger it.",
"cadence": "Billing cadence",
"perEvent": "Per event",
"monthly": "Monthly",
"quarterly": "Quarterly",
"manual": "Manual (trigger only)",
"cycleDay": "Cycle day",
"cycleDayHint": "1..28 = day of month. Use negative -1..-15 for \"N days before month end\" (so -3 fires on the 28th of a 31-day month).",
"skontoDisabled": "No Skonto for this customer",
"skontoDisabledHint": "Disables the early-payment discount on all of this customers invoices, regardless of template or global defaults.",
"triggerNow": "Trigger invoice now",
"triggerConfirm": "Issue this customer's monthly bill now? The customer receives the email immediately.",
"triggerConfirmManual": "Issue this customer's accumulated bill now? The customer receives the email immediately.",
"triggerHint": "Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.",
"triggerHintManual": "Issues the running draft immediately. Manual-cadence drafts never ship automatically — this is the only way to send them. Refuses when nothing has been queued.",
"triggered": "Monthly bill issued: {{number}}",
"triggerError": "Could not trigger the monthly bill.",
"draftPreview": {
"title": "Pending in this month's bill",
"titleManual": "Pending — ships on manual trigger",
"periodRange": "{{number}} · {{from}} {{to}}"
}
},
@@ -3325,7 +3426,16 @@
"pickPlaceholder": "— Select customer —",
"emptyList": "No customers have hours logging enabled yet. Flip \"Hours logging\" on a customer's detail page first.",
"searchPlaceholder": "Search by email or company…",
"customerLoggingDisabled": "This customer has hour logging disabled. Enable it on the customer's detail page to log hours."
"customerLoggingDisabled": "This customer has hour logging disabled. Enable it on the customer's detail page to log hours.",
"openHours": {
"title": "Open hours across all customers",
"subtitle": "Unbilled time blocks waiting to be billed. Pick a customer above, or click a row to drill in.",
"empty": "No unbilled hours right now — everything is billed or no time has been logged yet.",
"entryLine_one": "{{count}} entry · {{hours}}h",
"entryLine_other": "{{count}} entries · {{hours}}h",
"passive": "Passive",
"needsRate": "Rate not set"
}
},
"crmDev": {
"title": "CRM Development",
@@ -3471,6 +3581,9 @@
"send": "Send",
"resend": "Resend",
"convert": "Convert to event",
"declineOnBehalf": "Decline on behalf",
"declineReasonPrompt": "Mark this quote as declined on behalf of the customer? Optionally note why (leave blank to skip).",
"declinedOnBehalfToast": "Quote marked as declined.",
"field": {
"issueDate": "Issued",
"validUntil": "Valid until",
@@ -3479,6 +3592,7 @@
"sentAt": "Sent at",
"acceptedAt": "Accepted at",
"declinedAt": "Declined at",
"declineReason": "Decline reason",
"responseWindow": "Response window",
"eventTimeStart": "Start time",
"eventTimeEnd": "End time",
@@ -3594,6 +3708,7 @@
"customer": "Customer",
"event": "Event",
"installment": "Installment",
"issueDate": "Issued",
"dueDate": "Due",
"total": "Total",
"status": "Status"
@@ -3635,6 +3750,8 @@
"field": {
"issueDate": "Issued",
"dueDate": "Due",
"dueDateOverrideOn": "Manual due date — untick to auto-set from send date + payment term",
"dueDateOverrideOff": "Auto from send date + payment term — tick to set manually",
"scheduledSendAt": "Scheduled send",
"installment": "Installment",
"total": "Total",
@@ -3657,6 +3774,7 @@
"selectTiming": "— Select schedule —",
"eventName": "Event",
"eventDate": "Event date",
"eventNamePlaceholder": "e.g. Smith wedding 2024",
"eventTimeStart": "Start time",
"eventTimeEnd": "End time",
"customer": "Customer",
@@ -3689,6 +3807,7 @@
"payment": {
"paidAt": "Date",
"amount": "Amount",
"date": "Payment date",
"method": "Method",
"reference": "Reference",
"notes": "Notes",
@@ -3727,6 +3846,27 @@
"savedToast": "Business profile saved.",
"title": "Business profile",
"subtitle": "Issuer block shown on every quote and invoice PDF.",
"businessHours": {
"title": "Business hours",
"subtitle": "Set opening hours per weekday — add a second block for a lunch break. Interpreted in the timezone above ({{tz}}).",
"closed": "Closed",
"addHours": "Add hours",
"addBlock": "Add another block",
"copyToAll": "Copy to all days",
"startTime": "Opening time",
"endTime": "Closing time",
"floorToggle": "Hold scheduled emails until business hours",
"floorToggleHelp": "When on, an automated email scheduled outside the hours above is delivered at the next opening instead of at an odd hour. When off, scheduled emails send at their exact time.",
"weekday": {
"1": "Monday",
"2": "Tuesday",
"3": "Wednesday",
"4": "Thursday",
"5": "Friday",
"6": "Saturday",
"7": "Sunday"
}
},
"section": {
"company": "Company",
"contact": "Contact",
@@ -3752,6 +3892,9 @@
"timezone": "Timezone (IANA)",
"vatLabel": "VAT label (e.g. MwSt., VAT)",
"vatRateDefault": "Default VAT rate %",
"defaultHourlyRate": "Default hourly rate",
"defaultHourlyRatePlaceholder": "e.g. 120.00",
"defaultHourlyRateHint": "Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.",
"defaultQrFormat": "Default invoice QR",
"footerLine": "PDF footer line"
},
@@ -3782,7 +3925,11 @@
"quotes": "Quotes",
"invoices": "Invoices",
"paymentDefaults": "Default payment conditions",
"installmentDefaults": "Default installment triggers"
"installmentDefaults": "Default installment triggers",
"contracts": "Contracts",
"quotesTos": "Terms of Service / AGB step",
"dashboardOverview": "Dashboard CRM overview",
"dashboardOverviewHint": "Hide CRM overview tiles on the admin dashboard. All tiles render by default; uncheck to hide."
},
"paymentDefaults": {
"help": "Pre-filled on every new quote and invoice. The editor still lets you pick a different combination per document.",
@@ -3857,6 +4004,48 @@
},
"crm_invoices_late_fee_enabled": {
"label": "Add a late fee on the second reminder"
},
"crm_quotes_tos_required": {
"label": "Require customers to tick \"I accept the Terms of Service\" before accepting"
},
"crm_quotes_tos_url": {
"label": "Terms of Service URL (optional)"
},
"crm_quotes_tos_text": {
"label": "Inline Terms text shown on the quote page",
"placeholder": "Paste the contract terms here. Plain text. Leave empty to only show the checkbox + URL."
},
"crm_contracts_pdf_attachment_enabled": {
"label": "Attach contract PDF to email"
},
"crm_contracts_require_drawn_signature": {
"label": "Require drawn signature (typed name alone is not enough)"
},
"crm_contracts_allow_pdf_upload": {
"label": "Allow customer to upload a wet-signed PDF"
},
"crm_contracts_store_ip": {
"label": "Store signer's IP address (recommended — corroborating evidence in civil disputes)",
"help": "When off, the customer's and admin's IP at signing time is NOT recorded into the contract row or the public sign-page audit confirmation. Per GDPR data-minimisation principle some operators prefer this — but IP is corroborating identity evidence if the contract is challenged, so we recommend keeping it on."
},
"crm_contracts_default_valid_days": {
"label": "Signing window (days)"
},
"crm_contracts_number_format": {
"label": "Contract number format",
"help": "Supported tokens: {YEAR}, {MONTH}, {SEQ:04d}. Example: LBM-C-{YEAR}-{SEQ:04d} → LBM-C-2026-0001."
},
"crm_overview_show_revenue": {
"label": "Revenue tiles (30 / 90 / 365 days)"
},
"crm_overview_show_outstanding": {
"label": "Outstanding payments tile"
},
"crm_overview_show_quotes": {
"label": "Quotes pipeline (per-status)"
},
"crm_overview_show_invoices": {
"label": "Invoices pipeline (per-status)"
}
},
"quoteResponse": {
@@ -3986,6 +4175,7 @@
"new": "New contract"
},
"detail": {
"previewPdf": "Preview PDF",
"integrity": {
"title": "PDF integrity check",
"help": "Re-hashes the unsigned + signed PDFs on disk and compares them to the SHA-256 stored when the document was issued. Catches backup corruption or manual edits since the customer received their copy.",
@@ -4000,7 +4190,108 @@
"expected": "expected",
"actual": "actual",
"error": "Integrity check failed."
}
},
"alreadyEventToast": "Already linked to an event.",
"auditEmpty": "No audit-log entries yet.",
"auditTrail": "Audit trail",
"auditTrailHelp": "Every event recorded on this contract. The list is append-only and is the source of truth if the contract is challenged.",
"back": "Back to list",
"blocks": "Included blocks",
"cancel": "Cancel",
"cancelConfirm": "Cancel this contract? Customer signing link will be invalidated.",
"cancelError": "Cancel failed",
"cancelledToast": "Contract cancelled.",
"clearSignature": "Clear",
"confirmConvertEvent": "Convert this contract into an event + scheduled invoices?",
"confirmConvertInvoice": "Convert this contract into invoice(s) only? No gallery / event will be created.",
"confirmCountersign": "Counter-sign",
"confirmResendSigned": "Re-send the signed contract PDF to both parties?",
"confirmRestamp": "Re-stamp & re-render PDF",
"convertError": "Convert failed",
"convertToEvent": "Convert to event",
"convertToInvoice": "Convert to invoice only",
"convertedToEvent": "Converted to event",
"convertedToEventToast": "Contract converted to event #{{id}}",
"convertedToInvoiceToast": "{{count}} invoice(s) created from this contract",
"countersignError": "Counter-sign failed",
"countersignHelp": "Type your name AND draw your signature below — both are stamped onto the re-rendered PDF. IP and timestamp are recorded for audit.",
"countersignSignaturePrompt": "Draw your signature",
"countersignTitle": "Counter-sign to make it binding",
"countersignedToast": "Counter-signed.",
"customer": "Customer",
"dates": "Dates",
"downloadPdf": "Download PDF",
"downloadSignedPdf": "Download signed PDF",
"edit": "Edit",
"fromQuote": "From quote",
"issued": "Issued",
"linkedInvoice": "Invoice",
"newInvoice": "New invoice",
"noBlocks": "No blocks included.",
"noSignatureImage": "No signature image captured — use \"Re-stamp signatures\" below to add one.",
"notFound": "Contract not found.",
"parties": "Parties",
"popupBlocked": "Allow pop-ups for this site to preview the PDF.",
"renderFailedBody": "The signature evidence is recorded, but the stamped PDF was not generated on the last attempt. Click \"Re-send signed PDF\" above to re-stamp from the original document and resend.",
"renderFailedTitle": "Signed PDF stamp failed — re-stamp required",
"resendError": "Resend failed",
"resendSigned": "Re-send signed PDF",
"resentSignedToast": "Signed contract re-sent to both parties.",
"restampAdmin": "Admin signature",
"restampCustomer": "Customer signature",
"restampError": "Re-stamp failed",
"restampHelp": "One or both signatures didn't capture an image. Draw the missing signature(s) here and we'll re-render the PDF. The typed names, timestamps, and IPs already on file stay untouched.",
"restampTitle": "Re-stamp missing signatures",
"restampedToast": "Signatures re-stamped and PDF re-rendered.",
"send": "Send to customer",
"sendError": "Send failed",
"sentAt": "Sent at",
"sentToast": "Contract sent.",
"signBy": "Sign by",
"signatures": "Signatures",
"signedByAdmin": "Counter-signed",
"signedByCustomer": "Signed by customer",
"signedNamePlaceholder": "Your full name",
"uploadError": "Upload failed",
"uploadSigned": "Upload signed PDF",
"uploadedToast": "Signed PDF uploaded."
},
"editor": {
"back": "Back to list",
"backToDetail": "Back to contract",
"create": "Create draft",
"createdToast": "Contract created.",
"customer": "Customer",
"disclaimerBody": "The seeded block bodies are EXAMPLES ONLY — written by the maintainer, not by a lawyer. Have your own lawyer review every block you include before sending the contract. See docs/crm-disclaimers.md.",
"disclaimerTitle": "Lawyer review required",
"eventDate": "Event date",
"eventHelp": "Snapshotted onto the contract and propagated to any event / invoice generated from it. Set this so the customer portal and dunning emails show the right \"Wedding Doe / Müller\" label.",
"eventName": "Event name",
"eventNamePlaceholder": "e.g. Wedding Doe / Müller",
"eventSection": "Event (optional)",
"eventTimeEnd": "End",
"eventTimeStart": "Start",
"intro": "Intro text (optional)",
"issueDate": "Issue date",
"language": "Language",
"locked": "Sent contracts cannot be edited. Cancel and create a fresh one for amendments.",
"noBlocksInSection": "No blocks for this section yet.",
"outro": "Closing text (optional)",
"popupBlocked": "Allow pop-ups for this site to preview the PDF.",
"preview": "Preview PDF",
"previewAfterSave": "Save the draft first, then preview.",
"previewError": "Preview failed",
"save": "Save",
"saveError": "Save failed",
"savedToast": "Contract saved.",
"schriftformWarning": "Signature type: simple electronic signature (SES). Sufficient for routine photography contracts in CH / DE / AT / FL. NOT sufficient for documents that legally require Schriftform / form qualifiée: Bürgschaft (DE § 766 BGB), Verbraucherdarlehensvertrag (DE § 492 BGB), befristete Arbeitsverträge (DE § 14 Abs. 4 TzBfG), and similar. For those, a qualified electronic signature (QES) from a Trust Service Provider is required — picpeak does not provide QES.",
"searchCustomer": "Search by email…",
"systemBadge": "System",
"titleEdit": "Edit contract",
"titleField": "Contract title",
"titleNew": "New contract",
"titlePlaceholder": "e.g. Wedding contract Doe / Müller",
"validUntil": "Sign by (optional)"
}
}
}
+12 -4
View File
@@ -7,6 +7,7 @@ import { Card, CardContent, Input, Button, Loading } from '../components/common'
import { useGalleryAuth } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { usePublicSettings } from '../hooks/usePublicSettings';
import { usePublicDarkMode } from '../hooks/usePublicDarkMode';
import { buildResourceUrl } from '../utils/url';
export const ClientAccessPage: React.FC = () => {
@@ -22,6 +23,13 @@ export const ClientAccessPage: React.FC = () => {
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug);
const { data: settingsData } = usePublicSettings();
// Theme-aware logo: the page background follows the themed
// --color-background (dark when branding_force_color_mode / OS is dark),
// so pick the dark logo variant accordingly.
const { isDark } = usePublicDarkMode();
const lightLogo = settingsData?.branding_logo_url?.trim();
const darkLogo = settingsData?.branding_logo_url_dark?.trim();
const brandLogo = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
// If already authenticated as client, redirect to gallery
React.useEffect(() => {
@@ -76,10 +84,10 @@ export const ClientAccessPage: React.FC = () => {
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex flex-col">
{settingsData?.branding_logo_url && (
{brandLogo && (
<div className="p-8 text-center">
<img
src={buildResourceUrl(settingsData.branding_logo_url)}
src={buildResourceUrl(brandLogo)}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
@@ -103,10 +111,10 @@ export const ClientAccessPage: React.FC = () => {
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex flex-col">
{/* Logo */}
{settingsData?.branding_logo_url && (
{brandLogo && (
<div className="p-8 text-center">
<img
src={buildResourceUrl(settingsData.branding_logo_url)}
src={buildResourceUrl(brandLogo)}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
+13 -4
View File
@@ -8,6 +8,7 @@ import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { useAdminAuth } from '../../contexts';
import { authService } from '../../services/auth.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
import { resolveLoginLogoClasses } from '../../utils/loginLogoSize';
import { api } from '../../config/api';
@@ -27,12 +28,20 @@ export const AdminLoginPage: React.FC = () => {
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
const { data: settingsData } = usePublicSettings();
const { isDark } = useAdminDarkMode();
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = settingsData?.branding_logo_url?.trim();
const resolvedLogoUrl = logoUrl
? (logoUrl.startsWith('http') ? logoUrl : logoUrl)
: '/picpeak-logo-transparent.png';
// Theme-aware logo: the login page honours the admin dark-mode preference
// (and any branding_force_color_mode). NOTE the frame nuance — a framed
// logo sits on a fixed cream plate (see render), so the light (dark-ink)
// logo always reads there; only the frameless logo sits on the themed
// (possibly dark) page background and needs the dark variant.
const lightLogo = settingsData?.branding_logo_url?.trim();
const darkLogo = settingsData?.branding_logo_url_dark?.trim();
const loginFrameEnabled = settingsData?.branding_login_logo_frame_enabled !== false;
const themedLogo = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
const logoUrl = loginFrameEnabled ? (lightLogo || darkLogo) : themedLogo;
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
// Check for session expired message
useEffect(() => {
+3 -1
View File
@@ -19,10 +19,12 @@ import { Button, Input, Card, Loading } from '../../components/common';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { archiveService } from '../../services/archive.service';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
// import { useNavigate } from 'react-router-dom';
export const ArchivesPage: React.FC = () => {
const { t } = useTranslation();
const { formatTime: fmtTime } = useLocalizedDate();
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState<string>('all');
const [sortBy, setSortBy] = useState<'date' | 'size' | 'name'>('date');
@@ -286,7 +288,7 @@ export const ArchivesPage: React.FC = () => {
<div>
<p>{formatDate(archive.archivedAt, 'MMM d, yyyy') || t('archives.processing')}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
{formatDate(archive.archivedAt, 'h:mm a')}
{archive.archivedAt ? fmtTime(archive.archivedAt) : ''}
</p>
</div>
</td>
@@ -15,10 +15,10 @@ import {
} from 'lucide-react';
import { toast } from 'react-toastify';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { Button, Card, Loading } from '../../components/common';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { BackupDashboard } from '../../components/admin/BackupDashboard';
import { BackupConfiguration } from '../../components/admin/BackupConfiguration';
import { BackupHistory } from '../../components/admin/BackupHistory';
@@ -33,6 +33,7 @@ export const BackupManagement: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
const queryClient = useQueryClient();
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const tabs = [
{ id: 'dashboard' as const, label: t('backup.tabs.dashboard'), icon: HardDrive },
@@ -122,7 +123,7 @@ export const BackupManagement: React.FC = () => {
<>
<CheckCircle className="h-5 w-5 text-green-500" />
<span className="text-neutral-700 dark:text-neutral-300">
{t('backup.status.lastBackup')}: {format(new Date(backupStatus.lastBackup.created_at), 'PPp')}
{t('backup.status.lastBackup')}: {fmtDateTime(backupStatus.lastBackup.created_at)}
</span>
</>
) : (
+87 -1
View File
@@ -12,6 +12,7 @@ import { buildResourceUrl } from '../../utils/url';
import { useFeatureEnabled, useFeatureFlags } from '../../contexts/FeatureFlagsContext';
import { CustomerDashboardBrandingCard } from '../../components/admin/CustomerDashboardBrandingCard';
import { PdfTypographyCard } from '../../components/admin/PdfTypographyCard';
import { usePublicSettings } from '../../hooks/usePublicSettings';
export const BrandingPage: React.FC = () => {
const { t } = useTranslation();
@@ -260,6 +261,47 @@ export const BrandingPage: React.FC = () => {
});
};
// Dark-mode logo — self-contained (the upload endpoint persists
// branding_logo_url_dark directly; not part of the theme payload).
// Consumers (admin header, gallery) pick it when the theme is dark.
const { data: pubSettings } = usePublicSettings();
const [logoDarkUrl, setLogoDarkUrl] = useState('');
useEffect(() => {
if (pubSettings?.branding_logo_url_dark !== undefined) {
setLogoDarkUrl(pubSettings.branding_logo_url_dark || '');
}
}, [pubSettings?.branding_logo_url_dark]);
const refreshSettings = () => {
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
};
const handleDarkLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const url = await settingsService.uploadLogo(file, 'dark');
setLogoDarkUrl(url);
refreshSettings();
toast.success(t('toast.uploadSuccess'));
} catch (error) {
console.error('Failed to upload dark logo:', error);
toast.error(t('toast.uploadError'));
}
};
const handleRemoveDarkLogo = async () => {
try {
await settingsService.removeLogo('dark');
setLogoDarkUrl('');
refreshSettings();
} catch (error) {
console.error('Failed to remove dark logo:', error);
toast.error(t('toast.saveError'));
}
};
const handleWatermarkLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
@@ -621,6 +663,50 @@ export const BrandingPage: React.FC = () => {
{t('branding.logoHelp', 'PNG, JPG or SVG format, recommended width: 200px')}
</p>
</div>
{/* Dark-mode logo */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.logoDark', 'Dark-mode logo')}
</label>
<div className="flex items-center gap-4">
{logoDarkUrl && (
<div className="relative">
<img
src={logoDarkUrl.startsWith('http') ? logoDarkUrl : buildResourceUrl(logoDarkUrl)}
alt="Dark logo"
className="h-16 object-contain bg-neutral-800 rounded p-2"
/>
<button
type="button"
onClick={handleRemoveDarkLogo}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
>
×
</button>
</div>
)}
<div>
<input
type="file"
accept="image/png,image/jpeg,image/svg+xml"
onChange={handleDarkLogoUpload}
className="hidden"
id="logo-dark-upload"
/>
<Button
variant="secondary"
size="sm"
onClick={() => document.getElementById('logo-dark-upload')?.click()}
leftIcon={<Upload className="w-4 h-4" />}
>
{logoDarkUrl ? t('branding.changeLogo', 'Change Logo') : t('branding.uploadLogo', 'Upload Logo')}
</Button>
</div>
</div>
<p className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
{t('branding.logoDarkHelp', 'Optional. Shown on dark themes / dark mode; falls back to the main logo when unset.')}
</p>
</div>
{/* Logo Size */}
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
@@ -1038,7 +1124,7 @@ export const BrandingPage: React.FC = () => {
</h3>
<GalleryPreview
theme={currentTheme}
branding={brandingSettings}
branding={{ ...brandingSettings, logo_url_dark: logoDarkUrl }}
className="shadow-lg"
/>
</Card>
+2 -2
View File
@@ -16,7 +16,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
export const CMSPage: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const { formatDateTime: fmtDateTime, formatTime: fmtTime } = useLocalizedDate();
const queryClient = useQueryClient();
const [selectedPage, setSelectedPage] = useState<string>('impressum');
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en');
@@ -592,7 +592,7 @@ export const CMSPage: React.FC = () => {
{!hasUnsavedChanges && lastSaved && (
<div className="flex items-center gap-2 text-green-600">
<Clock className="w-4 h-4" />
Saved {new Date(lastSaved).toLocaleTimeString()}
Saved {fmtTime(new Date(lastSaved))}
</div>
)}
</div>
+7 -13
View File
@@ -15,7 +15,7 @@ import {
import { addDays } from 'date-fns';
import { toast } from 'react-toastify';
import { Button, Input, Card, PasswordGenerator } from '../../components/common';
import { Button, Input, Card, PasswordGenerator, LocalizedDateInput, TimeField } from '../../components/common';
import { ThemeCustomizerEnhanced, GalleryPreview, WelcomeMessageEditor, FeedbackSettings } from '../../components/admin';
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
import { useMutation, useQuery } from '@tanstack/react-query';
@@ -587,13 +587,11 @@ export const CreateEventPage: React.FC = () => {
leftIcon={<Calendar className="w-5 h-5" />}
/>
<Input
type="date"
<LocalizedDateInput
label={requireEventDate ? t('events.eventDate') : `${t('events.eventDate')} (${t('common.optional')})`}
value={formData.event_date}
onChange={handleInputChange('event_date')}
onChange={(iso) => setFormData(prev => ({ ...prev, event_date: iso }))}
error={errors.event_date}
leftIcon={<Calendar className="w-5 h-5" />}
/>
</div>
@@ -615,19 +613,15 @@ export const CreateEventPage: React.FC = () => {
</label>
{!formData.is_full_day && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
type="time"
step={900}
<TimeField
label={t('events.eventTimeStart', 'Start time') as string}
value={formData.event_time_start}
onChange={handleInputChange('event_time_start')}
onChange={(v) => setFormData(prev => ({ ...prev, event_time_start: v }))}
/>
<Input
type="time"
step={900}
<TimeField
label={t('events.eventTimeEnd', 'End time') as string}
value={formData.event_time_end}
onChange={handleInputChange('event_time_end')}
onChange={(v) => setFormData(prev => ({ ...prev, event_time_end: v }))}
/>
</div>
)}
+66 -42
View File
@@ -19,7 +19,7 @@ import {
Clock,
} from 'lucide-react';
import { Button, Card, Input, Loading } from '../../components/common';
import { Button, Card, CountrySelect, Input, Loading } from '../../components/common';
import { SUPPORTED_LANGUAGES } from '../../components/common/LanguageSelector';
import { DecimalInput } from '../../components/common/DecimalInput';
import { AssignedEventsDialog } from '../../components/admin/AssignedEventsDialog';
@@ -40,7 +40,7 @@ type EditableFields =
| 'addressLine1' | 'addressLine2' | 'postalCode' | 'city' | 'state'
| 'countryCode' | 'countryName' | 'preferredLanguage' | 'notes'
| 'featureCalendar' | 'featureQuotes' | 'featureBills' | 'featureHoursLogging'
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay';
| 'hourlyRateMinor' | 'billingCadence' | 'billingCycleDay' | 'skontoDisabled';
// `fmtDate` (from useLocalizedDate, below) is the single canonical date
// formatter. It honors the admin's `general_date_format` setting AND
@@ -77,7 +77,7 @@ export const CustomerDetailPage: React.FC = () => {
queryKey: ['admin-customer-monthly-draft', customerId],
queryFn: () => customerAdminService.getMonthlyDraft(customerId),
enabled: Number.isFinite(customerId) && customerId > 0
&& (customer?.billingCadence === 'monthly'),
&& (customer?.billingCadence === 'monthly' || customer?.billingCadence === 'manual'),
});
const monthlyDraft = monthlyDraftRes?.draft || null;
@@ -140,6 +140,7 @@ export const CustomerDetailPage: React.FC = () => {
hourlyRateMinor: customer.hourlyRateMinor ?? null,
billingCadence: customer.billingCadence ?? 'per_event',
billingCycleDay: customer.billingCycleDay ?? 1,
skontoDisabled: customer.skontoDisabled ?? false,
} as any);
}
}, [customer, form]);
@@ -538,27 +539,17 @@ export const CustomerDetailPage: React.FC = () => {
<Input value={form.state || ''} onChange={setField('state')} />
</div>
<div>
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.countryCode', 'Country abbreviation (FL, CH, DE …)')}</label>
<Input
<CountrySelect
label={t('customers.detail.country', 'Country') as string}
value={form.countryCode || ''}
onChange={setField('countryCode')}
maxLength={2}
placeholder="FL"
/>
</div>
<div>
{/* Free-text country name override (migration 107). When
left empty the PDF renderer falls back to the locale-
aware lookup on the abbreviation; useful when the
abbreviation isn't an ISO code (e.g. "FL" for
Liechtenstein, which is "LI" in ISO). */}
<label className="block text-sm font-medium text-theme mb-1">{t('customers.detail.countryName', 'Country (full name)')}</label>
<Input
value={form.countryName || ''}
onChange={setField('countryName')}
placeholder="Liechtenstein"
onChange={(code) => setForm((prev) => ({ ...prev, countryCode: code }))}
/>
</div>
{/* The free-text "Country (full name)" override (migration 107) was
removed as redundant the country picker stores the ISO code and
the PDF renderer derives the localized full name from it
(pdfService.countryName). The DB column + the `country_name ||`
fallback stay, so any legacy override still renders. */}
</div>
</Card>
@@ -717,9 +708,10 @@ export const CustomerDetailPage: React.FC = () => {
<option value="per_event">{t('customers.billing.perEvent', 'Per event')}</option>
<option value="monthly">{t('customers.billing.monthly', 'Monthly')}</option>
<option value="quarterly">{t('customers.billing.quarterly', 'Quarterly')}</option>
<option value="manual">{t('customers.billing.manual', 'Manual (trigger only)')}</option>
</select>
</div>
{form.billingCadence && form.billingCadence !== 'per_event' && (
{(form.billingCadence === 'monthly' || form.billingCadence === 'quarterly') && (
<div>
<label className="block text-sm font-medium text-theme mb-1">
{t('customers.billing.cycleDay', 'Cycle day')}
@@ -740,26 +732,50 @@ export const CustomerDetailPage: React.FC = () => {
)}
</div>
{/* Per-customer Skonto opt-out (migration 112). For B2B
customers who negotiated "no early-payment discount" set
once instead of ticking the per-invoice toggle every time. */}
<label className="mt-4 flex items-start gap-2 text-sm text-theme">
<input
type="checkbox"
checked={!!form.skontoDisabled}
onChange={(e) => setForm((prev) => ({ ...prev, skontoDisabled: e.target.checked } as any))}
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600"
/>
<span>
{t('customers.billing.skontoDisabled', 'No Skonto for this customer')}
<span className="block text-xs text-muted-theme">
{t('customers.billing.skontoDisabledHint',
'Disables the early-payment discount on all of this customers invoices, regardless of template or global defaults.')}
</span>
</span>
</label>
{/* Preview of the open monthly draft (migration 128). Shows
every line item queued for the customer's current billing
period so admin sees exactly what "Trigger invoice now"
would ship. Hidden when no draft exists yet (admin hasn't
saved anything onto the period). */}
{form.billingCadence === 'monthly' && monthlyDraft && monthlyDraft.lineItems.length > 0 && (
{(form.billingCadence === 'monthly' || form.billingCadence === 'manual') && monthlyDraft && monthlyDraft.lineItems.length > 0 && (
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-theme">
{t('customers.billing.draftPreview.title',
'Pending in this month\'s bill')}
{form.billingCadence === 'manual'
? t('customers.billing.draftPreview.titleManual',
'Pending — ships on manual trigger')
: t('customers.billing.draftPreview.title',
'Pending in this month\'s bill')}
</h3>
<span className="text-xs text-muted-theme">
{t('customers.billing.draftPreview.periodRange',
'{{number}} · {{from}} {{to}}',
{
number: monthlyDraft.invoiceNumber,
from: fmtDate(monthlyDraft.periodStart),
to: fmtDate(monthlyDraft.periodEnd),
})}
{monthlyDraft.periodStart && monthlyDraft.periodEnd
? t('customers.billing.draftPreview.periodRange',
'{{number}} · {{from}} {{to}}',
{
number: monthlyDraft.invoiceNumber,
from: fmtDate(monthlyDraft.periodStart),
to: fmtDate(monthlyDraft.periodEnd),
})
: monthlyDraft.invoiceNumber}
</span>
</div>
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
@@ -816,20 +832,25 @@ export const CustomerDetailPage: React.FC = () => {
</div>
)}
{/* Manual trigger issue the running monthly draft NOW
instead of waiting for the cadence-day scheduler tick.
Only shown for monthly-mode customers (per-event has no
draft to arm; the equivalent action there is "Bill these
hours" on the standalone Hours-logging page). */}
{form.billingCadence === 'monthly' && (
{/* Manual trigger issue the running draft NOW. For monthly
customers this bypasses the cadence-day scheduler tick; for
manual-cadence customers it's the ONLY way the draft ships
(the scheduler never auto-flushes a manual draft). Per-event
has no draft to arm; the equivalent action there is "Bill
these hours" on the standalone Hours-logging page. */}
{(form.billingCadence === 'monthly' || form.billingCadence === 'manual') && (
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<Button
variant="outline"
disabled={triggerMonthlyBillMutation.isPending}
isLoading={triggerMonthlyBillMutation.isPending}
onClick={() => {
if (window.confirm(t('customers.billing.triggerConfirm',
'Issue this customer\'s monthly bill now? The customer receives the email immediately.') as string)) {
const confirmMsg = form.billingCadence === 'manual'
? t('customers.billing.triggerConfirmManual',
'Issue this customer\'s accumulated bill now? The customer receives the email immediately.')
: t('customers.billing.triggerConfirm',
'Issue this customer\'s monthly bill now? The customer receives the email immediately.');
if (window.confirm(confirmMsg as string)) {
triggerMonthlyBillMutation.mutate();
}
}}
@@ -837,8 +858,11 @@ export const CustomerDetailPage: React.FC = () => {
{t('customers.billing.triggerNow', 'Trigger invoice now')}
</Button>
<p className="text-xs text-muted-theme mt-2">
{t('customers.billing.triggerHint',
'Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.')}
{form.billingCadence === 'manual'
? t('customers.billing.triggerHintManual',
'Issues the running draft immediately. Manual-cadence drafts never ship automatically — this is the only way to send them. Refuses when nothing has been queued.')
: t('customers.billing.triggerHint',
'Bypasses the cadence day and issues the running draft immediately. Refuses when nothing has been queued for the current period.')}
</p>
</div>
)}
+56 -5
View File
@@ -18,6 +18,7 @@ import { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common';
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor';
import { SentEmailsPanel } from '../../components/admin/SentEmailsPanel';
import { Palette, RefreshCw, Info } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
@@ -129,7 +130,7 @@ The Photo Sharing Team`,
export const EmailConfigPage: React.FC = () => {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState<'smtp' | 'templates'>('smtp');
const [activeTab, setActiveTab] = useState<'smtp' | 'templates' | 'sent'>('smtp');
const [selectedTemplateKey, setSelectedTemplateKey] = useState<string>('gallery_created');
const [editedTemplate, setEditedTemplate] = useState<Partial<EmailTemplate>>({});
const [editingLang, setEditingLang] = useState<string>('en');
@@ -227,14 +228,23 @@ export const EmailConfigPage: React.FC = () => {
}, [selectedTemplate]);
// Mutations
// Surface the actual backend error (SMTP auth/connection failure, masked
// password, private-host rejection, …) instead of a generic toast — for
// email config these messages are the whole diagnosis.
const errMsg = (e: any, fallback: string): string =>
e?.response?.data?.error
|| e?.response?.data?.details
|| e?.message
|| fallback;
const saveConfigMutation = useMutation({
mutationFn: (config: EmailConfig) => emailService.updateConfig(config),
onSuccess: () => {
toast.success(t('toast.emailConfigSaved'));
queryClient.invalidateQueries({ queryKey: ['email-config'] });
},
onError: () => {
toast.error(t('toast.saveError'));
onError: (e: any) => {
toast.error(errMsg(e, t('toast.saveError')));
}
});
@@ -243,8 +253,22 @@ export const EmailConfigPage: React.FC = () => {
onSuccess: () => {
toast.success(t('email.testEmailSuccess'));
},
onError: () => {
toast.error(t('toast.saveError'));
onError: (e: any) => {
toast.error(errMsg(e, t('toast.saveError')));
}
});
const flushQueueMutation = useMutation({
mutationFn: () => emailService.flushQueue(),
onSuccess: (summary) => {
if (summary.processed === 0) {
toast.info(t('email.flushQueue.empty'));
} else {
toast.success(t('email.flushQueue.success', { sent: summary.sent, failed: summary.failed }));
}
},
onError: (e: any) => {
toast.error(errMsg(e, t('toast.saveError')));
}
});
@@ -462,9 +486,22 @@ export const EmailConfigPage: React.FC = () => {
>
{t('email.emailTemplates')}
</button>
<button
onClick={() => setActiveTab('sent')}
className={`py-2 px-1 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'sent'
? 'border-accent text-accent'
: 'border-transparent text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300'
}`}
>
{t('email.sentEmails.tab', 'Sent emails')}
</button>
</nav>
</div>
{/* Sent emails Tab */}
{activeTab === 'sent' && <SentEmailsPanel />}
{/* SMTP Settings Tab */}
{activeTab === 'smtp' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
@@ -665,6 +702,20 @@ export const EmailConfigPage: React.FC = () => {
</div>
</div>
</Card>
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('email.flushQueue.title')}</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">{t('email.flushQueue.help')}</p>
<Button
variant="outline"
onClick={() => flushQueueMutation.mutate()}
isLoading={flushQueueMutation.isPending}
leftIcon={<Send className="w-5 h-5" />}
className="w-full"
>
{t('email.flushQueue.button')}
</Button>
</Card>
</div>
)}
+26 -6
View File
@@ -18,6 +18,7 @@ import {
Key,
Mail,
MessageSquare,
Receipt,
Lock,
Eye,
EyeOff,
@@ -57,7 +58,7 @@ const safeParseDate = (dateValue: unknown): Date | null => {
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, Loading, MarkdownContent } from '../../components/common';
import { Button, Input, Card, Loading, MarkdownContent, LocalizedDateInput } from '../../components/common';
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, FocalPointPicker, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu, AdminGuestsList } from '../../components/admin';
import { CustomerAccountPicker } from '../../components/admin/CustomerAccountPicker';
import { EventReminderOverrideCard } from '../../components/admin/EventReminderOverrideCard';
@@ -244,7 +245,7 @@ export const EventDetailsPage: React.FC = () => {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { format, formatDateTime: fmtDateTime } = useLocalizedDate();
const { flags } = useFeatureFlags();
// Validate ID parameter
@@ -980,6 +981,26 @@ export const EventDetailsPage: React.FC = () => {
{t('feedback.manage', 'Manage Feedback')}
</Button>
)}
{/* Create a draft invoice for this event pre-fills the
bill editor with the event snapshot + (when exactly
one is linked) the customer. Gated on the bills flag. */}
{flags.bills && (
<Button
variant="outline"
size="sm"
leftIcon={<Receipt className="w-4 h-4" />}
onClick={() => {
const accts = ((event as { customer_accounts?: Array<{ id: number }> }).customer_accounts) || [];
const params = new URLSearchParams({ eventId: String(event.id) });
if (event.event_name) params.set('eventName', event.event_name);
if (event.event_date) params.set('eventDate', String(event.event_date).slice(0, 10));
if (accts.length === 1) params.set('customerAccountId', String(accts[0].id));
navigate(`/admin/clients/bills/new?${params.toString()}`);
}}
>
{t('events.createInvoice', 'Create invoice')}
</Button>
)}
</>
)}
</>
@@ -1194,10 +1215,9 @@ export const EventDetailsPage: React.FC = () => {
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('events.expirationDate')}
</label>
<Input
type="date"
<LocalizedDateInput
value={editForm.expires_at}
onChange={(e) => setEditForm(prev => ({ ...prev, expires_at: e.target.value }))}
onChange={(iso) => setEditForm(prev => ({ ...prev, expires_at: iso }))}
min={format(new Date(), 'yyyy-MM-dd')}
/>
</div>
@@ -2331,7 +2351,7 @@ export const EventDetailsPage: React.FC = () => {
<div>
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">{t('events.archivedOn')}</p>
<p className="text-sm text-neutral-900 dark:text-neutral-100">
{event.archived_at && format(safeParseDate(event.archived_at)!, 'PPp')}
{event.archived_at && fmtDateTime(safeParseDate(event.archived_at)!)}
</p>
</div>
@@ -25,13 +25,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { feedbackService } from '../../services/feedback.service';
import type { PhotoFeedback, FeedbackAnalytics, FeedbackResponse } from '../../services/feedback.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
export const EventFeedbackPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const [activeTab, setActiveTab] = useState<'settings' | 'feedback' | 'analytics' | 'moderation'>('settings');
const [feedbackFilter, setFeedbackFilter] = useState({
type: '',
@@ -297,7 +299,7 @@ export const EventFeedbackPage: React.FC = () => {
const d = typeof item.created_at === 'string'
? parseISO(item.created_at)
: new Date(item.created_at);
return isNaN(d.getTime()) ? t('common.unknownDate', 'Unknown date') : format(d, 'PPpp');
return isNaN(d.getTime()) ? t('common.unknownDate', 'Unknown date') : fmtDateTime(d);
})()}
</p>
</div>
@@ -0,0 +1,115 @@
/**
* Admin System health. Aggregates background failures that would
* otherwise go unnoticed. v1: stuck/failed outbound emails (the queue
* processor gave up or exhausted retries), with retry + dismiss.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { AlertCircle, RefreshCw, Trash2, CheckCircle } from 'lucide-react';
import { Button, Card, Loading } from '../../components/common';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { systemHealthService } from '../../services/systemHealth.service';
export const SystemHealthPage: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['system-health-failures'],
queryFn: () => systemHealthService.getFailures(),
});
const invalidate = () => qc.invalidateQueries({ queryKey: ['system-health-failures'] });
const retryMutation = useMutation({
mutationFn: (id: number) => systemHealthService.retryEmail(id),
onSuccess: () => { toast.success(t('systemHealth.retriedToast', 'Email re-queued.')); invalidate(); },
onError: () => toast.error(t('toast.saveError')),
});
const dismissMutation = useMutation({
mutationFn: (id: number) => systemHealthService.dismissEmail(id),
onSuccess: () => { toast.success(t('systemHealth.dismissedToast', 'Dismissed.')); invalidate(); },
onError: () => toast.error(t('toast.saveError')),
});
const stuckEmails = data?.stuckEmails ?? [];
return (
<div className="container py-6">
<div className="mb-6">
<h1 className="text-2xl font-bold text-theme">{t('systemHealth.title', 'System health')}</h1>
<p className="text-sm text-muted-theme mt-1">
{t('systemHealth.subtitle', 'Background failures that need attention.')}
</p>
</div>
<Card padding="lg">
<div className="flex items-center gap-2 mb-3">
<AlertCircle className="w-5 h-5 text-amber-500" />
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('systemHealth.stuckEmails.title', 'Stuck / failed emails')}
</h2>
{!isLoading && (
<span className="ml-1 text-sm text-muted-theme">({stuckEmails.length})</span>
)}
</div>
{isLoading ? <Loading /> : stuckEmails.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-green-700 dark:text-green-400 py-6">
<CheckCircle className="w-5 h-5" />
{t('systemHealth.stuckEmails.empty', 'No stuck or failed emails — all clear.')}
</div>
) : (
<div className="rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.recipient', 'Recipient')}</th>
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.type', 'Type')}</th>
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.error', 'Error')}</th>
<th className="px-3 py-2 text-left">{t('systemHealth.stuckEmails.col.queued', 'Queued')}</th>
<th className="px-3 py-2 text-right">{t('systemHealth.stuckEmails.col.actions', 'Actions')}</th>
</tr>
</thead>
<tbody>
{stuckEmails.map((m) => (
<tr key={m.id} className="border-t border-neutral-200 dark:border-neutral-700 align-top">
<td className="px-3 py-2 break-all">{m.recipientEmail}</td>
<td className="px-3 py-2 font-mono text-xs">{m.emailType}</td>
<td className="px-3 py-2 max-w-xs">
<span className="text-xs text-red-700 dark:text-red-400 break-words">
{m.errorMessage || t('systemHealth.stuckEmails.noError', 'retries exhausted')}
</span>
</td>
<td className="px-3 py-2 whitespace-nowrap">{m.createdAt ? fmtDateTime(m.createdAt) : '—'}</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1">
<Button variant="outline" size="sm"
isLoading={retryMutation.isPending && retryMutation.variables === m.id}
onClick={() => retryMutation.mutate(m.id)}
leftIcon={<RefreshCw className="w-3.5 h-3.5" />}>
{t('systemHealth.retry', 'Retry')}
</Button>
<button type="button"
aria-label={t('systemHealth.dismiss', 'Dismiss') as string}
onClick={() => dismissMutation.mutate(m.id)}
className="p-1.5 text-neutral-400 hover:text-red-600">
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</Card>
</div>
);
};
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Eye, Send, CheckCircle, BellRing, XCircle, Truck, Edit2, RefreshCw } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common';
import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common';
import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
import { billsService } from '../../../services/bills.service';
import { formatMoney } from '../../../components/admin/LineItemsTable';
@@ -29,6 +29,9 @@ export const BillDetailPage: React.FC = () => {
const [payDialogOpen, setPayDialogOpen] = useState(false);
const [payAmount, setPayAmount] = useState('');
// Optional payment date — defaults to today, backdate it to when the
// payment actually arrived. Drives `paid_at` (cash-basis revenue windows).
const [payDate, setPayDate] = useState(new Date().toISOString().slice(0, 10));
const [payMethod, setPayMethod] = useState('');
const [payReference, setPayReference] = useState('');
const [payNotes, setPayNotes] = useState('');
@@ -210,6 +213,7 @@ export const BillDetailPage: React.FC = () => {
try {
await billsService.markPaid(inv.id, {
amountMinor: Math.round(Number(payAmount) * 100),
paidAt: payDate || undefined,
paymentMethod: payMethod || undefined,
reference: payReference || undefined,
notes: payNotes || undefined,
@@ -218,6 +222,7 @@ export const BillDetailPage: React.FC = () => {
setPayDialogOpen(false);
setPayAmount(''); setPayMethod(''); setPayReference(''); setPayNotes('');
setPayWithSkonto(false);
setPayDate(new Date().toISOString().slice(0, 10));
qc.invalidateQueries({ queryKey: ['invoice', id] });
toast.success(t('bills.paymentRecordedToast', 'Payment recorded.'));
} catch (e: any) {
@@ -386,7 +391,14 @@ export const BillDetailPage: React.FC = () => {
<Card>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
{inv.eventName && (
<div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.eventName', 'Event')}</div><div>{inv.eventName}{inv.eventDate ? ` · ${inv.eventDate}` : ''}</div></div>
<div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.eventName', 'Event')}</div>
<div>
{inv.eventId ? (
<Link to={`/admin/events/${inv.eventId}`} className="text-theme hover:underline">{inv.eventName}</Link>
) : inv.eventName}
{inv.eventDate ? ` · ${fmtDate(inv.eventDate)}` : ''}
</div>
</div>
)}
<div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.issueDate', 'Issued')}</div><div>{fmtDate(inv.issueDate)}</div></div>
<div><div className="text-neutral-600 dark:text-neutral-300">{t('bills.field.dueDate', 'Due')}</div><div>{fmtDate(inv.dueDate)}</div></div>
@@ -459,6 +471,14 @@ export const BillDetailPage: React.FC = () => {
<div className="space-y-3">
<Input type="number" step="0.01" label={t('bills.payment.amount', 'Amount') as string} value={payAmount}
onChange={(e) => setPayAmount(e.target.value)} placeholder={String(outstanding.toFixed(2))} />
{/* Optional payment date drives `paid_at`, which the
dashboard's cash-basis revenue windows key on. Defaults
to today; backdate it to when the payment actually arrived. */}
<LocalizedDateInput
label={t('bills.payment.date', 'Payment date') as string}
value={payDate}
onChange={setPayDate}
/>
{/* Skonto checkbox (migration 126). Only surfaced when
the invoice's payment terms actually offer Skonto
the backend resolves skontoPercent from the snapshot
@@ -5,10 +5,10 @@
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Eye, Save as SaveIcon } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common';
import { Button, Card, Loading, Input, LocalizedDateInput, TimeField } from '../../../components/common';
import { billsService, type InvoiceCreatePayload, type InvoiceQrFormat } from '../../../services/bills.service';
import { quotesService } from '../../../services/quotes.service';
import { contractsService } from '../../../services/contracts.service';
@@ -20,7 +20,6 @@ import { customerAdminService } from '../../../services/customerAdmin.service';
import { userManagementService } from '../../../services/userManagement.service';
import { settingsService } from '../../../services/settings.service';
import { useAdminAuth } from '../../../contexts/AdminAuthContext';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { toast } from 'react-toastify';
function toMinor(amount: number) {
@@ -29,8 +28,6 @@ function toMinor(amount: number) {
export const BillEditorPage: React.FC = () => {
const { t } = useTranslation();
const { timeFormat } = useLocalizedDate();
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
const { id } = useParams<{ id?: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
@@ -48,6 +45,12 @@ export const BillEditorPage: React.FC = () => {
const [currency, setCurrency] = useState('CHF');
const [issueDate, setIssueDate] = useState(new Date().toISOString().slice(0, 10));
const [dueDate, setDueDate] = useState('');
// Due date is normally view-only: it auto-tracks (send date else issue
// date) + the selected Net-days template, so the payment clock starts
// on the day the invoice actually goes out. Flipping this lets the
// admin type a different date by hand; we keep it pinned so the auto
// effect below stops clobbering their value.
const [dueDateOverridden, setDueDateOverridden] = useState(false);
const [scheduledSendAt, setScheduledSendAt] = useState('');
// null = inherit profile default at render time. 'none' / 'swiss' /
// 'epc' = explicit per-invoice override. (Existing invoices that
@@ -80,6 +83,7 @@ export const BillEditorPage: React.FC = () => {
// event section — admin can type a free-text label without needing
// an actual events row, and it carries through to the customer
// portal + tax report + email templates.
const [eventId, setEventId] = useState<number | null>(null);
const [eventName, setEventName] = useState('');
const [eventDate, setEventDate] = useState('');
const [eventTimeStart, setEventTimeStart] = useState('');
@@ -124,6 +128,10 @@ export const BillEditorPage: React.FC = () => {
setCurrency(inv.currency);
setIssueDate(inv.issueDate);
setDueDate(inv.dueDate);
// The invoice already carries a due date — preserve it rather than
// letting the auto effect recompute and surprise the admin. They
// can untick "Override" to re-enable auto-tracking.
setDueDateOverridden(true);
setScheduledSendAt(inv.scheduledSendAt ? inv.scheduledSendAt.slice(0, 16) : '');
// Preserve null when the saved invoice has no explicit format —
// it inherits the profile default at render time.
@@ -136,6 +144,7 @@ export const BillEditorPage: React.FC = () => {
setPaymentTimingTemplateId(inv.paymentTimingTemplateId ?? null);
setBusinessBankAccountId(inv.businessBankAccountId ?? null);
setSkontoDisabled(Boolean(inv.skontoDisabled));
setEventId(inv.eventId ?? null);
setEventName(inv.eventName || '');
setEventDate(inv.eventDate || '');
setEventTimeStart(inv.eventTimeStart || '');
@@ -209,6 +218,23 @@ export const BillEditorPage: React.FC = () => {
})();
}, [isEdit, searchParams, customerId]);
// Pre-fill the event link + snapshot when opened from an event's
// "Create invoice" button (/admin/clients/bills/new?eventId=&eventName=&eventDate=).
const didPrefillEventRef = useRef(false);
useEffect(() => {
if (isEdit) return;
if (didPrefillEventRef.current) return;
const eidRaw = searchParams.get('eventId');
const eid = eidRaw ? parseInt(eidRaw, 10) : NaN;
const en = searchParams.get('eventName');
const ed = searchParams.get('eventDate');
if (!(Number.isFinite(eid) && eid > 0) && !en && !ed) return;
didPrefillEventRef.current = true;
if (Number.isFinite(eid) && eid > 0) setEventId(eid);
if (en) setEventName((prev) => prev || en);
if (ed) setEventDate((prev) => prev || ed);
}, [isEdit, searchParams]);
// Pre-fill from a fully-signed contract when the editor is opened
// via `?fromContractId=<id>` (the "New invoice" link on
// ContractDetailPage's header, used after the contract has been
@@ -317,6 +343,25 @@ export const BillEditorPage: React.FC = () => {
setPaymentTimingTemplateId((prev) => prev ?? defaultTiming.id);
}, [isEdit, netDaysTemplates, timingTemplates, appSettings]);
// Auto-track the due date off (scheduled send date else issue date) +
// the selected Net-days template, mirroring the backend's
// computeDueDate. The clock starts the day the invoice goes out, so
// scheduling a future send pushes the due date out with it. Skipped
// once the admin overrides the field by hand. Date math is in UTC to
// match the backend (which parses the YYYY-MM-DD base as UTC midnight).
useEffect(() => {
if (dueDateOverridden) return;
const base = (scheduledSendAt ? scheduledSendAt.slice(0, 10) : issueDate) || '';
if (!/^\d{4}-\d{2}-\d{2}$/.test(base)) return;
const tpl = netDaysTemplates?.templates?.find((t) => t.id === paymentNetDaysTemplateId);
const netDays = tpl?.netDays != null
? Number(tpl.netDays)
: Number(appSettings?.crm_payment_default_net_days) || 30;
const d = new Date(`${base}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + netDays);
setDueDate(d.toISOString().slice(0, 10));
}, [dueDateOverridden, scheduledSendAt, issueDate, paymentNetDaysTemplateId, netDaysTemplates, appSettings]);
const buildPayload = (): InvoiceCreatePayload => ({
customerAccountId: customerId || 0,
currency,
@@ -358,6 +403,7 @@ export const BillEditorPage: React.FC = () => {
// so the backend can distinguish "not provided" from a deliberate
// clear (which the route's `optional({ values: 'falsy' })` already
// treats identically — falsy values bypass validation entirely).
eventId: eventId ?? undefined,
eventName: eventName || undefined,
eventDate: eventDate || undefined,
eventTimeStart: eventTimeStart || undefined,
@@ -488,20 +534,38 @@ export const BillEditorPage: React.FC = () => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label={t('bills.field.eventName', 'Event') as string}
value={eventName} onChange={(e) => setEventName(e.target.value)} />
<Input type="date" label={t('bills.field.eventDate', 'Event date') as string}
value={eventDate} onChange={(e) => setEventDate(e.target.value)} />
<Input type="time" lang={timeInputLang} label={t('bills.field.eventTimeStart', 'Start time') as string}
value={eventTimeStart} onChange={(e) => setEventTimeStart(e.target.value)} />
<Input type="time" lang={timeInputLang} label={t('bills.field.eventTimeEnd', 'End time') as string}
value={eventTimeEnd} onChange={(e) => setEventTimeEnd(e.target.value)} />
<LocalizedDateInput label={t('bills.field.eventDate', 'Event date') as string}
value={eventDate} onChange={setEventDate} />
<TimeField label={t('bills.field.eventTimeStart', 'Start time') as string}
value={eventTimeStart} onChange={setEventTimeStart} />
<TimeField label={t('bills.field.eventTimeEnd', 'End time') as string}
value={eventTimeEnd} onChange={setEventTimeEnd} />
</div>
</Card>
<Card>
<h3 className="font-semibold mb-2">{t('bills.section.details', 'Details')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input type="date" label={t('bills.field.issueDate', 'Issue date') as string} value={issueDate} onChange={(e) => setIssueDate(e.target.value)} />
<Input type="date" label={t('bills.field.dueDate', 'Due date') as string} value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
<LocalizedDateInput label={t('bills.field.issueDate', 'Issue date') as string} value={issueDate} onChange={setIssueDate} />
<div>
<LocalizedDateInput
label={t('bills.field.dueDate', 'Due date') as string}
value={dueDate}
onChange={setDueDate}
disabled={!dueDateOverridden}
/>
<label className="mt-1.5 flex items-center gap-2 text-xs text-neutral-600 dark:text-neutral-400">
<input
type="checkbox"
checked={dueDateOverridden}
onChange={(e) => setDueDateOverridden(e.target.checked)}
className="rounded border-neutral-300 dark:border-neutral-600"
/>
{dueDateOverridden
? t('bills.field.dueDateOverrideOn', 'Manual due date — untick to auto-set from send date + payment term')
: t('bills.field.dueDateOverrideOff', 'Auto from send date + payment term — tick to set manually')}
</label>
</div>
<Input type="datetime-local" label={t('bills.field.scheduledSendAt', 'Scheduled send (optional)') as string}
value={scheduledSendAt} onChange={(e) => setScheduledSendAt(e.target.value)} />
<div>
@@ -589,6 +653,10 @@ export const BillEditorPage: React.FC = () => {
only has the single FK still resolve their preview text. */}
<Card>
<h3 className="font-semibold mb-2">{t('bills.section.payment', 'Payment conditions')}</h3>
<Link to="/admin/settings?tab=crm"
className="text-xs text-accent hover:underline mb-2 inline-block">
{t('common.configureInSettings', 'Configure defaults in Settings ↗')}
</Link>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium mb-1">{t('bills.field.paymentNetDays', 'Net days')}</label>
+46 -131
View File
@@ -8,7 +8,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Search, Upload, X } from 'lucide-react';
import { billsService, type InvoiceStatus, type InvoiceSort } from '../../../services/bills.service';
import { Button, Card, Input, Loading } from '../../../components/common';
import { Button, Card, Input, Loading, LocalizedDateInput, SortableHeader, useColumnSort, type SortColumnMap } from '../../../components/common';
import { formatMoney } from '../../../components/admin/LineItemsTable';
import { customerAdminService } from '../../../services/customerAdmin.service';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
@@ -16,6 +16,18 @@ import { toast } from 'react-toastify';
const STATUSES: InvoiceStatus[] = ['scheduled', 'pending_delivery', 'sent', 'paid', 'overdue', 'cancelled', 'skipped'];
// Maps each clickable column to its server-side sort enum pair. The "#"
// column sorts by creation order (newest/oldest) since that's how the
// invoice sequence is assigned; "Issued" sorts the admin-controlled
// issue_date and is the default (newest issued first).
const SORT_COLUMNS: SortColumnMap = {
number: { asc: 'oldest', desc: 'newest', defaultDir: 'desc' },
customer: { asc: 'customer_asc', desc: 'customer_desc' },
issue: { asc: 'issue_asc', desc: 'issue_desc', defaultDir: 'desc' },
due: { asc: 'due_asc', desc: 'due_desc' },
value: { asc: 'value_asc', desc: 'value_desc', defaultDir: 'desc' },
};
export const BillsListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -23,10 +35,12 @@ export const BillsListPage: React.FC = () => {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<InvoiceStatus[]>([]);
const [unpaidOnly, setUnpaidOnly] = useState(false);
const [sort, setSort] = useState<InvoiceSort>('newest');
const { sort, activeKey, activeDir, toggle } = useColumnSort<InvoiceSort>(SORT_COLUMNS, 'issue_desc');
const [page, setPage] = useState(1);
const [importOpen, setImportOpen] = useState(false);
const onSort = (key: string) => { toggle(key); setPage(1); };
const { data, isLoading } = useQuery({
queryKey: ['invoices', { search, statusFilter, unpaidOnly, sort, page }],
queryFn: () => billsService.list({
@@ -87,18 +101,6 @@ export const BillsListPage: React.FC = () => {
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
</div>
<select
className="px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={sort}
onChange={(e) => setSort(e.target.value as InvoiceSort)}
>
<option value="newest">{t('bills.sort.newest', 'Newest first')}</option>
<option value="due_asc">{t('bills.sort.dueAsc', 'Due soon first')}</option>
<option value="due_desc">{t('bills.sort.dueDesc', 'Due latest first')}</option>
<option value="customer_asc">{t('bills.sort.customerAsc', 'Customer A→Z')}</option>
<option value="value_asc">{t('bills.sort.valueAsc', 'Value low→high')}</option>
<option value="value_desc">{t('bills.sort.valueDesc', 'Value high→low')}</option>
</select>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={unpaidOnly} onChange={(e) => setUnpaidOnly(e.target.checked)} />
{t('bills.filter.unpaidOnly', 'Unpaid only')}
@@ -127,12 +129,13 @@ export const BillsListPage: React.FC = () => {
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left">#</th>
<th className="px-3 py-2 text-left">{t('bills.table.customer', 'Customer')}</th>
<SortableHeader label="#" columnKey="number" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<SortableHeader label={t('bills.table.customer', 'Customer')} columnKey="customer" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<th className="px-3 py-2 text-left">{t('bills.table.event', 'Event')}</th>
<th className="px-3 py-2 text-left">{t('bills.table.installment', 'Installment')}</th>
<th className="px-3 py-2 text-left">{t('bills.table.dueDate', 'Due')}</th>
<th className="px-3 py-2 text-right">{t('bills.table.total', 'Total')}</th>
<SortableHeader label={t('bills.table.issueDate', 'Issued')} columnKey="issue" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<SortableHeader label={t('bills.table.dueDate', 'Due')} columnKey="due" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<SortableHeader label={t('bills.table.total', 'Total')} columnKey="value" activeKey={activeKey} activeDir={activeDir} onSort={onSort} align="right" />
<th className="px-3 py-2 text-left">{t('bills.table.status', 'Status')}</th>
</tr>
</thead>
@@ -167,10 +170,17 @@ export const BillsListPage: React.FC = () => {
)}
</td>
<td className="px-3 py-2">{inv.customer.companyName || inv.customer.displayName || inv.customer.email}</td>
<td className="px-3 py-2 truncate max-w-xs">{inv.eventName || '—'}</td>
<td className="px-3 py-2 truncate max-w-xs">
{inv.eventName
? (inv.eventId
? <Link to={`/admin/events/${inv.eventId}`} className="text-theme hover:underline" onClick={(e) => e.stopPropagation()}>{inv.eventName}</Link>
: inv.eventName)
: '—'}
</td>
<td className="px-3 py-2 text-xs text-muted-theme">
{inv.installmentTotal > 1 ? `${inv.installmentIndex + 1}/${inv.installmentTotal} · ${inv.installmentLabel || ''}` : '—'}
</td>
<td className="px-3 py-2 whitespace-nowrap">{inv.issueDate ? fmtDate(inv.issueDate) : '—'}</td>
<td className="px-3 py-2">{fmtDate(inv.dueDate)}</td>
<td className="px-3 py-2 text-right tabular-nums">
{formatMoney(Number(inv.totalAmountMinor) / 100, inv.currency)}
@@ -215,6 +225,8 @@ const ImportHistoricalInvoiceModal: React.FC<ImportModalProps> = ({ onClose }) =
const [customerId, setCustomerId] = useState<number | null>(null);
const [customerLabel, setCustomerLabel] = useState('');
const [invoiceNumber, setInvoiceNumber] = useState('');
const [eventName, setEventName] = useState('');
const [eventDate, setEventDate] = useState('');
const [issueDate, setIssueDate] = useState(new Date().toISOString().slice(0, 10));
const [dueDate, setDueDate] = useState('');
const [totalMajor, setTotalMajor] = useState('');
@@ -239,6 +251,8 @@ const ImportHistoricalInvoiceModal: React.FC<ImportModalProps> = ({ onClose }) =
await billsService.importHistorical({
customerAccountId: customerId,
invoiceNumber,
eventName: eventName.trim() || undefined,
eventDate: eventDate || undefined,
issueDate,
dueDate: dueDate || undefined,
totalAmountMinor: Math.round(Number(totalMajor) * 100),
@@ -321,6 +335,17 @@ const ImportHistoricalInvoiceModal: React.FC<ImportModalProps> = ({ onClose }) =
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="md:col-span-2">
<Input label={t('bills.field.eventName', 'Event / occasion (optional)') as string}
value={eventName}
placeholder={t('bills.field.eventNamePlaceholder', 'e.g. Smith wedding 2024') as string}
onChange={(e) => setEventName(e.target.value)} />
</div>
<LocalizedDateInput
label={t('bills.field.eventDate', 'Event date (optional)') as string}
value={eventDate}
onChange={setEventDate}
/>
<Input label={t('bills.field.invoiceNumber', 'Invoice number') as string}
value={invoiceNumber}
placeholder="R-2024-0001"
@@ -329,12 +354,12 @@ const ImportHistoricalInvoiceModal: React.FC<ImportModalProps> = ({ onClose }) =
value={currency}
maxLength={3}
onChange={(e) => setCurrency(e.target.value.toUpperCase())} />
<LocalizedDateField
<LocalizedDateInput
label={t('bills.field.issueDate', 'Issued') as string}
value={issueDate}
onChange={setIssueDate}
/>
<LocalizedDateField
<LocalizedDateInput
label={t('bills.field.dueDate', 'Due') as string}
value={dueDate}
onChange={setDueDate}
@@ -392,113 +417,3 @@ const ImportHistoricalInvoiceModal: React.FC<ImportModalProps> = ({ onClose }) =
</div>
);
};
/**
* Date field that displays + accepts values in the admin-configured
* format from Settings General (`general_date_format`). Stores
* + emits ISO (YYYY-MM-DD) so the rest of the form / API surface
* keeps the canonical shape.
*
* Native `<input type="date">` always renders in the browser's
* locale (en-US users see MM/DD/YYYY), which mismatched what
* customers + the rest of the app see elsewhere. This component
* uses a plain text input + parses on blur, with the configured
* format shown as both placeholder and helper text. A small
* shadow native date input next to the field gives the click-to-
* open calendar without affecting the displayed format.
*/
interface LocalizedDateFieldProps {
label: string;
value: string;
onChange: (iso: string) => void;
}
const LocalizedDateField: React.FC<LocalizedDateFieldProps> = ({ label, value, onChange }) => {
const { dateFormat } = useLocalizedDate();
// Normalise the configured format down to the four shapes our
// parser understands. Defaults to DD.MM.YYYY (the maintainer's
// primary locale) when unknown.
const normalisedFormat = ((): 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' | 'YYYY-MM-DD' => {
const f = String(dateFormat || 'dd.MM.yyyy').toLowerCase();
if (f.startsWith('mm/dd')) return 'MM/DD/YYYY';
if (f.startsWith('yyyy')) return 'YYYY-MM-DD';
if (f.includes('/')) return 'DD/MM/YYYY';
return 'DD.MM.YYYY';
})();
const placeholder = normalisedFormat.toLowerCase();
// ISO → display
const toDisplay = (iso: string): string => {
if (!iso) return '';
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!m) return iso;
const [, y, mo, d] = m;
switch (normalisedFormat) {
case 'MM/DD/YYYY': return `${mo}/${d}/${y}`;
case 'YYYY-MM-DD': return `${y}-${mo}-${d}`;
case 'DD/MM/YYYY': return `${d}/${mo}/${y}`;
case 'DD.MM.YYYY':
default: return `${d}.${mo}.${y}`;
}
};
// display → ISO (accepts variant separators leniently)
const toIso = (raw: string): string => {
const s = raw.trim();
if (!s) return '';
// Already ISO?
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
// Split on . / -
const parts = s.split(/[./-]/);
if (parts.length !== 3) return '';
let [a, b, c] = parts;
let y: string, mo: string, d: string;
if (normalisedFormat === 'YYYY-MM-DD' || a.length === 4) {
[y, mo, d] = [a, b, c];
} else if (normalisedFormat === 'MM/DD/YYYY') {
[mo, d, y] = [a, b, c];
} else {
// DD.MM.YYYY or DD/MM/YYYY
[d, mo, y] = [a, b, c];
}
if (!/^\d{1,2}$/.test(d) || !/^\d{1,2}$/.test(mo) || !/^\d{4}$/.test(y)) return '';
return `${y}-${mo.padStart(2, '0')}-${d.padStart(2, '0')}`;
};
const [text, setText] = React.useState(toDisplay(value));
React.useEffect(() => { setText(toDisplay(value)); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [value]);
return (
<div>
<label className="block text-sm font-medium mb-1">{label}</label>
<div className="flex gap-2">
<Input
value={text}
placeholder={placeholder}
onChange={(e) => setText(e.target.value)}
onBlur={() => {
const iso = toIso(text);
if (iso) {
onChange(iso);
setText(toDisplay(iso));
} else if (!text.trim()) {
onChange('');
}
}}
/>
{/* Tiny native date picker shortcut gives the calendar
without polluting the visible text input. Hidden value
stays in ISO so it's always parseable. */}
<input
type="date"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
aria-label={label}
className="text-sm px-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800"
style={{ width: 36 }}
/>
</div>
<p className="text-xs text-neutral-500 mt-1">{placeholder}</p>
</div>
);
};
@@ -27,7 +27,7 @@ import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Lock, Trash2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Input } from '../../../components/common';
import { Button, Card, Input, TimeField } from '../../../components/common';
import { customerAdminService } from '../../../services/customerAdmin.service';
import type { CalendarHoursItem } from '../../../services/calendar.service';
@@ -176,23 +176,13 @@ export const HourEntryInlinePopover: React.FC<HourEntryInlinePopoverProps> = ({
<label className="block text-sm font-medium mb-1">
{t('calendar.hourEntry.startLabel', 'Start')}
</label>
<Input
type="time"
step={900}
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
/>
<TimeField value={startTime} onChange={setStartTime} />
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('calendar.hourEntry.endLabel', 'End')}
</label>
<Input
type="time"
step={900}
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
<TimeField value={endTime} onChange={setEndTime} />
</div>
</div>
<div>
@@ -13,6 +13,7 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Clock, ChevronRight, AlertTriangle } from 'lucide-react';
import { Card } from '../../../components/common';
import { HoursSection } from '../../../components/admin/HoursSection';
import {
@@ -22,7 +23,21 @@ import {
import {
customerAdminService,
type CustomerAccountDetail,
type UnbilledHoursSummaryRow,
} from '../../../services/customerAdmin.service';
import { businessProfileService } from '../../../services/businessProfile.service';
import { formatMoneyMinor } from '../../../utils/money';
/** Build a display label matching the CustomerPicker convention. */
function summaryLabel(r: UnbilledHoursSummaryRow): string {
return (
r.companyName
|| [r.firstName, r.lastName].filter(Boolean).join(' ')
|| r.displayName
|| r.email
|| `#${r.customerAccountId}`
);
}
export const HoursLoggingPage: React.FC = () => {
const { t } = useTranslation();
@@ -47,6 +62,30 @@ export const HoursLoggingPage: React.FC = () => {
enabled: !!selectedId,
});
// Landing aggregate — every customer with open (unbilled) hours. Only
// fetched while no customer is picked; once one is selected the page
// hands over to HoursSection. invalidated implicitly by remount on
// re-entry (HoursSection mutations bump per-customer keys).
const { data: summary = [], isLoading: summaryLoading } = useQuery({
queryKey: ['admin-unbilled-hours-summary'],
queryFn: () => customerAdminService.getUnbilledHoursSummary(),
enabled: !selectedId,
});
const { data: profileSnapshot } = useQuery({
queryKey: ['business-profile-snapshot'],
queryFn: () => businessProfileService.get(),
staleTime: 5 * 60 * 1000,
});
const currency = profileSnapshot?.profile?.defaultCurrency || 'CHF';
const selectFromSummary = (r: UnbilledHoursSummaryRow) => {
setSelectedId(r.customerAccountId);
setCustomerLabel(summaryLabel(r));
setCustomerIsPassive(r.isPassive);
setCustomerHoursAllowed(true);
};
return (
<div className="container py-6 space-y-6">
<div className="flex items-center justify-between">
@@ -116,6 +155,76 @@ export const HoursLoggingPage: React.FC = () => {
)}
</Card>
{!selectedId && (
<Card padding="lg">
<div className="flex items-center gap-2 mb-1">
<Clock className="w-4 h-4 text-muted-theme" />
<h2 className="text-base font-semibold text-theme">
{t('hoursLogging.openHours.title', 'Open hours across all customers')}
</h2>
</div>
<p className="text-sm text-muted-theme mb-4">
{t('hoursLogging.openHours.subtitle',
'Unbilled time blocks waiting to be billed. Pick a customer above, or click a row to drill in.')}
</p>
{summaryLoading ? (
<p className="text-sm text-muted-theme py-6 text-center">
{t('common.loading', 'Loading…')}
</p>
) : summary.length === 0 ? (
<p className="text-sm text-muted-theme py-6 text-center">
{t('hoursLogging.openHours.empty',
'No unbilled hours right now — everything is billed or no time has been logged yet.')}
</p>
) : (
<div className="divide-y divide-neutral-200 dark:divide-neutral-700">
{summary.map((r) => (
<button
key={r.customerAccountId}
type="button"
onClick={() => selectFromSummary(r)}
className="w-full flex items-center justify-between gap-4 py-3 text-left hover:bg-neutral-50 dark:hover:bg-neutral-800/60 rounded-md px-2 -mx-2 transition-colors"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-theme truncate">{summaryLabel(r)}</span>
{r.isPassive && (
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-neutral-100 text-neutral-600 dark:bg-neutral-700 dark:text-neutral-300">
{t('hoursLogging.openHours.passive', 'Passive')}
</span>
)}
</div>
<div className="text-xs text-muted-theme mt-0.5">
{t('hoursLogging.openHours.entryLine', {
count: r.entryCount,
hours: (r.totalMinutes / 60).toFixed(2),
defaultValue: '{{count}} entries · {{hours}}h',
})}
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
<div className="text-right">
{r.rateResolvable ? (
<div className="font-semibold text-theme tabular-nums">
{formatMoneyMinor(r.openAmountMinor, currency)}
</div>
) : (
<div className="flex items-center gap-1 text-amber-700 dark:text-amber-300 text-xs">
<AlertTriangle className="w-3.5 h-3.5" />
{t('hoursLogging.openHours.needsRate', 'Rate not set')}
</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-muted-theme" />
</div>
</button>
))}
</div>
)}
</Card>
)}
{selectedId && customerHoursAllowed && (
<HoursSection
customerId={selectedId}
@@ -18,7 +18,7 @@ import React, { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { Calculator, Download, FileDown, AlertCircle } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common';
import { Button, Card, Loading, LocalizedDateInput } from '../../../components/common';
// Lightweight native select styled to match Input — the common barrel
// doesn't export a Select component, and the form pieces here are
@@ -89,7 +89,7 @@ function triggerBrowserDownload(url: string, filename: string) {
export const TaxReportPage: React.FC = () => {
const { t, i18n } = useTranslation();
const { format: fmtDate, dateInputLang } = useLocalizedDate();
const { format: fmtDate } = useLocalizedDate();
const [preset, setPreset] = useState<PeriodPreset>('thisYear');
const initialPeriod = useMemo(() => periodForPreset('thisYear'), []);
const [from, setFrom] = useState(initialPeriod.from);
@@ -196,27 +196,21 @@ export const TaxReportPage: React.FC = () => {
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="period-from" className="block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1">
<label className="block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('taxReport.filters.from', 'From')}
</label>
<Input
id="period-from"
type="date"
lang={dateInputLang}
<LocalizedDateInput
value={from}
onChange={(e) => { setFrom(e.target.value); setPreset('custom'); }}
onChange={(iso) => { setFrom(iso); setPreset('custom'); }}
/>
</div>
<div>
<label htmlFor="period-to" className="block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1">
<label className="block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('taxReport.filters.to', 'To')}
</label>
<Input
id="period-to"
type="date"
lang={dateInputLang}
<LocalizedDateInput
value={to}
onChange={(e) => { setTo(e.target.value); setPreset('custom'); }}
onChange={(iso) => { setTo(iso); setPreset('custom'); }}
/>
</div>
</div>
@@ -197,6 +197,25 @@ export const ContractDetailPage: React.FC = () => {
}
}
// Pre-send preview: renders a fresh PDF from the current draft without
// writing/sending anything, so the admin can sanity-check layout +
// signature blocks before committing to send (no audit trail created).
async function handlePdfPreview() {
if (!numericId) return;
const previewWindow = window.open('about:blank', '_blank');
if (!previewWindow) {
toast.error(t('contracts.detail.popupBlocked', 'Allow pop-ups for this site to preview the PDF.') as string);
return;
}
try {
const url = await contractsService.previewPdfUrl(numericId);
previewWindow.location.href = url;
} catch (err: any) {
previewWindow.close();
toast.error(err?.response?.data?.error || 'Preview failed');
}
}
async function handleSignedPdfDownload() {
if (!numericId) return;
const previewWindow = window.open('about:blank', '_blank');
@@ -241,6 +260,10 @@ export const ContractDetailPage: React.FC = () => {
<Edit2 className="w-4 h-4 mr-1" />
{t('contracts.detail.edit', 'Edit')}
</Button>
<Button variant="outline" onClick={handlePdfPreview}>
<FileDown className="w-4 h-4 mr-1" />
{t('contracts.detail.previewPdf', 'Preview PDF')}
</Button>
<Button onClick={() => sendMutation.mutate()} disabled={sendMutation.isPending}>
<Send className="w-4 h-4 mr-1" />
{t('contracts.detail.send', 'Send to customer')}
@@ -17,14 +17,13 @@ import { useNavigate, useParams, Link } from 'react-router-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { ArrowLeft, Eye, Save } from 'lucide-react';
import { Button, Card, Input, Loading } from '../../../components/common';
import { Button, Card, Input, Loading, LocalizedDateInput, TimeField } from '../../../components/common';
import {
contractsService,
type ContractBlockSection,
CONTRACT_SECTIONS,
} from '../../../services/contracts.service';
import { CustomerPicker } from '../../../components/admin/CustomerPicker';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
interface BlockRow {
blockId: number;
@@ -38,8 +37,6 @@ interface BlockRow {
export const ContractEditorPage: React.FC = () => {
const { t } = useTranslation();
const { timeFormat } = useLocalizedDate();
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
const { id } = useParams<{ id?: string }>();
const navigate = useNavigate();
const isEdit = Boolean(id);
@@ -380,13 +377,13 @@ export const ContractEditorPage: React.FC = () => {
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.issueDate', 'Issue date')}
</label>
<Input type="date" value={issueDate} onChange={(e) => setIssueDate(e.target.value)} />
<LocalizedDateInput value={issueDate} onChange={setIssueDate} />
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.validUntil', 'Sign by (optional)')}
</label>
<Input type="date" value={validUntil} onChange={(e) => setValidUntil(e.target.value)} />
<LocalizedDateInput value={validUntil} onChange={setValidUntil} />
</div>
</div>
@@ -419,20 +416,20 @@ export const ContractEditorPage: React.FC = () => {
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.eventDate', 'Event date')}
</label>
<Input type="date" value={eventDate} onChange={(e) => setEventDate(e.target.value)} />
<LocalizedDateInput value={eventDate} onChange={setEventDate} />
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.eventTimeStart', 'Start')}
</label>
<Input type="time" lang={timeInputLang} value={eventTimeStart} onChange={(e) => setEventTimeStart(e.target.value)} />
<TimeField value={eventTimeStart} onChange={setEventTimeStart} />
</div>
<div>
<label className="block text-sm font-medium mb-1">
{t('contracts.editor.eventTimeEnd', 'End')}
</label>
<Input type="time" lang={timeInputLang} value={eventTimeEnd} onChange={(e) => setEventTimeEnd(e.target.value)} />
<TimeField value={eventTimeEnd} onChange={setEventTimeEnd} />
</div>
</div>
</div>
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Plus, Search, BookOpen } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common';
import { Button, Card, Loading, SortableHeader, useColumnSort, type SortColumnMap } from '../../../components/common';
import {
contractsService,
type ContractStatus,
@@ -27,15 +27,25 @@ const STATUSES: ContractStatus[] = [
'draft', 'sent', 'signed_by_customer', 'signed_by_admin', 'fully_signed', 'cancelled',
];
// "Number" sorts by creation order (newest/oldest); "Issued" sorts by
// the admin-controlled issue_date, which can drift from chronology.
const SORT_COLUMNS: SortColumnMap = {
number: { asc: 'oldest', desc: 'newest', defaultDir: 'desc' },
customer: { asc: 'customer_asc', desc: 'customer_desc' },
issue: { asc: 'issue_asc', desc: 'issue_desc', defaultDir: 'desc' },
};
export const ContractsListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { format } = useLocalizedDate();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<ContractStatus[]>([]);
const [sort, setSort] = useState<ContractSort>('newest');
const { sort, activeKey, activeDir, toggle } = useColumnSort<ContractSort>(SORT_COLUMNS, 'issue_desc');
const [page, setPage] = useState(1);
const onSort = (key: string) => { toggle(key); setPage(1); };
const { data, isLoading } = useQuery({
queryKey: ['contracts', { search, statusFilter, sort, page }],
queryFn: () => contractsService.list({
@@ -98,15 +108,6 @@ export const ContractsListPage: React.FC = () => {
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
</div>
<select
className="px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={sort}
onChange={(e) => setSort(e.target.value as ContractSort)}
>
<option value="newest">{t('contracts.list.sort.newest', 'Newest first')}</option>
<option value="oldest">{t('contracts.list.sort.oldest', 'Oldest first')}</option>
<option value="customer_asc">{t('contracts.list.sort.customer', 'Customer A→Z')}</option>
</select>
</div>
<div className="mt-3 flex flex-wrap gap-1">
@@ -135,10 +136,10 @@ export const ContractsListPage: React.FC = () => {
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left">{t('contracts.list.table.number', 'Number')}</th>
<th className="px-3 py-2 text-left">{t('contracts.list.table.customer', 'Customer')}</th>
<SortableHeader label={t('contracts.list.table.number', 'Number')} columnKey="number" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<SortableHeader label={t('contracts.list.table.customer', 'Customer')} columnKey="customer" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<th className="px-3 py-2 text-left">{t('contracts.list.table.title', 'Title')}</th>
<th className="px-3 py-2 text-left">{t('contracts.list.table.issueDate', 'Issued')}</th>
<SortableHeader label={t('contracts.list.table.issueDate', 'Issued')} columnKey="issue" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<th className="px-3 py-2 text-left">{t('contracts.list.table.status', 'Status')}</th>
</tr>
</thead>
+1
View File
@@ -8,6 +8,7 @@ export { ArchivesPage } from './ArchivesPage';
export { AnalyticsPage } from './AnalyticsPage';
export { BrandingPage } from './BrandingPage';
export { SettingsPage } from './SettingsPage';
export { SystemHealthPage } from './SystemHealthPage';
export { CMSPage } from './CMSPage';
export { BackupManagement } from './BackupManagement';
export { EventFeedbackPage } from './EventFeedbackPage';
@@ -7,7 +7,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Eye, Send, Copy, ArrowRightCircle, Edit2, Receipt, CheckCircle2, ScrollText } from 'lucide-react';
import { ArrowLeft, Eye, Send, Copy, ArrowRightCircle, Edit2, Receipt, CheckCircle2, ScrollText, XCircle } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common';
import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
import { quotesService } from '../../../services/quotes.service';
@@ -125,6 +125,26 @@ export const QuoteDetailPage: React.FC = () => {
}
};
/**
* Admin decline-on-behalf. Used when the customer says no by phone/
* email admin flips the quote to declined and (optionally) records
* why. The quote can still be duplicated to start a fresh round.
*/
const handleDeclineOnBehalf = async () => {
const reason = window.prompt(t('quotes.declineReasonPrompt',
'Mark this quote as declined on behalf of the customer? Optionally note why (leave blank to skip).'));
// prompt returns null on Cancel; '' (empty) means "decline, no reason".
if (reason === null) return;
try {
await quotesService.declineOnBehalf(q.id, reason.trim() || undefined);
toast.success(t('quotes.declinedOnBehalfToast', 'Quote marked as declined.'));
qc.invalidateQueries({ queryKey: ['quote', id] });
qc.invalidateQueries({ queryKey: ['quotes'] });
} catch (err: any) {
toast.error(err?.response?.data?.error || 'Decline failed');
}
};
const handleDuplicate = async () => {
try {
const result = await quotesService.duplicate(q.id);
@@ -168,6 +188,15 @@ export const QuoteDetailPage: React.FC = () => {
{t('quotes.acceptOnBehalf', 'Accept on behalf')}
</Button>
)}
{/* Decline-on-behalf same states as accept-on-behalf. Flips
the quote to declined for "customer said no by phone"
cases; hidden once accepted / declined / converted. */}
{['draft', 'sent', 'expired'].includes(q.status) && (
<Button variant="outline" onClick={handleDeclineOnBehalf}>
<XCircle className="w-4 h-4 mr-1" />
{t('quotes.declineOnBehalf', 'Decline on behalf')}
</Button>
)}
{q.status === 'accepted' && (
<>
<Button onClick={handleConvert}>
@@ -207,6 +236,7 @@ export const QuoteDetailPage: React.FC = () => {
{q.sentAt && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.sentAt', 'Sent at')}</div><div>{fmtDateTime(q.sentAt)}</div></div>}
{q.acceptedAt && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.acceptedAt', 'Accepted at')}</div><div>{fmtDateTime(q.acceptedAt)}</div></div>}
{q.declinedAt && <div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.declinedAt', 'Declined at')}</div><div>{fmtDateTime(q.declinedAt)}</div></div>}
{q.declineReason && <div className="col-span-2 md:col-span-4"><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.declineReason', 'Decline reason')}</div><div className="whitespace-pre-line">{q.declineReason}</div></div>}
{q.respondedAt && !responseLocked && (
<div><div className="text-neutral-600 dark:text-neutral-300">{t('quotes.field.responseWindow', 'Response window')}</div>
<div className="text-amber-700">{t('quotes.responseWindowOpen', 'Open until {{at}}', { at: q.responseLockedAt ? fmtDateTime(q.responseLockedAt) : '' })}</div></div>
@@ -13,10 +13,10 @@
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Eye, Send } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common';
import { Button, Card, Loading, Input, LocalizedDateInput, TimeField } from '../../../components/common';
import {
quotesService,
type QuoteCreatePayload,
@@ -29,7 +29,6 @@ import { customerAdminService } from '../../../services/customerAdmin.service';
import { userManagementService } from '../../../services/userManagement.service';
import { settingsService } from '../../../services/settings.service';
import { useAdminAuth } from '../../../contexts/AdminAuthContext';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { toast } from 'react-toastify';
interface FormState {
@@ -140,8 +139,6 @@ function buildPayload(f: FormState): QuoteCreatePayload {
export const QuoteEditorPage: React.FC = () => {
const { t } = useTranslation();
const { timeFormat } = useLocalizedDate();
const timeInputLang = timeFormat === '12h' ? 'en-US' : 'de-DE';
const { id } = useParams<{ id?: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
@@ -490,17 +487,17 @@ export const QuoteEditorPage: React.FC = () => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<Input label={t('quotes.field.eventName', 'Event name') as string} value={form.eventName}
onChange={(e) => setForm((f) => ({ ...f, eventName: e.target.value }))} />
<Input type="date" label={t('quotes.field.eventDate', 'Event date') as string} value={form.eventDate}
onChange={(e) => setForm((f) => ({ ...f, eventDate: e.target.value }))} />
<Input type="time" lang={timeInputLang} label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart}
onChange={(e) => setForm((f) => ({ ...f, eventTimeStart: e.target.value }))} />
<Input type="time" lang={timeInputLang} label={t('quotes.field.eventTimeEnd', 'End time') as string} value={form.eventTimeEnd}
onChange={(e) => setForm((f) => ({ ...f, eventTimeEnd: e.target.value }))} />
<LocalizedDateInput label={t('quotes.field.eventDate', 'Event date') as string} value={form.eventDate}
onChange={(iso) => setForm((f) => ({ ...f, eventDate: iso }))} />
<TimeField label={t('quotes.field.eventTimeStart', 'Start time') as string} value={form.eventTimeStart}
onChange={(v) => setForm((f) => ({ ...f, eventTimeStart: v }))} />
<TimeField label={t('quotes.field.eventTimeEnd', 'End time') as string} value={form.eventTimeEnd}
onChange={(v) => setForm((f) => ({ ...f, eventTimeEnd: v }))} />
<Input type="number" step="0.5" label={t('quotes.field.expectedDuration', 'Expected duration (h)') as string}
value={form.expectedDurationHours}
onChange={(e) => setForm((f) => ({ ...f, expectedDurationHours: e.target.value }))} />
<Input type="date" label={t('quotes.field.validUntil', 'Valid until') as string} value={form.validUntil}
onChange={(e) => setForm((f) => ({ ...f, validUntil: e.target.value }))} />
<LocalizedDateInput label={t('quotes.field.validUntil', 'Valid until') as string} value={form.validUntil}
onChange={(iso) => setForm((f) => ({ ...f, validUntil: iso }))} />
</div>
</Card>
@@ -539,6 +536,10 @@ export const QuoteEditorPage: React.FC = () => {
below now reads from the timing template. */}
<Card>
<h3 className="font-semibold mb-2">4. {t('quotes.section.payment', 'Payment conditions')}</h3>
<Link to="/admin/settings?tab=crm"
className="text-xs text-accent hover:underline mb-2 inline-block">
{t('common.configureInSettings', 'Configure defaults in Settings ↗')}
</Link>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium mb-1">{t('quotes.field.paymentNetDays', 'Net days')}</label>
@@ -8,21 +8,32 @@ import { Link, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Plus, Search } from 'lucide-react';
import { quotesService, type QuoteStatus, type QuoteSort } from '../../../services/quotes.service';
import { Button, Card, Loading } from '../../../components/common';
import { Button, Card, Loading, SortableHeader, useColumnSort, type SortColumnMap } from '../../../components/common';
import { formatMoney } from '../../../components/admin/LineItemsTable';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
const STATUSES: QuoteStatus[] = ['draft', 'sent', 'accepted', 'declined', 'expired', 'converted'];
// "#" sorts by creation order (newest/oldest); "Issued" sorts by the
// admin-controlled issue_date, which can drift from chronology.
const SORT_COLUMNS: SortColumnMap = {
number: { asc: 'oldest', desc: 'newest', defaultDir: 'desc' },
customer: { asc: 'customer_asc', desc: 'customer_desc' },
issue: { asc: 'issue_asc', desc: 'issue_desc', defaultDir: 'desc' },
value: { asc: 'value_asc', desc: 'value_desc', defaultDir: 'desc' },
};
export const QuotesListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const { format: fmtDate } = useLocalizedDate();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<QuoteStatus[]>([]);
const [sort, setSort] = useState<QuoteSort>('newest');
const { sort, activeKey, activeDir, toggle } = useColumnSort<QuoteSort>(SORT_COLUMNS, 'issue_desc');
const [page, setPage] = useState(1);
const onSort = (key: string) => { toggle(key); setPage(1); };
const { data, isLoading } = useQuery({
queryKey: ['quotes', { search, statusFilter, sort, page }],
queryFn: () => quotesService.list({
@@ -73,17 +84,6 @@ export const QuotesListPage: React.FC = () => {
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
/>
</div>
<select
className="px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
value={sort}
onChange={(e) => setSort(e.target.value as QuoteSort)}
>
<option value="newest">{t('quotes.sort.newest', 'Newest first')}</option>
<option value="oldest">{t('quotes.sort.oldest', 'Oldest first')}</option>
<option value="customer_asc">{t('quotes.sort.customerAsc', 'Customer A→Z')}</option>
<option value="value_asc">{t('quotes.sort.valueAsc', 'Value low→high')}</option>
<option value="value_desc">{t('quotes.sort.valueDesc', 'Value high→low')}</option>
</select>
</div>
<div className="mt-3 flex flex-wrap gap-1">
{STATUSES.map((s) => {
@@ -111,11 +111,11 @@ export const QuotesListPage: React.FC = () => {
<table className="w-full text-sm">
<thead className="bg-neutral-50 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300">
<tr>
<th className="px-3 py-2 text-left">#</th>
<th className="px-3 py-2 text-left">{t('quotes.table.customer', 'Customer')}</th>
<SortableHeader label="#" columnKey="number" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<SortableHeader label={t('quotes.table.customer', 'Customer')} columnKey="customer" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<th className="px-3 py-2 text-left">{t('quotes.table.event', 'Event')}</th>
<th className="px-3 py-2 text-left">{t('quotes.table.issueDate', 'Issued')}</th>
<th className="px-3 py-2 text-right">{t('quotes.table.total', 'Total')}</th>
<SortableHeader label={t('quotes.table.issueDate', 'Issued')} columnKey="issue" activeKey={activeKey} activeDir={activeDir} onSort={onSort} />
<SortableHeader label={t('quotes.table.total', 'Total')} columnKey="value" activeKey={activeKey} activeDir={activeDir} onSort={onSort} align="right" />
<th className="px-3 py-2 text-left">{t('quotes.table.status', 'Status')}</th>
</tr>
</thead>
@@ -8,14 +8,17 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Star, Pencil, Save } from 'lucide-react';
import { Plus, Trash2, Star, Pencil, Save, Clock, Copy } from 'lucide-react';
import {
businessProfileService,
type BusinessProfile,
type BankAccount,
type BusinessHours,
type BusinessHoursBlock,
type QrFormat,
} from '../../../services/businessProfile.service';
import { Button, Card, Loading, Input } from '../../../components/common';
import { Button, Card, Loading, Input, CountrySelect, TimeField } from '../../../components/common';
import { DecimalInput } from '../../../components/common/DecimalInput';
import { toast } from 'react-toastify';
export const SettingsBusinessProfilePage: React.FC = () => {
@@ -82,18 +85,14 @@ export const SettingsBusinessProfilePage: React.FC = () => {
onChange={(e) => setProfile({ ...profile, city: e.target.value })} />
<Input label={t('businessProfile.field.state', 'State / Region') as string} value={profile.state}
onChange={(e) => setProfile({ ...profile, state: e.target.value })} />
<Input label={t('businessProfile.field.countryCode', 'Country abbreviation (FL, CH, DE …)') as string}
value={profile.countryCode}
maxLength={2}
placeholder="FL"
onChange={(e) => setProfile({ ...profile, countryCode: e.target.value.toUpperCase() })} />
{/* Free-text country name override (migration 107). When
left empty the renderer falls back to the COUNTRY_NAMES
lookup on the abbreviation. */}
<Input label={t('businessProfile.field.countryName', 'Country (full name)') as string}
value={profile.countryName || ''}
placeholder="Liechtenstein"
onChange={(e) => setProfile({ ...profile, countryName: e.target.value })} />
<CountrySelect label={t('businessProfile.field.countryCode', 'Country') as string}
value={profile.countryCode || ''}
onChange={(code) => setProfile({ ...profile, countryCode: code })} />
{/* The free-text "Country (full name)" override (migration 107) was
removed as redundant the picker stores the ISO code and the PDF
renderer derives the localized full name from it
(pdfService.countryName). The DB column + `country_name ||`
fallback remain, so any legacy override still renders. */}
</div>
</Card>
@@ -134,6 +133,30 @@ export const SettingsBusinessProfilePage: React.FC = () => {
<Input type="number" step="0.01" label={t('businessProfile.field.vatRateDefault', 'Default VAT rate %') as string}
value={profile.vatRateDefault ?? 0}
onChange={(e) => setProfile({ ...profile, vatRateDefault: Number(e.target.value) })} />
{/* Install-wide fallback hourly rate (migration 113). Stored in
minor units; entered here in major units. Blank = no global
default, so hours-logging then needs a per-customer or
per-entry rate. Comma-tolerant via DecimalInput. */}
<div>
<label className="block text-sm font-medium mb-1">
{t('businessProfile.field.defaultHourlyRate', 'Default hourly rate')}
</label>
<DecimalInput
value={profile.defaultHourlyRateMinor != null ? profile.defaultHourlyRateMinor / 100 : NaN}
fractionDigits={2}
onChange={(n) => setProfile({
...profile,
defaultHourlyRateMinor: Number.isFinite(n) ? Math.max(0, Math.round(n * 100)) : null,
})}
className="w-full px-3 py-2 rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-sm"
placeholder={t('businessProfile.field.defaultHourlyRatePlaceholder', 'e.g. 120.00') as string}
/>
<p className="text-xs text-muted-theme mt-1">
{t('businessProfile.field.defaultHourlyRateHint',
'Fallback used when a customer has no own rate. In {{currency}}, major units. Leave blank to require a per-customer or per-entry rate.',
{ currency: profile.defaultCurrency || 'CHF' })}
</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t('businessProfile.field.defaultQrFormat', 'Default invoice QR')}</label>
<select value={profile.defaultQrFormat} onChange={(e) => setProfile({ ...profile, defaultQrFormat: e.target.value as QrFormat })}
@@ -228,6 +251,37 @@ export const SettingsBusinessProfilePage: React.FC = () => {
</div>
</Card>
{/* Business hours (migration 114). Per-weekday opening blocks with
lunch-break support, interpreted in the profile timezone above.
Drives the scheduled-email floor: an email scheduled outside the
open blocks is held until the next opening. */}
<Card>
<div className="flex items-center gap-2 mb-1">
<Clock className="w-5 h-5 text-neutral-500" />
<h3 className="font-semibold">{t('businessProfile.businessHours.title', 'Business hours')}</h3>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('businessProfile.businessHours.subtitle',
'Set opening hours per weekday — add a second block for a lunch break. Interpreted in the timezone above ({{tz}}).',
{ tz: profile.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone })}
</p>
<BusinessHoursEditor
value={profile.businessHours}
onChange={(next) => setProfile({ ...profile, businessHours: next })}
/>
<div className="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<PdfToggleRow
label={t('businessProfile.businessHours.floorToggle', 'Hold scheduled emails until business hours') as string}
description={t('businessProfile.businessHours.floorToggleHelp',
'When on, an automated email scheduled outside the hours above is delivered at the next opening instead of at an odd hour. When off, scheduled emails send at their exact time.') as string}
enabled={profile.scheduledEmailFloorEnabled}
onChange={(v) => setProfile({ ...profile, scheduledEmailFloorEnabled: v })}
/>
</div>
</Card>
{/* Disclaimer banner for QR-bill / IBAN data. picpeak renders
what the operator types it cannot validate IBAN/BIC, QR-IID
or scan-compatibility with any specific bank's e-banking app.
@@ -284,6 +338,143 @@ const PdfToggleRow: React.FC<PdfToggleRowProps> = ({ label, description, enabled
</label>
);
/**
* Per-weekday business-hours editor (migration 114). Google-style: each
* weekday holds zero or more {start,end} blocks, so a day can be closed
* (no blocks), open all day (one block), or have a lunch break (two).
* Edits the parent's `businessHours` object directly; the page-level Save
* persists it. ISO weekday keys "1".."7" (1=Mon 7=Sun).
*/
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7];
const BusinessHoursEditor: React.FC<{
value: BusinessHours | null;
onChange: (next: BusinessHours) => void;
}> = ({ value, onChange }) => {
const { t } = useTranslation();
// Always work with a fully-populated 7-day object so toggling a day on
// and off doesn't drop sibling keys.
const full: BusinessHours = {};
for (const iso of WEEKDAYS) {
const blocks = value?.[String(iso)];
full[String(iso)] = Array.isArray(blocks) ? blocks : [];
}
const setDay = (iso: number, blocks: BusinessHoursBlock[]) => {
onChange({ ...full, [String(iso)]: blocks });
};
const addBlock = (iso: number) => {
const blocks = full[String(iso)];
// First block defaults to a full workday; a second one defaults to a
// post-lunch afternoon so the common 0912 / 1318 split is one click.
const next: BusinessHoursBlock = blocks.length === 0
? { start: '09:00', end: '17:00' }
: { start: '13:00', end: '18:00' };
setDay(iso, [...blocks, next]);
};
const updateBlock = (iso: number, idx: number, patch: Partial<BusinessHoursBlock>) => {
setDay(iso, full[String(iso)].map((b, i) => (i === idx ? { ...b, ...patch } : b)));
};
const removeBlock = (iso: number, idx: number) => {
setDay(iso, full[String(iso)].filter((_, i) => i !== idx));
};
const copyToAll = (iso: number) => {
const src = full[String(iso)];
const next: BusinessHours = {};
for (const d of WEEKDAYS) next[String(d)] = src.map((b) => ({ ...b }));
onChange(next);
};
return (
<div className="space-y-2">
{WEEKDAYS.map((iso) => {
const blocks = full[String(iso)];
const isOpen = blocks.length > 0;
return (
<div
key={iso}
className="flex flex-col sm:flex-row sm:items-start gap-2 sm:gap-3 py-2 border-b border-neutral-100 dark:border-neutral-800 last:border-0"
>
<div className="w-28 shrink-0 pt-2 text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t(`businessProfile.businessHours.weekday.${iso}`)}
</div>
<div className="flex-1 space-y-2">
{!isOpen && (
<div className="flex items-center gap-3">
<span className="text-sm text-neutral-500 dark:text-neutral-400">
{t('businessProfile.businessHours.closed', 'Closed')}
</span>
<button
type="button"
onClick={() => addBlock(iso)}
className="inline-flex items-center gap-1 text-sm text-primary-600 hover:text-primary-700"
>
<Plus className="w-4 h-4" />
{t('businessProfile.businessHours.addHours', 'Add hours')}
</button>
</div>
)}
{blocks.map((block, idx) => (
<div key={idx} className="flex items-center gap-2">
<TimeField
value={block.start}
onChange={(v) => updateBlock(iso, idx, { start: v })}
ariaLabel={t('businessProfile.businessHours.startTime', 'Opening time') as string}
className="w-32 shrink-0"
/>
<span className="text-neutral-400"></span>
<TimeField
value={block.end}
onChange={(v) => updateBlock(iso, idx, { end: v })}
ariaLabel={t('businessProfile.businessHours.endTime', 'Closing time') as string}
className="w-32 shrink-0"
/>
<button
type="button"
onClick={() => removeBlock(iso, idx)}
aria-label={t('common.remove', 'Remove') as string}
className="p-1.5 text-neutral-400 hover:text-red-600"
>
<Trash2 className="w-4 h-4" />
</button>
{idx === blocks.length - 1 && (
<button
type="button"
onClick={() => addBlock(iso)}
aria-label={t('businessProfile.businessHours.addBlock', 'Add another block') as string}
className="p-1.5 text-primary-600 hover:text-primary-700"
>
<Plus className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
{isOpen && (
<button
type="button"
onClick={() => copyToAll(iso)}
className="shrink-0 inline-flex items-center gap-1 pt-2 text-xs text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
>
<Copy className="w-3.5 h-3.5" />
{t('businessProfile.businessHours.copyToAll', 'Copy to all days')}
</button>
)}
</div>
);
})}
</div>
);
};
/**
* Dedicated PDF letterhead logo uploader. Accepts PNG / JPEG / SVG;
* the backend rasterises SVG to PNG via sharp so vector uploads work
@@ -21,13 +21,14 @@ import { Lock, MapPin, Phone, User as UserIcon, AlertCircle, CheckCircle } from
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card, Loading } from '../../components/common';
import { Button, Input, Card, Loading, CountrySelect } from '../../components/common';
import {
customerService,
type CustomerInvitationInfo,
type CustomerProfilePrefill,
} from '../../services/customer.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { usePublicDarkMode } from '../../hooks/usePublicDarkMode';
interface FormState {
display_name: string;
@@ -76,8 +77,14 @@ export const CustomerAcceptInvitePage: React.FC = () => {
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: settingsData } = usePublicSettings();
// Theme-aware logo: the page renders on the themed customer surface
// (dark when branding_force_color_mode is dark / OS dark), so pick the
// dark logo variant accordingly. No frame here — logo sits on the page bg.
const { isDark } = usePublicDarkMode();
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = settingsData?.branding_logo_url?.trim();
const lightLogo = settingsData?.branding_logo_url?.trim();
const darkLogo = settingsData?.branding_logo_url_dark?.trim();
const logoUrl = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
// Pre-flight invitation lookup. The response carries any prefill data
@@ -386,7 +393,7 @@ export const CustomerAcceptInvitePage: React.FC = () => {
onChange={(e) => update('postal_code', e.target.value)}
/>
</div>
<div className="sm:col-span-3">
<div className="sm:col-span-4">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-city">
{t('customer.profile.field.city', 'City')}
</label>
@@ -398,20 +405,6 @@ export const CustomerAcceptInvitePage: React.FC = () => {
onChange={(e) => update('city', e.target.value)}
/>
</div>
<div className="sm:col-span-1">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-country">
{t('customer.profile.field.countryCode', 'Country')}
</label>
<Input
id="invite-country"
name="country"
autoComplete="billing country"
placeholder="DE"
maxLength={2}
value={form.country_code}
onChange={(e) => update('country_code', e.target.value.toUpperCase().slice(0, 2))}
/>
</div>
<div className="sm:col-span-3">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="invite-state">
{t('customer.profile.field.state', 'State / region')}
@@ -424,6 +417,16 @@ export const CustomerAcceptInvitePage: React.FC = () => {
onChange={(e) => update('state', e.target.value)}
/>
</div>
<div className="sm:col-span-3">
{/* Country picker (ISO code) dropdown, mirroring the
admin customer / business-profile forms, placed after
State / region. Replaces the old free-text 2-char input. */}
<CountrySelect
label={t('customer.profile.field.countryCode', 'Country') as string}
value={form.country_code}
onChange={(code) => update('country_code', code)}
/>
</div>
</div>
</section>
+10 -1
View File
@@ -65,7 +65,16 @@ export const CustomerLayout: React.FC = () => {
const [sidebarOpen, setSidebarOpen] = useState(false);
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = settingsData?.branding_logo_url?.trim();
// Theme-aware logo: the customer surface follows branding_force_color_mode
// ('auto' → OS preference). Symmetric fallback so a single uploaded logo
// serves both modes.
const forceMode = settingsData?.branding_force_color_mode;
const customerIsDark = forceMode === 'dark'
|| (forceMode === 'auto' && typeof window !== 'undefined'
&& window.matchMedia?.('(prefers-color-scheme: dark)').matches);
const lightLogo = settingsData?.branding_logo_url?.trim();
const darkLogo = settingsData?.branding_logo_url_dark?.trim();
const logoUrl = customerIsDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
// Filter out feature-gated entries the customer can't see. Galleries +
@@ -14,6 +14,7 @@ import { Button, Input, Card, ReCaptcha } from '../../components/common';
import { useCustomerAuth } from '../../contexts/CustomerAuthContext';
import { customerService } from '../../services/customer.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { usePublicDarkMode } from '../../hooks/usePublicDarkMode';
import { resolveLoginLogoClasses } from '../../utils/loginLogoSize';
export const CustomerLoginPage: React.FC = () => {
@@ -28,8 +29,17 @@ export const CustomerLoginPage: React.FC = () => {
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
const { data: settingsData } = usePublicSettings();
// Customer surface follows branding_force_color_mode (+ OS fallback); isDark
// drives the theme-aware logo pick. A framed logo sits on a fixed cream
// plate (see render), so the light (dark-ink) logo always reads there;
// only the frameless logo sits on the themed (possibly dark) page bg.
const { isDark } = usePublicDarkMode();
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = settingsData?.branding_logo_url?.trim();
const lightLogo = settingsData?.branding_logo_url?.trim();
const darkLogo = settingsData?.branding_logo_url_dark?.trim();
const loginFrameEnabled = settingsData?.branding_login_logo_frame_enabled !== false;
const themedLogo = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
const logoUrl = loginFrameEnabled ? (lightLogo || darkLogo) : themedLogo;
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
// After /accept-invite the user is redirected here with ?accepted=1
@@ -28,7 +28,7 @@ import { toast } from 'react-toastify';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Lock, Save, User as UserIcon, MapPin, Phone, Mail } from 'lucide-react';
import { Button, Input, Loading } from '../../components/common';
import { Button, Input, Loading, CountrySelect } from '../../components/common';
/**
* Inline tile wrapper used in place of <Card> on this page.
@@ -415,7 +415,7 @@ export const CustomerProfilePage: React.FC = () => {
/>
</div>
<div className="sm:col-span-3">
<div className="sm:col-span-4">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-city">
{t('customer.profile.field.city', 'City')}
</label>
@@ -428,21 +428,6 @@ export const CustomerProfilePage: React.FC = () => {
/>
</div>
<div className="sm:col-span-1">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-country">
{t('customer.profile.field.countryCode', 'Country')}
</label>
<Input
id="profile-country"
name="country"
autoComplete="billing country"
placeholder="DE"
maxLength={2}
value={form.countryCode || ''}
onChange={(e) => updateField('countryCode', e.target.value.toUpperCase().slice(0, 2))}
/>
</div>
<div className="sm:col-span-3">
<label className="block text-sm font-medium text-theme mb-1" htmlFor="profile-state">
{t('customer.profile.field.state', 'State / region')}
@@ -455,6 +440,17 @@ export const CustomerProfilePage: React.FC = () => {
onChange={(e) => updateField('state', e.target.value)}
/>
</div>
<div className="sm:col-span-3">
{/* Country picker (ISO code) dropdown, mirroring the admin
customer / business-profile forms, placed after State /
region. Replaces the old free-text 2-char input. */}
<CountrySelect
label={t('customer.profile.field.countryCode', 'Country') as string}
value={form.countryCode || ''}
onChange={(code) => updateField('countryCode', code)}
/>
</div>
</div>
</ProfileTile>
@@ -18,6 +18,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Input, Card, Loading } from '../../components/common';
import { customerService } from '../../services/customer.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { usePublicDarkMode } from '../../hooks/usePublicDarkMode';
export const CustomerResetPasswordPage: React.FC = () => {
const { t } = useTranslation();
@@ -33,8 +34,14 @@ export const CustomerResetPasswordPage: React.FC = () => {
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: settingsData } = usePublicSettings();
// Theme-aware logo: page renders on the themed customer surface (dark when
// branding_force_color_mode is dark / OS dark). No frame — logo sits on the
// page bg, so pick the dark variant when dark.
const { isDark } = usePublicDarkMode();
const companyName = settingsData?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = settingsData?.branding_logo_url?.trim();
const lightLogo = settingsData?.branding_logo_url?.trim();
const darkLogo = settingsData?.branding_logo_url_dark?.trim();
const logoUrl = isDark ? (darkLogo || lightLogo) : (lightLogo || darkLogo);
const resolvedLogoUrl = logoUrl || '/picpeak-logo-transparent.png';
// Pre-flight token validation. Same pattern as the invite page — if the
@@ -81,8 +81,9 @@ export const ContractResponsePage: React.FC = () => {
// Honour branding dark/light mode the same way QuoteResponsePage
// does — without this the page renders in light regardless of admin
// settings. The wrapper styling below still has `dark:` variants
// so the page reads cleanly in either mode.
usePublicDarkMode();
// so the page reads cleanly in either mode. `isDark` drives the
// theme-aware logo pick in the header.
const { isDark } = usePublicDarkMode();
const canvasRef = useRef<HTMLCanvasElement>(null);
const padRef = useRef<SignaturePad | null>(null);
@@ -233,6 +234,18 @@ export const ContractResponsePage: React.FC = () => {
<div className="max-w-3xl mx-auto py-8 px-4">
{/* Issuer header — same shape as QuoteResponsePage. */}
<div className="text-center mb-6">
{(() => {
const logo = isDark
? (c.issuer?.logoUrlDark || c.issuer?.logoUrl)
: (c.issuer?.logoUrl || c.issuer?.logoUrlDark);
return logo ? (
<img
src={logo}
alt={c.issuer?.companyName || 'Logo'}
className="mx-auto mb-3 h-16 object-contain"
/>
) : null;
})()}
{c.issuer?.companyName && (
<h2 className="text-xl font-bold">{c.issuer.companyName}</h2>
)}
@@ -260,12 +260,16 @@ export const PaymentCheckPage: React.FC = () => {
};
const BrandingHeader: React.FC<{ issuer: PaymentCheckIssuer | null }> = ({ issuer }) => {
if (!issuer || (!issuer.logoUrl && !issuer.companyName)) return null;
const { isDark } = usePublicDarkMode();
if (!issuer || (!issuer.logoUrl && !issuer.logoUrlDark && !issuer.companyName)) return null;
const logo = isDark
? (issuer.logoUrlDark || issuer.logoUrl)
: (issuer.logoUrl || issuer.logoUrlDark);
return (
<header className="text-center mb-8">
{issuer.logoUrl && (
{logo && (
<img
src={issuer.logoUrl}
src={logo}
alt={issuer.companyName || 'Logo'}
className="mx-auto h-16 w-auto object-contain mb-3"
/>
+16 -10
View File
@@ -31,7 +31,7 @@ import { formatShortDate } from '../../utils/dateShort';
export const QuoteResponsePage: React.FC = () => {
const { t, i18n } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const { formatDateTime: fmtDateTime, formatTime: fmtTime } = useLocalizedDate();
const { token } = useParams<{ token: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const [error, setError] = useState<string | null>(null);
@@ -39,7 +39,8 @@ export const QuoteResponsePage: React.FC = () => {
// Apply dark mode per the branding settings (forced dark/light)
// or fall back to the OS preference. Without this the public
// quote page renders in light mode regardless of admin settings.
usePublicDarkMode();
// `isDark` drives the theme-aware logo pick below.
const { isDark } = usePublicDarkMode();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['public-quote', token],
@@ -153,13 +154,18 @@ export const QuoteResponsePage: React.FC = () => {
the backend storage directory directly. */}
{quote.issuer && (
<div className="text-center mb-6">
{quote.issuer.logoUrl && (
<img
src={quote.issuer.logoUrl}
alt={quote.issuer.companyName || 'Logo'}
className="mx-auto mb-3 h-16 object-contain"
/>
)}
{(() => {
const logo = isDark
? (quote.issuer.logoUrlDark || quote.issuer.logoUrl)
: (quote.issuer.logoUrl || quote.issuer.logoUrlDark);
return logo ? (
<img
src={logo}
alt={quote.issuer.companyName || 'Logo'}
className="mx-auto mb-3 h-16 object-contain"
/>
) : null;
})()}
<h2 className="text-xl font-bold">{quote.issuer.companyName}</h2>
{quote.issuer.website && (
<p className="text-sm text-neutral-500 dark:text-neutral-400">{quote.issuer.website}</p>
@@ -309,7 +315,7 @@ export const QuoteResponsePage: React.FC = () => {
? Math.max(0, Math.ceil((lockAt.getTime() - Date.now()) / 60000))
: 0;
return {
at: lockAt ? lockAt.toLocaleTimeString() : '',
at: lockAt ? fmtTime(lockAt) : '',
minutes,
};
})())
+6 -1
View File
@@ -19,9 +19,10 @@ export type InvoiceStatus = 'scheduled' | 'sent' | 'paid' | 'overdue' | 'cancell
export type InvoiceKind = 'invoice' | 'storno';
export type InvoiceSort =
| 'newest' | 'oldest'
| 'issue_asc' | 'issue_desc'
| 'due_asc' | 'due_desc'
| 'value_asc' | 'value_desc'
| 'customer_asc';
| 'customer_asc' | 'customer_desc';
export type InvoiceQrFormat = 'swiss' | 'epc' | 'none';
@@ -330,6 +331,8 @@ export const billsService = {
async importHistorical(payload: {
customerAccountId: number;
invoiceNumber: string;
eventName?: string;
eventDate?: string;
issueDate: string;
dueDate?: string;
totalAmountMinor: number;
@@ -343,6 +346,8 @@ export const billsService = {
form.append('pdf', payload.file);
form.append('customerAccountId', String(payload.customerAccountId));
form.append('invoiceNumber', payload.invoiceNumber);
if (payload.eventName) form.append('eventName', payload.eventName);
if (payload.eventDate) form.append('eventDate', payload.eventDate);
form.append('issueDate', payload.issueDate);
if (payload.dueDate) form.append('dueDate', payload.dueDate);
form.append('totalAmountMinor', String(payload.totalAmountMinor));
@@ -34,6 +34,11 @@ export interface BusinessProfile {
taxId: string;
vatLabel: string;
vatRateDefault: number | null;
/** Install-wide fallback hourly rate in MINOR units (migration 113).
* Last link in the hour-entry rate chain after the per-entry
* override and the per-customer default. null = no global default;
* the hours page then requires a per-customer or per-entry rate. */
defaultHourlyRateMinor: number | null;
defaultCurrency: string;
defaultLocale: string;
defaultQrFormat: QrFormat;
@@ -79,10 +84,28 @@ export interface BusinessProfile {
* via publicSettings. When null/empty, the calendar UI falls back
* to the browser's `Intl.DateTimeFormat().resolvedOptions().timeZone`. */
timezone: string | null;
/** Per-ISO-weekday opening hours (migration 114). Keyed "1".."7"
* (1=Mon 7=Sun); each value is a list of {start,end} "HH:MM" blocks,
* so a day can carry a lunch break or differ from its neighbours. A day
* with no blocks is closed. null = no hours configured. Interpreted in
* `timezone`. Drives the scheduled-email business-hours floor. */
businessHours: BusinessHours | null;
/** Master switch for the scheduled-email business-hours floor
* (migration 114). Defaults true. When off, scheduled emails send at
* their requested instant regardless of `businessHours`. */
scheduledEmailFloorEnabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface BusinessHoursBlock {
start: string; // "HH:MM"
end: string; // "HH:MM"
}
/** ISO-weekday-keyed ("1".."7") opening blocks. */
export type BusinessHours = Record<string, BusinessHoursBlock[]>;
export interface BankAccount {
id: number;
label: string;
+7 -1
View File
@@ -43,7 +43,10 @@ export type ContractStatus =
| 'fully_signed'
| 'cancelled';
export type ContractSort = 'newest' | 'oldest' | 'customer_asc';
export type ContractSort =
| 'newest' | 'oldest'
| 'issue_asc' | 'issue_desc'
| 'customer_asc' | 'customer_desc';
/** Canonical section enum kept in sync with backend SECTIONS_ORDER
* and contractBlocksService.ALLOWED_SECTIONS. Renaming any value
@@ -426,6 +429,9 @@ export interface PublicContractView {
city: string | null;
email: string | null;
website: string | null;
/** Light + dark branding logo URLs; the page picks per its colour mode. */
logoUrl?: string | null;
logoUrlDark?: string | null;
} | null;
/** Admin-set behaviour flags surfaced for the public sign page.
* Server re-enforces both these only drive the UI. */
+39 -5
View File
@@ -58,8 +58,12 @@ export interface CustomerAccountDetail extends CustomerAccountSummary {
* - 'monthly' / 'quarterly': snap every scheduled invoice to
* `billingCycleDay` of the next period.
*/
billingCadence?: 'per_event' | 'monthly' | 'quarterly';
billingCadence?: 'per_event' | 'monthly' | 'quarterly' | 'manual';
billingCycleDay?: number;
/** Per-customer Skonto opt-out (migration 112). When true, none of
* this customer's invoices qualify for an early-payment discount,
* regardless of template / global defaults. */
skontoDisabled?: boolean;
notes: string | null;
events: Array<{
id: number;
@@ -158,6 +162,8 @@ export const customerAdminService = {
// CRM billing cadence (migration 102 + 128).
billingCadence: 'billing_cadence',
billingCycleDay: 'billing_cycle_day',
// Per-customer Skonto opt-out (migration 112).
skontoDisabled: 'skonto_disabled',
};
for (const [k, v] of Object.entries(payload)) {
if (k in map) snake[map[k]] = v;
@@ -334,6 +340,14 @@ export const customerAdminService = {
return (response.data as any).data ?? response.data;
},
/** Landing aggregate for /admin/clients/hours every customer that
* currently carries unbilled hour entries, with open hours + open
* amount (install default currency). Sorted by open amount desc. */
async getUnbilledHoursSummary(): Promise<UnbilledHoursSummaryRow[]> {
const response = await api.get(`/admin/customers/hour-entries/unbilled-summary`);
return ((response.data as any).data?.summary ?? (response.data as any).summary) || [];
},
/** Admin override issue the customer's running monthly draft now,
* bypassing the cadence-day wait. 409 when no draft exists or the
* draft is empty. Returns the issued invoice id + number. */
@@ -354,16 +368,18 @@ export const customerAdminService = {
},
};
/** Open monthly bill accumulator preview (migration 128). One row in
/** Open bill accumulator preview (migration 128). One row in
* the invoices table with is_monthly_draft=true that gathers every
* invoice line created for this customer during the current period;
* ships on the cadence day or via triggerMonthlyBill. */
* ships on the cadence day or via triggerMonthlyBill. Manual-cadence
* drafts carry no period (periodStart/End null) and ship only on the
* admin trigger. */
export interface MonthlyDraftPreview {
id: number;
invoiceNumber: string;
currency: string;
periodStart: string;
periodEnd: string;
periodStart: string | null;
periodEnd: string | null;
netAmountMinor: number;
vatRate: number | null;
vatAmountMinor: number;
@@ -411,6 +427,24 @@ export interface HourEntry {
updatedAt: string;
}
export interface UnbilledHoursSummaryRow {
customerAccountId: number;
companyName: string | null;
displayName: string | null;
firstName: string | null;
lastName: string | null;
email: string | null;
isPassive: boolean;
billingCadence: string | null;
entryCount: number;
totalMinutes: number;
openAmountMinor: number;
/** false when at least one entry has no resolvable rate (no override,
* no customer rate, no install default) its amount is excluded from
* openAmountMinor and the UI prompts to set a rate. */
rateResolvable: boolean;
}
export interface HourEntryCreatePayload {
entryDate: string; // YYYY-MM-DD
startTime: string; // HH:MM
+47
View File
@@ -1,5 +1,27 @@
import { api } from '../config/api';
export type EmailQueueStatus = 'pending' | 'sent' | 'failed';
export interface EmailQueueItem {
id: number;
recipientEmail: string;
emailType: string;
status: EmailQueueStatus;
createdAt: string;
scheduledAt: string | null;
sentAt: string | null;
errorMessage: string | null;
retryCount: number;
eventId: number | null;
eventName: string | null;
eventSlug: string | null;
}
export interface EmailQueueListResponse {
items: EmailQueueItem[];
pagination: { total: number; page: number; pageSize: number; totalPages: number };
}
export interface EmailConfig {
smtp_host: string;
smtp_port: number;
@@ -74,6 +96,31 @@ export const emailService = {
await api.post('/admin/email/test', { test_email: testEmail });
},
/** Flush the email queue immediately. Sends every pending email now,
* bypassing the business-hours floor the escape hatch for draining
* the queue before maintenance/updates. */
async flushQueue(): Promise<{ processed: number; sent: number; failed: number }> {
const response = await api.post<{ processed: number; sent: number; failed: number }>(
'/admin/email/flush-queue'
);
return response.data;
},
/** Read-only "Sent emails" feed paginated view of the email_queue
* table with filters. email_data is never returned. */
async listQueue(params: {
status?: EmailQueueStatus;
emailType?: string;
q?: string;
from?: string;
to?: string;
page?: number;
pageSize?: number;
} = {}): Promise<EmailQueueListResponse> {
const response = await api.get<EmailQueueListResponse>('/admin/email/queue', { params });
return response.data;
},
// Get all email templates
async getTemplates(): Promise<EmailTemplate[]> {
const response = await api.get<EmailTemplate[]>('/admin/email/templates');
@@ -32,6 +32,8 @@ export interface PaymentCheckIssuer {
email?: string;
website?: string;
logoUrl: string | null;
/** Dark-mode branding logo; the page picks per its colour mode. */
logoUrlDark?: string | null;
}
export interface PaymentCheckResponse {
invoice: PaymentCheckView;

Some files were not shown because too many files have changed in this diff Show More