feat(setup): per-feature config step after feature selection

When the chosen features need config the wizard can collect, 'Finish' on
the usage step now advances to a lean config step instead of jumping to
the dashboard:
- Invoicing (if Invoices): company/legal name, address, VAT-ID or tax
  number, IBAN, currency → saved to business-profile + a default bank
  account. Carries the bank/VAT legal disclaimer.
- Email (if reminders/incoming-mail/whatsapp/invoices): SMTP host/port/
  user/pass/from → saved to email_configs.
Each section persists only if started, and 'Skip for now' is always
available — soft settings keep their seeded defaults. en + de strings.
This commit is contained in:
Luca
2026-07-02 22:10:54 +02:00
parent a95ee473ae
commit 07b450a954
4 changed files with 226 additions and 5 deletions
@@ -0,0 +1,153 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { ShieldAlert } from 'lucide-react';
import { Button, Input } from '../common';
import type { FeatureKey } from '../../services/featureFlags.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { emailService, type EmailConfig } from '../../services/email.service';
// Features that need working SMTP to deliver anything.
const EMAIL_FEATURES: FeatureKey[] = ['reminderEmails', 'incomingMail', 'whatsapp', 'bills'];
interface Props {
selectedFeatures: Set<FeatureKey>;
onDone: () => void;
}
// Lean per-feature config, shown after the "How will you use PicPeak?" step.
// Only the sections a selected feature actually needs are rendered; everything
// else keeps its seeded defaults and is tunable later in Settings. Saving is
// best-effort per section — a failure never traps the user on setup.
export const SetupConfigStep: React.FC<Props> = ({ selectedFeatures, onDone }) => {
const { t } = useTranslation();
const showInvoicing = selectedFeatures.has('bills');
const showEmail = EMAIL_FEATURES.some((f) => selectedFeatures.has(f));
const [saving, setSaving] = useState(false);
const [inv, setInv] = useState({
companyName: '', addressLine1: '', postalCode: '', city: '', countryCode: '',
vatId: '', taxId: '', defaultCurrency: 'CHF', iban: '',
});
const [mail, setMail] = useState({
smtp_host: '', smtp_port: '587', smtp_user: '', smtp_pass: '', from_email: '', from_name: '',
});
const invField = (k: keyof typeof inv) => (e: React.ChangeEvent<HTMLInputElement>) =>
setInv((p) => ({ ...p, [k]: e.target.value }));
const mailField = (k: keyof typeof mail) => (e: React.ChangeEvent<HTMLInputElement>) =>
setMail((p) => ({ ...p, [k]: e.target.value }));
const finish = async () => {
setSaving(true);
try {
// Invoicing: only persist if they actually started filling it in.
if (showInvoicing && inv.companyName.trim()) {
await businessProfileService.update({
companyName: inv.companyName.trim(),
addressLine1: inv.addressLine1.trim(),
postalCode: inv.postalCode.trim(),
city: inv.city.trim(),
countryCode: inv.countryCode.trim(),
vatId: inv.vatId.trim(),
taxId: inv.taxId.trim(),
defaultCurrency: inv.defaultCurrency.trim() || 'CHF',
});
if (inv.iban.trim()) {
await businessProfileService.createBankAccount({
iban: inv.iban.replace(/\s+/g, ''),
accountHolder: inv.companyName.trim(),
currency: inv.defaultCurrency.trim() || 'CHF',
isDefault: true,
});
}
}
// Email: only persist if a host was entered.
if (showEmail && mail.smtp_host.trim()) {
const port = parseInt(mail.smtp_port, 10) || 587;
const config: EmailConfig = {
smtp_host: mail.smtp_host.trim(),
smtp_port: port,
smtp_secure: port === 465,
smtp_user: mail.smtp_user.trim(),
smtp_pass: mail.smtp_pass,
from_email: mail.from_email.trim(),
from_name: mail.from_name.trim(),
tls_reject_unauthorized: true,
};
await emailService.updateConfig(config);
}
} catch (_) {
toast.warn(t('setup.config.saveFailed', 'Some settings could not be saved — you can finish them in Settings.'));
} finally {
setSaving(false);
onDone();
}
};
return (
<div className="space-y-8">
<p className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2 text-xs text-neutral-600">
{t('setup.config.intro', 'A few details for the features you picked. Anything you skip keeps its default and can be set later in Settings.')}
</p>
{showInvoicing && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-800">{t('setup.config.invoicing', 'Invoicing details')}</h3>
<div className="flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3">
<ShieldAlert className="mt-0.5 h-4 w-4 flex-shrink-0 text-amber-600" />
<p className="text-xs text-amber-800">
{t('setup.config.invoicingDisclaimer', 'Used on your invoices. Bank/IBAN and VAT details are your responsibility — verify them with your bank and Treuhänder/tax advisor.')}
</p>
</div>
<Input placeholder={t('setup.config.companyName', 'Company / legal name')} value={inv.companyName} onChange={invField('companyName')} />
<Input placeholder={t('setup.config.addressLine1', 'Street and number')} value={inv.addressLine1} onChange={invField('addressLine1')} />
<div className="grid grid-cols-3 gap-3">
<Input placeholder={t('setup.config.postalCode', 'Postal code')} value={inv.postalCode} onChange={invField('postalCode')} />
<div className="col-span-2"><Input placeholder={t('setup.config.city', 'City')} value={inv.city} onChange={invField('city')} /></div>
</div>
<div className="grid grid-cols-2 gap-3">
<Input placeholder={t('setup.config.countryCode', 'Country code (e.g. CH)')} value={inv.countryCode} onChange={invField('countryCode')} />
<Input placeholder={t('setup.config.currency', 'Currency (e.g. CHF)')} value={inv.defaultCurrency} onChange={invField('defaultCurrency')} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input placeholder={t('setup.config.vatId', 'VAT ID (or leave blank)')} value={inv.vatId} onChange={invField('vatId')} />
<Input placeholder={t('setup.config.taxId', 'Tax number (or VAT ID)')} value={inv.taxId} onChange={invField('taxId')} />
</div>
<Input placeholder={t('setup.config.iban', 'IBAN (for invoice payments)')} value={inv.iban} onChange={invField('iban')} />
</div>
)}
{showEmail && (
<div className="space-y-3">
<h3 className="text-sm font-semibold text-neutral-800">{t('setup.config.email', 'Email delivery (SMTP)')}</h3>
<p className="text-xs text-neutral-500">{t('setup.config.emailHint', 'Required to send reminders, invoices and notifications.')}</p>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2"><Input placeholder={t('setup.config.smtpHost', 'SMTP host')} value={mail.smtp_host} onChange={mailField('smtp_host')} /></div>
<Input placeholder={t('setup.config.smtpPort', 'Port')} value={mail.smtp_port} onChange={mailField('smtp_port')} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input placeholder={t('setup.config.smtpUser', 'Username')} value={mail.smtp_user} onChange={mailField('smtp_user')} autoComplete="off" />
<Input type="password" placeholder={t('setup.config.smtpPass', 'Password')} value={mail.smtp_pass} onChange={mailField('smtp_pass')} autoComplete="new-password" />
</div>
<div className="grid grid-cols-2 gap-3">
<Input type="email" placeholder={t('setup.config.fromEmail', 'From address')} value={mail.from_email} onChange={mailField('from_email')} />
<Input placeholder={t('setup.config.fromName', 'From name')} value={mail.from_name} onChange={mailField('from_name')} />
</div>
</div>
)}
<div className="flex gap-3">
<Button type="button" variant="outline" size="lg" onClick={onDone} disabled={saving}>
{t('setup.config.skip', 'Skip for now')}
</Button>
<Button type="button" variant="primary" size="lg" isLoading={saving} className="flex-1" onClick={finish}>
{t('setup.config.finish', 'Finish setup')}
</Button>
</div>
</div>
);
};
SetupConfigStep.displayName = 'SetupConfigStep';
+26
View File
@@ -3509,6 +3509,32 @@
"restoreEntry": "Wechsel von einer anderen PicPeak-Instanz?", "restoreEntry": "Wechsel von einer anderen PicPeak-Instanz?",
"restoreEntryHint": "Stellen Sie stattdessen ein .picpeak-Backup wieder her, anstatt neu einzurichten.", "restoreEntryHint": "Stellen Sie stattdessen ein .picpeak-Backup wieder her, anstatt neu einzurichten.",
"restoreIntro": "Laden Sie ein .picpeak-Backup hoch, um eine andere Instanz auf diese zu klonen. Dies ersetzt alles außer dem gerade erstellten Konto.", "restoreIntro": "Laden Sie ein .picpeak-Backup hoch, um eine andere Instanz auf diese zu klonen. Dies ersetzt alles außer dem gerade erstellten Konto.",
"config": {
"subtitle": "Richten Sie Ihre Funktionen ein",
"intro": "Einige Angaben zu den gewählten Funktionen. Was Sie überspringen, behält den Standard und kann später in den Einstellungen festgelegt werden.",
"invoicing": "Rechnungsdaten",
"invoicingDisclaimer": "Erscheint auf Ihren Rechnungen. Bank-/IBAN- und Mehrwertsteuerangaben liegen in Ihrer Verantwortung — prüfen Sie sie mit Ihrer Bank und Ihrem Treuhänder/Steuerberater.",
"companyName": "Firma / rechtlicher Name",
"addressLine1": "Strasse und Nummer",
"postalCode": "PLZ",
"city": "Ort",
"countryCode": "Ländercode (z. B. CH)",
"currency": "Währung (z. B. CHF)",
"vatId": "MwSt-Nummer (oder leer lassen)",
"taxId": "Steuernummer (oder MwSt-Nummer)",
"iban": "IBAN (für Rechnungszahlungen)",
"email": "E-Mail-Versand (SMTP)",
"emailHint": "Erforderlich, um Erinnerungen, Rechnungen und Benachrichtigungen zu senden.",
"smtpHost": "SMTP-Host",
"smtpPort": "Port",
"smtpUser": "Benutzername",
"smtpPass": "Passwort",
"fromEmail": "Absenderadresse",
"fromName": "Absendername",
"skip": "Vorerst überspringen",
"finish": "Einrichtung abschließen",
"saveFailed": "Einige Einstellungen konnten nicht gespeichert werden — Sie können sie in den Einstellungen abschließen."
},
"stepOf": "Schritt {{current}} von {{total}}", "stepOf": "Schritt {{current}} von {{total}}",
"continue": "Weiter", "continue": "Weiter",
"back": "Zurück", "back": "Zurück",
+26
View File
@@ -3405,6 +3405,32 @@
"restoreEntry": "Migrating from another PicPeak?", "restoreEntry": "Migrating from another PicPeak?",
"restoreEntryHint": "Restore a .picpeak backup instead of setting up fresh.", "restoreEntryHint": "Restore a .picpeak backup instead of setting up fresh.",
"restoreIntro": "Upload a .picpeak backup to clone another instance onto this one. This replaces everything except the account you just created.", "restoreIntro": "Upload a .picpeak backup to clone another instance onto this one. This replaces everything except the account you just created.",
"config": {
"subtitle": "Set up your features",
"intro": "A few details for the features you picked. Anything you skip keeps its default and can be set later in Settings.",
"invoicing": "Invoicing details",
"invoicingDisclaimer": "Used on your invoices. Bank/IBAN and VAT details are your responsibility — verify them with your bank and Treuhänder/tax advisor.",
"companyName": "Company / legal name",
"addressLine1": "Street and number",
"postalCode": "Postal code",
"city": "City",
"countryCode": "Country code (e.g. CH)",
"currency": "Currency (e.g. CHF)",
"vatId": "VAT ID (or leave blank)",
"taxId": "Tax number (or VAT ID)",
"iban": "IBAN (for invoice payments)",
"email": "Email delivery (SMTP)",
"emailHint": "Required to send reminders, invoices and notifications.",
"smtpHost": "SMTP host",
"smtpPort": "Port",
"smtpUser": "Username",
"smtpPass": "Password",
"fromEmail": "From address",
"fromName": "From name",
"skip": "Skip for now",
"finish": "Finish setup",
"saveFailed": "Some settings could not be saved — you can finish them in Settings."
},
"stepOf": "Step {{current}} of {{total}}", "stepOf": "Step {{current}} of {{total}}",
"continue": "Continue", "continue": "Continue",
"back": "Back", "back": "Back",
+20 -4
View File
@@ -10,6 +10,7 @@ import { useAdminAuth } from '../contexts';
import { setupService } from '../services/setup.service'; import { setupService } from '../services/setup.service';
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service'; import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard'; import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
import { SetupConfigStep } from '../components/admin/SetupConfigStep';
import { resolveLoginLogoClasses } from '../utils/loginLogoSize'; import { resolveLoginLogoClasses } from '../utils/loginLogoSize';
import type { AdminUser } from '../types'; import type { AdminUser } from '../types';
@@ -52,7 +53,7 @@ export const SetupPage: React.FC = () => {
staleTime: Infinity, staleTime: Infinity,
}); });
const [step, setStep] = useState<'token' | 'account' | 'usage' | 'restore'>('token'); const [step, setStep] = useState<'token' | 'account' | 'usage' | 'restore' | 'config'>('token');
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' }); const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -226,7 +227,15 @@ export const SetupPage: React.FC = () => {
toast.warn(t('setup.featuresSaveFailed')); toast.warn(t('setup.featuresSaveFailed'));
} finally { } finally {
setIsSavingFeatures(false); setIsSavingFeatures(false);
navigate('/admin/dashboard', { replace: true }); // If the chosen features need config the wizard can collect (invoicing,
// email), go to the config step; otherwise enter the app.
const needsConfig =
selectedFeatures.has('bills') ||
selectedFeatures.has('reminderEmails') ||
selectedFeatures.has('incomingMail') ||
selectedFeatures.has('whatsapp');
if (needsConfig) setStep('config');
else navigate('/admin/dashboard', { replace: true });
} }
}; };
@@ -256,9 +265,11 @@ export const SetupPage: React.FC = () => {
? t('setup.accountStepSubtitle') ? t('setup.accountStepSubtitle')
: step === 'restore' : step === 'restore'
? t('setup.restoreStepSubtitle') ? t('setup.restoreStepSubtitle')
: step === 'config'
? t('setup.config.subtitle')
: t('setup.usageSubtitle')} : t('setup.usageSubtitle')}
</p> </p>
{step !== 'restore' && ( {(step === 'token' || step === 'account' || step === 'usage') && (
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: '#171717', opacity: 0.5 }}> <p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: '#171717', opacity: 0.5 }}>
{t('setup.stepOf', { current: stepNumber, total: 3 })} {t('setup.stepOf', { current: stepNumber, total: 3 })}
</p> </p>
@@ -461,7 +472,7 @@ export const SetupPage: React.FC = () => {
{selectedFeatures.size > 0 ? t('setup.finish') : t('setup.usageSkip')} {selectedFeatures.size > 0 ? t('setup.finish') : t('setup.usageSkip')}
</Button> </Button>
</div> </div>
) : ( ) : step === 'restore' ? (
<div className="space-y-6"> <div className="space-y-6">
<p className="text-sm text-neutral-600">{t('setup.restoreIntro')}</p> <p className="text-sm text-neutral-600">{t('setup.restoreIntro')}</p>
<PicpeakRestoreCard /> <PicpeakRestoreCard />
@@ -475,6 +486,11 @@ export const SetupPage: React.FC = () => {
{t('setup.back')} {t('setup.back')}
</Button> </Button>
</div> </div>
) : (
<SetupConfigStep
selectedFeatures={selectedFeatures}
onDone={() => navigate('/admin/dashboard', { replace: true })}
/>
)} )}
</Card> </Card>
</div> </div>