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:
@@ -2305,6 +2305,13 @@
|
||||
"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"
|
||||
},
|
||||
"commonSmtpSettings": "Häufige SMTP-Einstellungen:",
|
||||
"editTemplate": "Vorlage bearbeiten",
|
||||
"templateName": "Vorlagenname",
|
||||
@@ -3686,6 +3693,25 @@
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -1959,6 +1959,13 @@
|
||||
"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"
|
||||
},
|
||||
"commonSmtpSettings": "Common SMTP Settings:",
|
||||
"editTemplate": "Edit Template",
|
||||
"templateName": "Template Name",
|
||||
@@ -3683,6 +3690,25 @@
|
||||
"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",
|
||||
"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",
|
||||
|
||||
@@ -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 09–12 / 13–18 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
|
||||
|
||||
@@ -84,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;
|
||||
|
||||
@@ -74,6 +74,16 @@ 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;
|
||||
},
|
||||
|
||||
// Get all email templates
|
||||
async getTemplates(): Promise<EmailTemplate[]> {
|
||||
const response = await api.get<EmailTemplate[]>('/admin/email/templates');
|
||||
|
||||
Reference in New Issue
Block a user