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