Merge pull request #731 from Luca-Timo/feat/setup-wizard-and-backup-roundtrip
feat: first-run setup wizard (feature selection + config) and portable .picpeak backup roundtrip
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Download, Upload, AlertTriangle, ShieldAlert, ExternalLink, CheckCircle2 } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Card } from '../common';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
// Portable ".picpeak" roundtrip, split across two Backup Manager tabs:
|
||||
// - PicpeakExportCard → Dashboard (making a backup)
|
||||
// - PicpeakRestoreCard → Restore (restoring a backup)
|
||||
// The manifest is bundled inside the .picpeak, so there is no separate
|
||||
// "manifest only" download here.
|
||||
|
||||
interface RestoreResult {
|
||||
tables: number;
|
||||
filesRestored: number;
|
||||
usesExternalMedia: boolean;
|
||||
}
|
||||
|
||||
// ── Download half (Dashboard) ────────────────────────────────────────────────
|
||||
export const PicpeakExportCard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [includePhotos, setIncludePhotos] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const handleDownload = async () => {
|
||||
setDownloading(true);
|
||||
try {
|
||||
const res = await api.get('/admin/backup/picpeak/export', {
|
||||
params: { includePhotos },
|
||||
responseType: 'blob',
|
||||
});
|
||||
const cd = (res.headers['content-disposition'] as string) || '';
|
||||
const match = cd.match(/filename="?([^"]+)"?/);
|
||||
const filename = (match && match[1]) || 'picpeak-backup.picpeak';
|
||||
const url = window.URL.createObjectURL(res.data as Blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (_) {
|
||||
toast.error(t('backup.picpeak.downloadFailed', 'Could not create the backup file.'));
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('backup.picpeak.title', 'Portable backup (.picpeak)')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('backup.picpeak.intro', 'Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.')}
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-700 dark:text-neutral-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-neutral-300"
|
||||
checked={includePhotos}
|
||||
onChange={(e) => setIncludePhotos(e.target.checked)}
|
||||
/>
|
||||
{t('backup.picpeak.includePhotos', 'Include original gallery photos (larger file)')}
|
||||
</label>
|
||||
<div className="mt-3 flex items-start gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/50 dark:bg-amber-900/20">
|
||||
<ShieldAlert className="mt-0.5 h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400" />
|
||||
<p className="text-xs text-amber-800 dark:text-amber-200">
|
||||
{t('backup.picpeak.secretsWarning', 'This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-3"
|
||||
isLoading={downloading}
|
||||
onClick={handleDownload}
|
||||
leftIcon={<Download className="h-4 w-4" />}
|
||||
>
|
||||
{t('backup.picpeak.download', 'Download .picpeak')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
PicpeakExportCard.displayName = 'PicpeakExportCard';
|
||||
|
||||
// ── Restore half (Restore tab) ───────────────────────────────────────────────
|
||||
export const PicpeakRestoreCard: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [result, setResult] = useState<RestoreResult | null>(null);
|
||||
|
||||
const onFilePick = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) setPendingFile(f);
|
||||
e.target.value = ''; // let the user re-pick the same file after cancelling
|
||||
};
|
||||
|
||||
const confirmRestore = async () => {
|
||||
if (!pendingFile) return;
|
||||
setRestoring(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('backup', pendingFile);
|
||||
const res = await api.post<RestoreResult>('/admin/backup/picpeak/import', fd);
|
||||
setResult(res.data);
|
||||
setPendingFile(null);
|
||||
toast.success(t('backup.picpeak.restoreDone', 'Backup restored.'));
|
||||
} catch (e: any) {
|
||||
const msg = e.response?.data?.error || t('backup.picpeak.restoreFailed', 'Restore failed.');
|
||||
toast.error(msg);
|
||||
setPendingFile(null);
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card padding="lg">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')}
|
||||
</p>
|
||||
<input ref={fileRef} type="file" accept=".picpeak,application/zip" className="hidden" onChange={onFilePick} />
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
leftIcon={<Upload className="h-4 w-4" />}
|
||||
>
|
||||
{t('backup.picpeak.chooseFile', 'Choose .picpeak file…')}
|
||||
</Button>
|
||||
|
||||
{result && (
|
||||
<div className="mt-4 rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-900/50 dark:bg-green-900/20">
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle2 className="mt-0.5 h-5 w-5 flex-shrink-0 text-green-600 dark:text-green-400" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
{t('backup.picpeak.restoreDone', 'Backup restored.')}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-green-700 dark:text-green-300">
|
||||
{t('backup.picpeak.restoreSummary', '{{tables}} tables and {{files}} files restored.', {
|
||||
tables: result.tables,
|
||||
files: result.filesRestored,
|
||||
})}
|
||||
</p>
|
||||
{result.usesExternalMedia && (
|
||||
<p className="mt-2 flex items-start gap-1 text-xs text-amber-800 dark:text-amber-300">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>
|
||||
{t('backup.picpeak.externalMediaNote', 'This backup references an external-media library. Make sure external-media routing is configured on this instance.')}{' '}
|
||||
<a
|
||||
href="https://github.com/PicPeak/picpeak/blob/main/README.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 underline"
|
||||
>
|
||||
{t('backup.picpeak.externalMediaLink', 'Setup guide')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
<Button variant="primary" size="sm" className="mt-3" onClick={() => window.location.reload()}>
|
||||
{t('backup.picpeak.reload', 'Reload app')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Destructive confirmation */}
|
||||
{pendingFile && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-neutral-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-6 w-6 flex-shrink-0 text-red-600 dark:text-red-400" />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('backup.picpeak.confirmTitle', 'Restore will delete all current data')}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-300">
|
||||
{t('backup.picpeak.confirmBody', 'This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.')}
|
||||
</p>
|
||||
<p className="mt-2 truncate text-xs text-neutral-500 dark:text-neutral-400">{pendingFile.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={() => setPendingFile(null)} disabled={restoring}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="!bg-red-600 hover:!bg-red-700"
|
||||
isLoading={restoring}
|
||||
onClick={confirmRestore}
|
||||
>
|
||||
{t('backup.picpeak.confirmRestore', 'Delete & restore')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
PicpeakRestoreCard.displayName = 'PicpeakRestoreCard';
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PicpeakRestoreCard } from './PicpeakBackupCard';
|
||||
import {
|
||||
RefreshCw,
|
||||
AlertTriangle,
|
||||
@@ -283,12 +284,47 @@ export const RestoreWizard = ({ onVerifyIntegrity } = {}) => {
|
||||
)}
|
||||
|
||||
{restoreData.source === 'upload' && (
|
||||
<Card className="p-4">
|
||||
<div className="text-center py-8">
|
||||
<Upload className="h-12 w-12 mx-auto mb-3 text-neutral-400" />
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.source.upload.comingSoon')}</p>
|
||||
<div className="space-y-4">
|
||||
{/* Two upload kinds: the working portable .picpeak restore, and the
|
||||
legacy manifest+files upload (still a stub). */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => setRestoreData(prev => ({ ...prev, uploadType: 'picpeak' }))}
|
||||
className={`p-6 rounded-lg border-2 transition-all ${
|
||||
restoreData.uploadType === 'picpeak'
|
||||
? 'border-primary bg-accent-dark/15'
|
||||
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
|
||||
}`}
|
||||
>
|
||||
<FileArchive className={`h-10 w-10 mb-2 mx-auto ${restoreData.uploadType === 'picpeak' ? 'text-primary' : 'text-neutral-400'}`} />
|
||||
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.upload.picpeak.name', '.picpeak backup')}</h4>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.upload.picpeak.description', 'Portable full backup — restores everything (full override, keeps your current account).')}</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRestoreData(prev => ({ ...prev, uploadType: 'manifest' }))}
|
||||
className={`p-6 rounded-lg border-2 transition-all ${
|
||||
restoreData.uploadType === 'manifest'
|
||||
? 'border-primary bg-accent-dark/15'
|
||||
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
|
||||
}`}
|
||||
>
|
||||
<Upload className={`h-10 w-10 mb-2 mx-auto ${restoreData.uploadType === 'manifest' ? 'text-primary' : 'text-neutral-400'}`} />
|
||||
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.upload.manifest.name', 'Manifest + files')}</h4>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.upload.manifest.description', 'Upload a manifest and its backup files (legacy format).')}</p>
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{restoreData.uploadType === 'picpeak' && <PicpeakRestoreCard />}
|
||||
|
||||
{restoreData.uploadType === 'manifest' && (
|
||||
<Card className="p-4">
|
||||
<div className="text-center py-8">
|
||||
<Upload className="h-12 w-12 mx-auto mb-3 text-neutral-400" />
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.source.upload.manifestComingSoon', 'Manifest Upload functionality coming soon')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
@@ -634,7 +634,16 @@
|
||||
"upload": {
|
||||
"name": "Backup hochladen",
|
||||
"description": "Eine Backup-Datei hochladen",
|
||||
"comingSoon": "Upload-Funktion kommt bald"
|
||||
"comingSoon": "Upload-Funktion kommt bald",
|
||||
"manifestComingSoon": "Manifest-Upload-Funktion kommt bald",
|
||||
"picpeak": {
|
||||
"name": ".picpeak-Backup",
|
||||
"description": "Portables Voll-Backup — stellt alles wieder her (vollständige Überschreibung, Ihr aktuelles Konto bleibt erhalten)."
|
||||
},
|
||||
"manifest": {
|
||||
"name": "Manifest + Dateien",
|
||||
"description": "Ein Manifest und die zugehörigen Backup-Dateien hochladen (Legacy-Format)."
|
||||
}
|
||||
},
|
||||
"configuration": {
|
||||
"s3": "S3-Konfiguration",
|
||||
@@ -1779,7 +1788,27 @@
|
||||
"title": "E-Mail-Einstellungen"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup"
|
||||
"title": "Backup",
|
||||
"picpeak": {
|
||||
"title": "Portables Backup (.picpeak)",
|
||||
"intro": "Laden Sie eine einzelne, in sich geschlossene Datei herunter und laden Sie sie auf einer anderen Instanz hoch, um diese zu klonen — komplett im Browser.",
|
||||
"includePhotos": "Original-Galeriefotos einschließen (größere Datei)",
|
||||
"secretsWarning": "Diese Datei enthält Geheimnisse im Klartext (E-Mail-Passwort, Admin-Zugangsdaten, API-Schlüssel). Bewahren Sie sie sicher auf und übertragen Sie sie nur über vertrauenswürdige Kanäle.",
|
||||
"download": ".picpeak herunterladen",
|
||||
"downloadFailed": "Die Backup-Datei konnte nicht erstellt werden.",
|
||||
"restoreTitle": "Aus einer .picpeak wiederherstellen",
|
||||
"restoreIntro": "Laden Sie eine .picpeak von dieser oder einer anderen Instanz hoch. Nur dieselbe Datenbank-Engine.",
|
||||
"chooseFile": ".picpeak-Datei auswählen…",
|
||||
"restoreDone": "Backup wiederhergestellt.",
|
||||
"restoreFailed": "Wiederherstellung fehlgeschlagen.",
|
||||
"restoreSummary": "{{tables}} Tabellen und {{files}} Dateien wiederhergestellt.",
|
||||
"externalMediaNote": "Dieses Backup verweist auf eine externe Medienbibliothek. Stellen Sie sicher, dass das externe Medien-Routing auf dieser Instanz konfiguriert ist.",
|
||||
"externalMediaLink": "Einrichtungsanleitung",
|
||||
"reload": "App neu laden",
|
||||
"confirmTitle": "Die Wiederherstellung löscht alle aktuellen Daten",
|
||||
"confirmBody": "Dies ersetzt ALLE Daten auf dieser Instanz dauerhaft durch das hochgeladene Backup, mit Ausnahme Ihres aktuellen Kontos. Dies kann nicht rückgängig gemacht werden.",
|
||||
"confirmRestore": "Löschen & wiederherstellen"
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
"title": "Branding"
|
||||
@@ -3467,6 +3496,45 @@
|
||||
"subtitle": "Erstellen Sie Ihr Administrator-Konto, um loszulegen",
|
||||
"tokenStepSubtitle": "Geben Sie zunächst Ihren einmaligen Setup-Token ein",
|
||||
"accountStepSubtitle": "Erstellen Sie nun Ihr Administrator-Konto",
|
||||
"usageSubtitle": "Wie möchten Sie PicPeak nutzen?",
|
||||
"usageAlwaysOn": "Galerien, Analysen und Benutzerverwaltung sind immer enthalten. Wählen Sie unten optionale Funktionen — Sie können dies jederzeit in den Einstellungen ändern.",
|
||||
"usageGroupCrm": "Kundenverwaltung",
|
||||
"usageGroupAccounting": "Buchhaltung",
|
||||
"usageGroupAutomation": "Automatisierung & Versand",
|
||||
"usageDepsNote": "Rechnungen aktivieren automatisch den Buchhaltungsbereich.",
|
||||
"usageSkip": "Nur mit Galerien fortfahren",
|
||||
"finish": "Einrichtung abschließen",
|
||||
"featuresSaveFailed": "Ihre Funktionsauswahl konnte nicht gespeichert werden — Sie können sie später unter Einstellungen → Funktionen festlegen.",
|
||||
"restoreStepSubtitle": "Aus einem Backup wiederherstellen",
|
||||
"restoreEntry": "Wechsel von einer anderen PicPeak-Instanz?",
|
||||
"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.",
|
||||
"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}}",
|
||||
"continue": "Weiter",
|
||||
"back": "Zurück",
|
||||
|
||||
@@ -1335,7 +1335,27 @@
|
||||
"title": "Email Settings"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup"
|
||||
"title": "Backup",
|
||||
"picpeak": {
|
||||
"title": "Portable backup (.picpeak)",
|
||||
"intro": "Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.",
|
||||
"includePhotos": "Include original gallery photos (larger file)",
|
||||
"secretsWarning": "This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.",
|
||||
"download": "Download .picpeak",
|
||||
"downloadFailed": "Could not create the backup file.",
|
||||
"restoreTitle": "Restore from a .picpeak",
|
||||
"restoreIntro": "Upload a .picpeak taken from this or another instance. Same database engine only.",
|
||||
"chooseFile": "Choose .picpeak file…",
|
||||
"restoreDone": "Backup restored.",
|
||||
"restoreFailed": "Restore failed.",
|
||||
"restoreSummary": "{{tables}} tables and {{files}} files restored.",
|
||||
"externalMediaNote": "This backup references an external-media library. Make sure external-media routing is configured on this instance.",
|
||||
"externalMediaLink": "Setup guide",
|
||||
"reload": "Reload app",
|
||||
"confirmTitle": "Restore will delete all current data",
|
||||
"confirmBody": "This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.",
|
||||
"confirmRestore": "Delete & restore"
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
"title": "Branding"
|
||||
@@ -3002,7 +3022,16 @@
|
||||
"upload": {
|
||||
"name": "Upload Backup",
|
||||
"description": "Upload a backup file",
|
||||
"comingSoon": "Upload functionality coming soon"
|
||||
"comingSoon": "Upload functionality coming soon",
|
||||
"manifestComingSoon": "Manifest Upload functionality coming soon",
|
||||
"picpeak": {
|
||||
"name": ".picpeak backup",
|
||||
"description": "Portable full backup — restores everything (full override, keeps your current account)."
|
||||
},
|
||||
"manifest": {
|
||||
"name": "Manifest + files",
|
||||
"description": "Upload a manifest and its backup files (legacy format)."
|
||||
}
|
||||
},
|
||||
"configuration": {
|
||||
"s3": "S3 Configuration",
|
||||
@@ -3363,6 +3392,45 @@
|
||||
"subtitle": "Create your administrator account to get started",
|
||||
"tokenStepSubtitle": "First, enter your one-time setup token",
|
||||
"accountStepSubtitle": "Now create your administrator account",
|
||||
"usageSubtitle": "How will you use PicPeak?",
|
||||
"usageAlwaysOn": "Galleries, analytics and user management are always included. Pick any extras below — you can change these anytime in Settings.",
|
||||
"usageGroupCrm": "Client management",
|
||||
"usageGroupAccounting": "Accounting",
|
||||
"usageGroupAutomation": "Automation & delivery",
|
||||
"usageDepsNote": "Invoices automatically enable the Accounting area.",
|
||||
"usageSkip": "Continue with galleries only",
|
||||
"finish": "Finish setup",
|
||||
"featuresSaveFailed": "Could not save your feature choices — you can set them later in Settings → Features.",
|
||||
"restoreStepSubtitle": "Restore from a backup",
|
||||
"restoreEntry": "Migrating from another PicPeak?",
|
||||
"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.",
|
||||
"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}}",
|
||||
"continue": "Continue",
|
||||
"back": "Back",
|
||||
|
||||
@@ -8,6 +8,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input, Card, Loading } from '../components/common';
|
||||
import { useAdminAuth } from '../contexts';
|
||||
import { setupService } from '../services/setup.service';
|
||||
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
|
||||
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
|
||||
import { SetupConfigStep } from '../components/admin/SetupConfigStep';
|
||||
import { resolveLoginLogoClasses } from '../utils/loginLogoSize';
|
||||
import type { AdminUser } from '../types';
|
||||
|
||||
@@ -16,6 +19,19 @@ import type { AdminUser } from '../types';
|
||||
const SETUP_DOCS_URL =
|
||||
'https://github.com/PicPeak/picpeak/blob/main/README.md#first-run--create-your-admin-account';
|
||||
|
||||
// "How will you use PicPeak?" — the opt-in feature groups shown after the admin
|
||||
// account is created. galleries/analytics/userManagement are always on and not
|
||||
// listed. Labels/descriptions reuse the existing Settings→Features i18n keys
|
||||
// (`settings.features.<key>.title/description`) so translations stay in sync.
|
||||
// Server-side applyDependencyRules resolves dependencies (e.g. Invoices pulls in
|
||||
// Accounting) when we PUT the selection, so we only send the raw ticks.
|
||||
const USAGE_GROUPS: { id: string; titleKey: string; features: FeatureKey[] }[] = [
|
||||
{ id: 'crm', titleKey: 'setup.usageGroupCrm', features: ['quotes', 'contracts', 'bills', 'hoursLogging', 'customerPortal', 'calendar'] },
|
||||
{ id: 'accounting', titleKey: 'setup.usageGroupAccounting', features: ['taxReport', 'incomingInvoices', 'expenses'] },
|
||||
{ id: 'automation', titleKey: 'setup.usageGroupAutomation', features: ['reminderEmails', 'slideshow', 'workflows', 'whatsapp', 'incomingMail'] },
|
||||
];
|
||||
const ALL_USAGE_FEATURES: FeatureKey[] = USAGE_GROUPS.flatMap((g) => g.features);
|
||||
|
||||
// First-run screen. Reached on a fresh instance where no admin account exists
|
||||
// yet — creates the first (super_admin) account from the browser using the
|
||||
// one-time setup token printed to the server logs. Once an admin exists the
|
||||
@@ -37,13 +53,15 @@ export const SetupPage: React.FC = () => {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const [step, setStep] = useState<'token' | 'account'>('token');
|
||||
const [step, setStep] = useState<'token' | 'account' | 'usage' | 'restore' | 'config'>('token');
|
||||
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isVerifyingToken, setIsVerifyingToken] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [selectedFeatures, setSelectedFeatures] = useState<Set<FeatureKey>>(new Set());
|
||||
const [isSavingFeatures, setIsSavingFeatures] = useState(false);
|
||||
|
||||
if (statusLoading) {
|
||||
return <Loading fullScreen />;
|
||||
@@ -144,7 +162,10 @@ export const SetupPage: React.FC = () => {
|
||||
};
|
||||
login('', adminUser);
|
||||
toast.success(t('setup.success'));
|
||||
navigate('/admin/dashboard', { replace: true });
|
||||
// Admin now exists and we're logged in (cookie set) — advance to the
|
||||
// opt-in "How will you use PicPeak?" step rather than jumping straight to
|
||||
// the dashboard. Authenticated calls (feature flags) work from here.
|
||||
setStep('usage');
|
||||
} catch (error: any) {
|
||||
const httpStatus = error.response?.status;
|
||||
const data = error.response?.data;
|
||||
@@ -184,10 +205,44 @@ export const SetupPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const stepNumber = step === 'token' ? 1 : 2;
|
||||
const toggleFeature = (key: FeatureKey) => {
|
||||
setSelectedFeatures((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Persist the feature selection, then enter the app. Saving is best-effort —
|
||||
// if it fails the admin can still flip features later in Settings, so we don't
|
||||
// trap them on the setup screen.
|
||||
const finishSetup = async () => {
|
||||
setIsSavingFeatures(true);
|
||||
try {
|
||||
const flags: Partial<FeatureFlags> = {};
|
||||
for (const key of ALL_USAGE_FEATURES) flags[key] = selectedFeatures.has(key);
|
||||
await featureFlagsService.update(flags);
|
||||
} catch (_) {
|
||||
toast.warn(t('setup.featuresSaveFailed'));
|
||||
} finally {
|
||||
setIsSavingFeatures(false);
|
||||
// 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 });
|
||||
}
|
||||
};
|
||||
|
||||
const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: '#fafafa' }}>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
{/* On a fresh instance there are no branding settings yet, so use the
|
||||
@@ -202,13 +257,23 @@ export const SetupPage: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<h1 className="text-3xl font-bold" style={{ color: 'var(--color-text, #171717)' }}>{t('setup.title')}</h1>
|
||||
<p className="mt-2" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
|
||||
{step === 'token' ? t('setup.tokenStepSubtitle') : t('setup.accountStepSubtitle')}
|
||||
</p>
|
||||
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: 'var(--color-text, #171717)', opacity: 0.5 }}>
|
||||
{t('setup.stepOf', { current: stepNumber, total: 2 })}
|
||||
<h1 className="text-3xl font-bold" style={{ color: '#171717' }}>{t('setup.title')}</h1>
|
||||
<p className="mt-2" style={{ color: '#171717', opacity: 0.7 }}>
|
||||
{step === 'token'
|
||||
? t('setup.tokenStepSubtitle')
|
||||
: step === 'account'
|
||||
? t('setup.accountStepSubtitle')
|
||||
: step === 'restore'
|
||||
? t('setup.restoreStepSubtitle')
|
||||
: step === 'config'
|
||||
? t('setup.config.subtitle')
|
||||
: t('setup.usageSubtitle')}
|
||||
</p>
|
||||
{(step === 'token' || step === 'account' || step === 'usage') && (
|
||||
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: '#171717', opacity: 0.5 }}>
|
||||
{t('setup.stepOf', { current: stepNumber, total: 3 })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card padding="lg">
|
||||
@@ -271,7 +336,7 @@ export const SetupPage: React.FC = () => {
|
||||
{t('setup.continue')}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
) : step === 'account' ? (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="setup-email" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
@@ -348,6 +413,84 @@ export const SetupPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : step === 'usage' ? (
|
||||
<div className="space-y-6">
|
||||
<p className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2 text-xs text-neutral-600">
|
||||
{t('setup.usageAlwaysOn')}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep('restore')}
|
||||
className="w-full rounded-lg border border-dashed border-neutral-300 p-3 text-left hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
<span className="block text-sm font-medium text-neutral-800">{t('setup.restoreEntry')}</span>
|
||||
<span className="block text-xs text-neutral-500">{t('setup.restoreEntryHint')}</span>
|
||||
</button>
|
||||
|
||||
{USAGE_GROUPS.map((group) => (
|
||||
<div key={group.id}>
|
||||
<h3 className="text-sm font-semibold text-neutral-800 mb-2">{t(group.titleKey)}</h3>
|
||||
<div className="space-y-2">
|
||||
{group.features.map((key) => (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-start gap-3 rounded-lg border border-neutral-200 p-3 cursor-pointer hover:bg-neutral-50 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 rounded border-neutral-300"
|
||||
checked={selectedFeatures.has(key)}
|
||||
onChange={() => toggleFeature(key)}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-neutral-800">
|
||||
{t(`settings.features.${key}.title`)}
|
||||
</span>
|
||||
<span className="block text-xs text-neutral-500">
|
||||
{t(`settings.features.${key}.description`)}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selectedFeatures.has('bills') && !selectedFeatures.has('taxReport') && (
|
||||
<p className="text-xs text-neutral-500">{t('setup.usageDepsNote')}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={isSavingFeatures}
|
||||
className="w-full"
|
||||
onClick={finishSetup}
|
||||
>
|
||||
{selectedFeatures.size > 0 ? t('setup.finish') : t('setup.usageSkip')}
|
||||
</Button>
|
||||
</div>
|
||||
) : step === 'restore' ? (
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-neutral-600">{t('setup.restoreIntro')}</p>
|
||||
<PicpeakRestoreCard />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => setStep('usage')}
|
||||
leftIcon={<ArrowLeft className="w-4 h-4" />}
|
||||
>
|
||||
{t('setup.back')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<SetupConfigStep
|
||||
selectedFeatures={selectedFeatures}
|
||||
onDone={() => navigate('/admin/dashboard', { replace: true })}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { BackupDashboard } from '../../components/admin/BackupDashboard';
|
||||
import { BackupConfiguration } from '../../components/admin/BackupConfiguration';
|
||||
import { BackupHistory } from '../../components/admin/BackupHistory';
|
||||
import { RestoreWizard } from '../../components/admin/RestoreWizard';
|
||||
import { PicpeakExportCard } from '../../components/admin/PicpeakBackupCard';
|
||||
import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard';
|
||||
import { BackupCoverageCard } from '../../components/admin/BackupCoverageCard';
|
||||
import { api } from '../../config/api';
|
||||
@@ -204,12 +205,15 @@ export const BackupManagement: React.FC = () => {
|
||||
{/* Tab Content */}
|
||||
<div className="mt-6">
|
||||
{activeTab === 'dashboard' && (
|
||||
<BackupDashboard
|
||||
status={backupStatus}
|
||||
config={backupConfig}
|
||||
onRunBackup={() => manualBackupMutation.mutate()}
|
||||
isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
<BackupDashboard
|
||||
status={backupStatus}
|
||||
config={backupConfig}
|
||||
onRunBackup={() => manualBackupMutation.mutate()}
|
||||
isBackupRunning={backupStatus?.isRunning || manualBackupMutation.isPending}
|
||||
/>
|
||||
<PicpeakExportCard />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'configuration' && (
|
||||
|
||||
Reference in New Issue
Block a user