feat(email-templates): group Templates UI by category + Feature off chip

This commit is contained in:
Luca
2026-05-11 20:42:16 +02:00
parent 84c06affb7
commit 5ec26fc998
7 changed files with 153 additions and 38 deletions
+10 -1
View File
@@ -2226,7 +2226,16 @@
"mutedTextColor": "Fußzeilentext", "mutedTextColor": "Fußzeilentext",
"mutedTextColorHelp": "Fußzeilentext und Copyright-Zeile. Entspricht Branding → Sekundärer Text.", "mutedTextColorHelp": "Fußzeilentext und Copyright-Zeile. Entspricht Branding → Sekundärer Text.",
"buttonTextColor": "Button-Text", "buttonTextColor": "Button-Text",
"buttonTextColorHelp": "Textfarbe auf gefüllten Buttons. Sollte klar gegen die Primärfarbe kontrastieren. Kein Branding-Äquivalent — meistens weiß." "buttonTextColorHelp": "Textfarbe auf gefüllten Buttons. Sollte klar gegen die Primärfarbe kontrastieren. Kein Branding-Äquivalent — meistens weiß.",
"categories": {
"core": "Kern",
"customers": "Kunden",
"calendar": "Kalender",
"quotes": "Angebote",
"billing": "Rechnungen"
},
"featureOff": "Funktion aus",
"featureOffTooltip": "Die zugehörige Funktion ist derzeit deaktiviert. Du kannst die Vorlage trotzdem bearbeiten — sie wird verwendet, sobald die Funktion wieder aktiviert ist."
}, },
"cms": { "cms": {
"title": "CMS-Seiten", "title": "CMS-Seiten",
+10 -1
View File
@@ -1880,7 +1880,16 @@
"mutedTextColor": "Footer text", "mutedTextColor": "Footer text",
"mutedTextColorHelp": "Footer text and copyright line. Maps to Branding → Secondary text.", "mutedTextColorHelp": "Footer text and copyright line. Maps to Branding → Secondary text.",
"buttonTextColor": "Button text", "buttonTextColor": "Button text",
"buttonTextColorHelp": "Text colour on filled buttons. Should contrast cleanly against the Primary colour. No Branding equivalent — usually white." "buttonTextColorHelp": "Text colour on filled buttons. Should contrast cleanly against the Primary colour. No Branding equivalent — usually white.",
"categories": {
"core": "Core",
"customers": "Customers",
"calendar": "Calendar",
"quotes": "Quotes",
"billing": "Billing"
},
"featureOff": "Feature off",
"featureOffTooltip": "The feature this template belongs to is currently disabled. You can still edit the template — it will be used once the feature is re-enabled."
}, },
"cms": { "cms": {
"title": "CMS Pages", "title": "CMS Pages",
+10 -1
View File
@@ -1826,7 +1826,16 @@
"enterUrl": "Saisir une URL…", "enterUrl": "Saisir une URL…",
"addLink": "Ajouter", "addLink": "Ajouter",
"cancel": "Annuler" "cancel": "Annuler"
} },
"categories": {
"core": "Principal",
"customers": "Clients",
"calendar": "Calendrier",
"quotes": "Devis",
"billing": "Facturation"
},
"featureOff": "Fonction désactivée",
"featureOffTooltip": "La fonctionnalité à laquelle appartient ce modèle est actuellement désactivée. Vous pouvez toujours modifier le modèle — il sera utilisé une fois la fonctionnalité réactivée."
}, },
"cms": { "cms": {
"title": "Pages CMS", "title": "Pages CMS",
+10 -1
View File
@@ -1880,7 +1880,16 @@
"mutedTextColor": "Voetteksttekst", "mutedTextColor": "Voetteksttekst",
"mutedTextColorHelp": "Voetteksttekst en copyrightregel. Komt overeen met Huisstijl → Secundaire tekst.", "mutedTextColorHelp": "Voetteksttekst en copyrightregel. Komt overeen met Huisstijl → Secundaire tekst.",
"buttonTextColor": "Knoptekst", "buttonTextColor": "Knoptekst",
"buttonTextColorHelp": "Tekstkleur op gevulde knoppen. Moet goed contrasteren met de primaire kleur. Doorgaans wit." "buttonTextColorHelp": "Tekstkleur op gevulde knoppen. Moet goed contrasteren met de primaire kleur. Doorgaans wit.",
"categories": {
"core": "Kern",
"customers": "Klanten",
"calendar": "Agenda",
"quotes": "Offertes",
"billing": "Facturatie"
},
"featureOff": "Functie uit",
"featureOffTooltip": "De functie waar deze sjabloon bij hoort, is momenteel uitgeschakeld. U kunt de sjabloon nog steeds bewerken — deze wordt gebruikt zodra de functie weer is ingeschakeld."
}, },
"cms": { "cms": {
"title": "CMS-pagina's", "title": "CMS-pagina's",
+10 -1
View File
@@ -1905,7 +1905,16 @@
"mutedTextColor": "Texto do rodapé", "mutedTextColor": "Texto do rodapé",
"mutedTextColorHelp": "Texto do rodapé e linha de copyright. Corresponde a Identidade Visual → Texto secundário.", "mutedTextColorHelp": "Texto do rodapé e linha de copyright. Corresponde a Identidade Visual → Texto secundário.",
"buttonTextColor": "Texto dos botões", "buttonTextColor": "Texto dos botões",
"buttonTextColorHelp": "Cor do texto em botões preenchidos. Deve contrastar bem com a cor primária. Geralmente branco." "buttonTextColorHelp": "Cor do texto em botões preenchidos. Deve contrastar bem com a cor primária. Geralmente branco.",
"categories": {
"core": "Principal",
"customers": "Clientes",
"calendar": "Calendário",
"quotes": "Orçamentos",
"billing": "Faturamento"
},
"featureOff": "Recurso desativado",
"featureOffTooltip": "O recurso ao qual este modelo pertence está atualmente desativado. Você ainda pode editar o modelo — ele será usado assim que o recurso for reativado."
}, },
"cms": { "cms": {
"title": "Páginas CMS", "title": "Páginas CMS",
+10 -1
View File
@@ -1930,7 +1930,16 @@
"mutedTextColor": "Текст подвала", "mutedTextColor": "Текст подвала",
"mutedTextColorHelp": "Текст подвала и строка авторского права. Соответствует Брендинг → Дополнительный текст.", "mutedTextColorHelp": "Текст подвала и строка авторского права. Соответствует Брендинг → Дополнительный текст.",
"buttonTextColor": "Текст кнопок", "buttonTextColor": "Текст кнопок",
"buttonTextColorHelp": "Цвет текста на заполненных кнопках. Должен хорошо контрастировать с основным цветом. Обычно белый." "buttonTextColorHelp": "Цвет текста на заполненных кнопках. Должен хорошо контрастировать с основным цветом. Обычно белый.",
"categories": {
"core": "Основное",
"customers": "Клиенты",
"calendar": "Календарь",
"quotes": "Сметы",
"billing": "Счета"
},
"featureOff": "Функция выключена",
"featureOffTooltip": "Связанная функция в данный момент отключена. Вы по-прежнему можете редактировать шаблон — он будет использоваться после повторного включения функции."
}, },
"cms": { "cms": {
"title": "Страницы CMS", "title": "Страницы CMS",
+93 -32
View File
@@ -24,6 +24,22 @@ import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateT
import { settingsService } from '../../services/settings.service'; import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES } from "../../components/common/LanguageSelector.tsx"; import { SUPPORTED_LANGUAGES } from "../../components/common/LanguageSelector.tsx";
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
/**
* Template categorisation (migration 098). Order matters — sidebar
* sections render in this sequence. Empty categories are hidden
* automatically. New categories: add the key here, give it an i18n
* label below, set `feature_flag` on templates that should chip
* out when the matching flag is off. No other UI changes required.
*/
const CATEGORY_ORDER: readonly string[] = [
'core',
'customers',
'calendar',
'quotes',
'billing',
] as const;
const defaultTemplateKeys = [ const defaultTemplateKeys = [
{ {
@@ -117,6 +133,7 @@ export const EmailConfigPage: React.FC = () => {
const [emailMutedTextColor, setEmailMutedTextColor] = useState('#666666'); const [emailMutedTextColor, setEmailMutedTextColor] = useState('#666666');
const [emailButtonTextColor, setEmailButtonTextColor] = useState('#ffffff'); const [emailButtonTextColor, setEmailButtonTextColor] = useState('#ffffff');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { flags: featureFlags } = useFeatureFlags();
// SMTP Configuration state // SMTP Configuration state
const [smtpConfig, setSmtpConfig] = useState<EmailConfig>({ const [smtpConfig, setSmtpConfig] = useState<EmailConfig>({
@@ -714,39 +731,83 @@ export const EmailConfigPage: React.FC = () => {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card padding="sm"> <Card padding="sm">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('email.templates')}</h3> <h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('email.templates')}</h3>
<div className="space-y-2"> {/* Templates grouped by category (migration 098). Categories
{templates.map(template => { in CATEGORY_ORDER render in sequence; templates that
const templateInfo = defaultTemplateKeys.find(t => t.key === template.template_key); report an unrecognised category fall into 'core' so a
const translationCount = getTranslationCount(template); forward-compat row never disappears from the UI.
const enTranslation = template.translations?.en; Empty categories are hidden — admins don't see a
return ( section header with no body. Templates whose
<button feature_flag is currently false stay fully visible and
key={template.template_key} editable, just chip-tagged so the admin knows the
onClick={() => { feature is dormant. */}
setSelectedTemplateKey(template.template_key); {(() => {
setEditedTemplate(template); const grouped: Record<string, EmailTemplate[]> = {};
}} for (const template of templates) {
className={`w-full text-left p-3 rounded-lg transition-colors ${ const cat = CATEGORY_ORDER.includes(template.category || 'core')
selectedTemplateKey === template.template_key ? (template.category || 'core')
? 'tile-selected' : 'core';
: 'bg-neutral-50 dark:bg-neutral-700 border-2 border-transparent hover:bg-neutral-100 dark:hover:bg-neutral-600' (grouped[cat] = grouped[cat] || []).push(template);
}`} }
> const visibleSections = CATEGORY_ORDER.filter((c) => grouped[c]?.length);
<div className="flex items-center justify-between">
<p className="font-medium text-neutral-900 dark:text-neutral-100"> return (
{templateInfo?.name || template.template_key} <div className="space-y-5">
</p> {visibleSections.map((category) => (
<span className="text-xs px-2 py-0.5 rounded-full bg-neutral-200 dark:bg-neutral-600 text-neutral-600 dark:text-neutral-300"> <div key={category}>
{translationCount}/{SUPPORTED_LANGUAGES.length} <h4 className="px-1 mb-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
</span> {t(`email.categories.${category}`, category)}
</h4>
<div className="space-y-2">
{grouped[category].map((template) => {
const templateInfo = defaultTemplateKeys.find((t) => t.key === template.template_key);
const translationCount = getTranslationCount(template);
const enTranslation = template.translations?.en;
const featureOff = template.feature_flag
? featureFlags[template.feature_flag as FeatureKey] === false
: false;
return (
<button
key={template.template_key}
onClick={() => {
setSelectedTemplateKey(template.template_key);
setEditedTemplate(template);
}}
className={`w-full text-left p-3 rounded-lg transition-colors ${
selectedTemplateKey === template.template_key
? 'tile-selected'
: 'bg-neutral-50 dark:bg-neutral-700 border-2 border-transparent hover:bg-neutral-100 dark:hover:bg-neutral-600'
}`}
>
<div className="flex items-center justify-between gap-2">
<p className="font-medium text-neutral-900 dark:text-neutral-100 truncate">
{templateInfo?.name || template.template_key}
</p>
<div className="flex items-center gap-1.5 flex-shrink-0">
{featureOff && (
<span
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"
title={t('email.featureOffTooltip', 'The feature this template belongs to is currently disabled. You can still edit the template — it will be used once the feature is re-enabled.')}
>
{t('email.featureOff', 'Feature off')}
</span>
)}
<span className="text-xs px-2 py-0.5 rounded-full bg-neutral-200 dark:bg-neutral-600 text-neutral-600 dark:text-neutral-300">
{translationCount}/{SUPPORTED_LANGUAGES.length}
</span>
</div>
</div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1 truncate">
{enTranslation?.subject || ''}
</p>
</button>
);
})}
</div>
</div> </div>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1 truncate"> ))}
{enTranslation?.subject || ''} </div>
</p> );
</button> })()}
);
})}
</div>
</Card> </Card>
<div className="lg:col-span-2"> <div className="lg:col-span-2">