feat(email): per-weekday business hours + manual queue flush

Move the scheduled-email business-hours floor onto the business profile
as Google-style per-weekday opening blocks (multiple blocks/day for lunch
breaks). Migration 114 adds business_profile.business_hours (JSON) +
scheduled_email_floor_enabled; emailProcessor snaps a queued email to the
next open block, read in the profile timezone. Editor lives under
Settings → Business profile.
Add an admin "Send queued emails now" flush (POST /admin/email/flush-queue)
that drains the queue immediately, ignoring the business-hours floor — the
escape hatch before maintenance/updates. processEmailQueue now takes
{ignoreSchedule, limit} and returns send counts; the scheduled interval
run is unchanged.
This commit is contained in:
Luca
2026-06-02 13:00:13 +02:00
parent 93956db0ca
commit 621ce942b5
13 changed files with 988 additions and 19 deletions
@@ -248,6 +248,20 @@ export const EmailConfigPage: React.FC = () => {
}
});
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: () => {
toast.error(t('toast.saveError'));
}
});
const saveTemplateMutation = useMutation({
mutationFn: ({ key, translations }: { key: string; translations: Record<string, EmailTemplateTranslation> }) =>
emailService.updateTemplate(key, { translations }),
@@ -665,6 +679,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>
)}
@@ -8,11 +8,13 @@
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, CountrySelect } from '../../../components/common';
@@ -251,6 +253,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.
@@ -307,6 +340,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">
<Input
type="time"
value={block.start}
onChange={(e) => updateBlock(iso, idx, { start: e.target.value })}
className="w-32"
/>
<span className="text-neutral-400"></span>
<Input
type="time"
value={block.end}
onChange={(e) => updateBlock(iso, idx, { end: e.target.value })}
className="w-32"
/>
<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