Aktueller Stand

This commit is contained in:
2026-01-23 01:33:35 +01:00
parent 082dc5e110
commit 2766dd12c5
10109 changed files with 1578841 additions and 77685 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,7 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
const [users, setUsers] = useState<User[]>([]);
const [accounts, setAccounts] = useState<Account[]>([]);
const [jobs, setJobs] = useState<Job[]>([]);
const [selectedJobIds, setSelectedJobIds] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState<"tenants" | "users" | "accounts" | "jobs" | "settings">(
(localStorage.getItem("ui.adminTab") as "tenants" | "users" | "accounts" | "jobs" | "settings") ?? "tenants"
);
@@ -66,6 +67,12 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
const [exportStatus, setExportStatus] = useState<"idle" | "loading" | "done" | "failed">("idle");
const [exportJobId, setExportJobId] = useState<string | null>(null);
const [exportHistory, setExportHistory] = useState<{ id: string; status: string; expiresAt?: string | null; createdAt?: string; progress?: number }[]>([]);
const [confirmDialog, setConfirmDialog] = useState<{
open: boolean;
message: string;
confirmLabel?: string;
onConfirm?: () => Promise<void> | void;
}>({ open: false, message: "" });
const [exportFilter, setExportFilter] = useState<"all" | "active" | "done" | "failed" | "expired">("all");
const [exportScope, setExportScope] = useState<"all" | "users" | "accounts" | "jobs" | "rules">("all");
const [exportFormat, setExportFormat] = useState<"json" | "csv" | "zip">("json");
@@ -77,7 +84,8 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
const [settingsDraft, setSettingsDraft] = useState({
googleClientId: "",
googleClientSecret: "",
googleRedirectUri: ""
googleRedirectUri: "",
cleanupScanLimit: ""
});
const [settingsStatus, setSettingsStatus] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [showGoogleSecret, setShowGoogleSecret] = useState(false);
@@ -118,10 +126,30 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
return t("toastGenericError");
};
const openConfirmDialog = (message: string, onConfirm: () => Promise<void> | void, confirmLabel?: string) => {
setConfirmDialog({ open: true, message, onConfirm, confirmLabel });
};
const closeConfirmDialog = () => {
setConfirmDialog({ open: false, message: "" });
};
const handleConfirmAction = async () => {
const action = confirmDialog.onConfirm;
closeConfirmDialog();
if (action) {
await action();
}
};
useEffect(() => {
loadAll().catch(() => undefined);
}, []);
useEffect(() => {
setSelectedJobIds((prev) => prev.filter((id) => jobs.some((job) => job.id === id)));
}, [jobs]);
useEffect(() => {
localStorage.setItem("ui.adminTab", activeTab);
}, [activeTab]);
@@ -133,7 +161,8 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
setSettingsDraft({
googleClientId: next["google.client_id"]?.value ?? "",
googleClientSecret: next["google.client_secret"]?.value ?? "",
googleRedirectUri: next["google.redirect_uri"]?.value ?? ""
googleRedirectUri: next["google.redirect_uri"]?.value ?? "",
cleanupScanLimit: next["cleanup.scan_limit"]?.value ?? ""
});
};
@@ -217,14 +246,19 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
};
const deleteTenant = async (tenant: Tenant) => {
if (!confirm(t("adminDeleteConfirm", { name: tenant.name }))) return;
try {
await apiFetch(`/admin/tenants/${tenant.id}`, { method: "DELETE" }, token);
setTenants((prev) => prev.filter((item) => item.id !== tenant.id));
pushToast(t("toastTenantDeleted"), "info");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
openConfirmDialog(
t("adminDeleteConfirm", { name: tenant.name }),
async () => {
try {
await apiFetch(`/admin/tenants/${tenant.id}`, { method: "DELETE" }, token);
setTenants((prev) => prev.filter((item) => item.id !== tenant.id));
pushToast(t("toastTenantDeleted"), "info");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
},
t("adminDelete")
);
};
const toggleUser = async (user: User) => {
@@ -314,6 +348,59 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
}
};
const deleteJob = async (job: Job) => {
openConfirmDialog(
t("adminDeleteJobConfirm"),
async () => {
try {
await apiFetch(`/admin/jobs/${job.id}`, { method: "DELETE" }, token);
setJobs((prev) => prev.filter((item) => item.id !== job.id));
pushToast(t("toastJobDeleted"), "info");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
},
t("adminDelete")
);
};
const toggleJobSelection = (jobId: string) => {
setSelectedJobIds((prev) => (prev.includes(jobId) ? prev.filter((id) => id !== jobId) : [...prev, jobId]));
};
const toggleAllJobs = (jobIds: string[]) => {
setSelectedJobIds((prev) => (prev.length === jobIds.length ? [] : [...jobIds]));
};
const cancelSelectedJobs = async () => {
if (selectedJobIds.length === 0) return;
try {
await Promise.all(selectedJobIds.map((id) => apiFetch(`/admin/jobs/${id}/cancel`, { method: "POST" }, token)));
setJobs((prev) => prev.map((item) => (selectedJobIds.includes(item.id) ? { ...item, status: "CANCELED" } : item)));
pushToast(t("toastJobCanceled"), "info");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
};
const deleteSelectedJobs = async () => {
if (selectedJobIds.length === 0) return;
openConfirmDialog(
t("adminDeleteJobsConfirm", { count: selectedJobIds.length }),
async () => {
try {
await Promise.all(selectedJobIds.map((id) => apiFetch(`/admin/jobs/${id}`, { method: "DELETE" }, token)));
setJobs((prev) => prev.filter((item) => !selectedJobIds.includes(item.id)));
setSelectedJobIds([]);
pushToast(t("toastJobDeleted"), "info");
} catch (err) {
pushToast(getErrorMessage(err), "error");
}
},
t("adminDelete")
);
};
const sortBy = <T,>(items: T[], mode: string, getKey: (item: T) => string) => {
const sorted = [...items];
if (mode === "name" || mode === "email" || mode === "status") {
@@ -364,7 +451,8 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
settings: {
"google.client_id": settingsDraft.googleClientId,
"google.client_secret": settingsDraft.googleClientSecret,
"google.redirect_uri": settingsDraft.googleRedirectUri
"google.redirect_uri": settingsDraft.googleRedirectUri,
"cleanup.scan_limit": settingsDraft.cleanupScanLimit
}
})
},
@@ -673,6 +761,24 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
</select>
</label>
</div>
<div className="list-toolbar">
<label className="checkbox">
<input
type="checkbox"
checked={jobsSorted.length > 0 && selectedJobIds.length === jobsSorted.length}
onChange={() => toggleAllJobs(jobsSorted.map((job) => job.id))}
/>
{t("selectAll")}
</label>
<div className="inline-actions">
<button className="ghost" type="button" onClick={cancelSelectedJobs} disabled={selectedJobIds.length === 0}>
{t("adminCancelSelected")}
</button>
<button className="ghost" type="button" onClick={deleteSelectedJobs} disabled={selectedJobIds.length === 0}>
{t("adminDeleteSelected")}
</button>
</div>
</div>
{jobsSorted.map((job) => (
<div key={job.id} className="list-item">
<div>
@@ -680,8 +786,15 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
<p>{job.tenant?.name ?? "-"} · {job.mailboxAccount?.email ?? "-"}</p>
</div>
<div className="inline-actions">
<input
className="checkbox-input"
type="checkbox"
checked={selectedJobIds.includes(job.id)}
onChange={() => toggleJobSelection(job.id)}
/>
<button className="ghost" onClick={() => retryJob(job)}>{t("adminRetry")}</button>
<button className="ghost" onClick={() => cancelJob(job)}>{t("adminCancelJob")}</button>
<button className="ghost" onClick={() => deleteJob(job)}>{t("adminDelete")}</button>
<span>{new Date(job.createdAt).toLocaleString()}</span>
</div>
</div>
@@ -718,6 +831,17 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, googleRedirectUri: event.target.value }))}
/>
</label>
<label className="field-row">
<span>{t("adminCleanupScanLimit")}</span>
<input
type="number"
min="0"
step="1"
value={settingsDraft.cleanupScanLimit}
onChange={(event) => setSettingsDraft((prev) => ({ ...prev, cleanupScanLimit: event.target.value }))}
/>
<small className="hint-text">{t("adminCleanupScanLimitHint")}</small>
</label>
<div className="inline-actions">
<button className="ghost" onClick={() => setShowGoogleSecret((prev) => !prev)}>
{showGoogleSecret ? t("adminHideSecret") : t("adminShowSecret")}
@@ -738,6 +862,29 @@ export default function AdminPanel({ token, onImpersonate }: Props) {
</div>
</div>
)}
{confirmDialog.open && (
<div className="modal-backdrop" onClick={closeConfirmDialog}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<div className="modal-header">
<h3>{t("confirmTitle")}</h3>
<button className="ghost" type="button" onClick={closeConfirmDialog}>
{t("close")}
</button>
</div>
<div className="modal-body">
<p>{confirmDialog.message}</p>
</div>
<div className="modal-actions">
<button className="ghost" type="button" onClick={closeConfirmDialog}>
{t("cancel")}
</button>
<button className="primary" type="button" onClick={handleConfirmAction}>
{confirmDialog.confirmLabel ?? t("confirm")}
</button>
</div>
</div>
</div>
)}
</section>
);
}

View File

@@ -22,7 +22,14 @@ export const apiFetch = async (path: string, options: RequestInit = {}, token?:
throw error;
}
return response.json();
if (response.status === 204) {
return null;
}
const text = await response.text();
if (!text) {
return null;
}
return JSON.parse(text);
};
export const createEventSource = async (jobId: string, token: string) => {

View File

@@ -27,6 +27,14 @@
"register": "Registrieren",
"createAccount": "Account erstellen",
"noAccount": "Noch keinen Account?",
"confirm": "Bestätigen",
"confirmTitle": "Bitte bestätigen",
"cancel": "Abbrechen",
"yes": "Ja",
"no": "Nein",
"provider": "Anbieter",
"authMode": "Login-Methode",
"oauth": "OAuth",
"passwordResetTitle": "Passwortänderung erforderlich",
"passwordResetHint": "Dein Admin-Passwort wurde zurückgesetzt. Bitte setze ein neues Passwort, um fortzufahren.",
"newPassword": "Neues Passwort",
@@ -57,6 +65,8 @@
"adminGoogleClientId": "Client-ID",
"adminGoogleClientSecret": "Client-Secret",
"adminGoogleRedirectUri": "Redirect-URL",
"adminCleanupScanLimit": "Max. Mails pro Bereinigung",
"adminCleanupScanLimitHint": "0 = unbegrenzt. Praktisch für Tests.",
"adminSaveSettings": "Einstellungen speichern",
"adminSaving": "Speichert...",
"adminSettingsSaved": "Gespeichert",
@@ -64,6 +74,10 @@
"adminShowSecret": "Secret anzeigen",
"adminHideSecret": "Secret verbergen",
"adminSettingsSource": "Quellen - Client-ID: {{id}}, Secret: {{secret}}, Redirect: {{redirect}}",
"selectAll": "Alle auswählen",
"adminCancelSelected": "Auswahl abbrechen",
"adminDeleteSelected": "Auswahl löschen",
"adminDeleteJobsConfirm": "{{count}} ausgewählte Jobs samt Events löschen?",
"adminRetry": "Retry",
"adminCancelJob": "Cancel",
"adminMailboxStatus": "Mailbox Status",
@@ -72,7 +86,13 @@
"oauthConnect": "OAuth prüfen/verbinden",
"gmailConnect": "Gmail OAuth verbinden",
"mailboxAdd": "Mailbox hinzufügen",
"mailboxEditTitle": "Mailbox bearbeiten",
"mailboxSave": "Speichern",
"mailboxUpdate": "Aktualisieren",
"mailboxEdit": "Bearbeiten",
"mailboxDelete": "Löschen",
"mailboxCancelEdit": "Abbrechen",
"mailboxEmpty": "Noch keine Mailbox. Füge eine hinzu, um zu starten.",
"cleanupStart": "Bereinigung starten",
"cleanupDryRun": "Dry run (keine Änderungen)",
"cleanupUnsubscribe": "Unsubscribe aktiv",
@@ -82,6 +102,9 @@
"cleanupOauthRequired": "Bitte Gmail OAuth verbinden, bevor die Bereinigung startet.",
"cleanupDryRunHint": "Dry run simuliert Routing und Unsubscribe. Es werden keine Änderungen durchgeführt und keine E-Mails gesendet.",
"rulesTitle": "Regeln",
"rulesAdd": "Regel hinzufügen",
"rulesAddTitle": "Regel erstellen",
"rulesEditTitle": "Regel bearbeiten",
"rulesName": "Rule Name",
"rulesEnabled": "Rule aktiv",
"rulesConditions": "Bedingungen",
@@ -90,8 +113,25 @@
"rulesAddAction": "+ Aktion",
"rulesSave": "Regel speichern",
"rulesOverview": "Regeln Übersicht",
"rulesEmpty": "Noch keine Regeln.",
"jobsTitle": "Jobs",
"jobsEmpty": "Noch keine Jobs.",
"jobsProgress": "Fortschritt",
"jobsEta": "Restzeit",
"jobDetailsTitle": "Job-Details",
"jobNoEvents": "Noch keine Events.",
"jobEvents": "Job Events",
"phaseListing": "Listing",
"phaseProcessing": "Verarbeitung",
"phaseUnsubscribe": "Abmelden",
"phaseListingPending": "Liste wird vorbereitet.",
"phaseProcessingPending": "Warte auf Verarbeitung.",
"phaseUnsubscribePending": "Warte auf Abmeldung.",
"phaseUnsubscribeDisabled": "Abmeldung ist für diesen Job deaktiviert.",
"phaseUnsubscribeSummary": "Abgemeldet {{ok}} · Fehlgeschlagen {{failed}} · Dry-Run {{dryRun}} ({{total}} gesamt).",
"phaseStatusActive": "Aktiv",
"phaseStatusDone": "Fertig",
"phaseStatusPending": "Ausstehend",
"noJobSelected": "Kein Job ausgewählt.",
"tenantName": "Tenant Name",
"password": "Passwort",
@@ -109,13 +149,22 @@
"countJobs": "{{count}} Jobs",
"placeholderEmail": "email@example.com",
"providerHintGmail": "Gmail nutzt OAuth. Du kannst das Passwort leer lassen und per OAuth verbinden.",
"providerHintGmx": "GMX nutzt IMAP. Gib hier dein AppPasswort oder IMAPPasswort ein.",
"providerHintWebde": "web.de nutzt IMAP. Gib hier dein AppPasswort oder IMAPPasswort ein.",
"providerHelp": "Hilfe zur MailboxEinrichtung",
"providerHintGmx": "GMX nutzt IMAP. Gib hier dein App-Passwort oder IMAP-Passwort ein.",
"providerHintWebde": "web.de nutzt IMAP. Gib hier dein App-Passwort oder IMAP-Passwort ein.",
"mailboxPasswordHintEdit": "Leer lassen, um das aktuelle App-Passwort zu behalten.",
"providerHelp": "Hilfe zur Mailbox-Einrichtung",
"providerHelpTitle": "MailboxEinrichtung",
"providerHelpGmail": "Erstelle einen Google OAuthClient und verbinde per OAuthButton. Das Passwortfeld ist bei OAuth nicht nötig.",
"providerHelpGmx": "Aktiviere IMAP in den GMXEinstellungen und erstelle ein AppPasswort. Dieses Passwort hier verwenden.",
"providerHelpWebde": "Aktiviere IMAP in den web.deEinstellungen und erstelle ein AppPasswort. Dieses Passwort hier verwenden.",
"wizardStepProvider": "Anbieter",
"wizardStepDetails": "Details",
"wizardStepReview": "Übersicht",
"wizardChooseProvider": "Anbieter wählen",
"wizardNext": "Weiter",
"wizardBack": "Zurück",
"wizardCreate": "Mailbox erstellen",
"wizardConnectNow": "Gmail OAuth jetzt verbinden",
"close": "Schließen",
"providerGmail": "Gmail",
"providerGmx": "GMX",
@@ -197,6 +246,8 @@
"toastLoginSuccess": "Erfolgreich eingeloggt.",
"toastRegisterSuccess": "Account erfolgreich erstellt.",
"toastMailboxAdded": "Mailbox hinzugefügt.",
"toastMailboxUpdated": "Mailbox aktualisiert.",
"toastMailboxDeleted": "Mailbox gelöscht.",
"toastRuleSaved": "Regel gespeichert.",
"toastRuleDeleted": "Regel gelöscht.",
"toastCleanupStarted": "Bereinigung gestartet.",
@@ -215,6 +266,9 @@
"toastPasswordReset": "Passwort zurückgesetzt.",
"toastJobCanceled": "Job abgebrochen.",
"toastJobRetry": "Job neu gestartet.",
"toastJobDeleted": "Job gelöscht.",
"toastSettingsSaved": "Einstellungen gespeichert.",
"toastSettingsFailed": "Einstellungen konnten nicht gespeichert werden."
"toastSettingsFailed": "Einstellungen konnten nicht gespeichert werden.",
"confirmMailboxDelete": "Mailbox {{email}} löschen? Dabei werden alle zugehörigen Daten entfernt.",
"adminDeleteJobConfirm": "Diesen Job samt Events löschen?"
}

View File

@@ -27,6 +27,14 @@
"register": "Register",
"createAccount": "Create account",
"noAccount": "No account yet?",
"confirm": "Confirm",
"confirmTitle": "Please confirm",
"cancel": "Cancel",
"yes": "Yes",
"no": "No",
"provider": "Provider",
"authMode": "Auth mode",
"oauth": "OAuth",
"passwordResetTitle": "Password change required",
"passwordResetHint": "Your admin password was reset. Please set a new password to continue.",
"newPassword": "New password",
@@ -57,6 +65,8 @@
"adminGoogleClientId": "Client ID",
"adminGoogleClientSecret": "Client secret",
"adminGoogleRedirectUri": "Redirect URL",
"adminCleanupScanLimit": "Max emails per cleanup",
"adminCleanupScanLimitHint": "0 = unlimited. Useful for testing.",
"adminSaveSettings": "Save settings",
"adminSaving": "Saving...",
"adminSettingsSaved": "Saved",
@@ -64,6 +74,10 @@
"adminShowSecret": "Show secret",
"adminHideSecret": "Hide secret",
"adminSettingsSource": "Sources - Client ID: {{id}}, Secret: {{secret}}, Redirect: {{redirect}}",
"selectAll": "Select all",
"adminCancelSelected": "Cancel selected",
"adminDeleteSelected": "Delete selected",
"adminDeleteJobsConfirm": "Delete {{count}} selected jobs and all events?",
"adminRetry": "Retry",
"adminCancelJob": "Cancel",
"adminMailboxStatus": "Mailbox status",
@@ -72,7 +86,13 @@
"oauthConnect": "Check/connect OAuth",
"gmailConnect": "Connect Gmail OAuth",
"mailboxAdd": "Add mailbox",
"mailboxEditTitle": "Edit mailbox",
"mailboxSave": "Save",
"mailboxUpdate": "Update",
"mailboxEdit": "Edit",
"mailboxDelete": "Delete",
"mailboxCancelEdit": "Cancel",
"mailboxEmpty": "No mailboxes yet. Add one to start cleaning.",
"cleanupStart": "Start cleanup",
"cleanupDryRun": "Dry run (no changes)",
"cleanupUnsubscribe": "Unsubscribe enabled",
@@ -82,6 +102,9 @@
"cleanupOauthRequired": "Connect Gmail OAuth before starting cleanup.",
"cleanupDryRunHint": "Dry run simulates routing and unsubscribe actions. No changes or emails are sent.",
"rulesTitle": "Rules",
"rulesAdd": "Add rule",
"rulesAddTitle": "Create rule",
"rulesEditTitle": "Edit rule",
"rulesName": "Rule name",
"rulesEnabled": "Rule enabled",
"rulesConditions": "Conditions",
@@ -90,8 +113,25 @@
"rulesAddAction": "+ Add action",
"rulesSave": "Save rule",
"rulesOverview": "Rules overview",
"rulesEmpty": "No rules yet.",
"jobsTitle": "Jobs",
"jobsEmpty": "No jobs yet.",
"jobsProgress": "Progress",
"jobsEta": "ETA",
"jobDetailsTitle": "Job details",
"jobNoEvents": "No events yet.",
"jobEvents": "Job events",
"phaseListing": "Listing",
"phaseProcessing": "Processing",
"phaseUnsubscribe": "Unsubscribe",
"phaseListingPending": "Preparing message list.",
"phaseProcessingPending": "Waiting for processing.",
"phaseUnsubscribePending": "Waiting for unsubscribe.",
"phaseUnsubscribeDisabled": "Unsubscribe disabled for this job.",
"phaseUnsubscribeSummary": "Unsubscribed {{ok}} · Failed {{failed}} · Dry run {{dryRun}} ({{total}} total).",
"phaseStatusActive": "Active",
"phaseStatusDone": "Done",
"phaseStatusPending": "Pending",
"noJobSelected": "No job selected.",
"tenantName": "Tenant name",
"password": "Password",
@@ -111,11 +151,20 @@
"providerHintGmail": "Gmail uses OAuth. You can leave the password empty and connect via the OAuth button.",
"providerHintGmx": "GMX uses IMAP. Enter your app password or IMAP password for this mailbox.",
"providerHintWebde": "web.de uses IMAP. Enter your app password or IMAP password for this mailbox.",
"mailboxPasswordHintEdit": "Leave empty to keep the current app password.",
"providerHelp": "Help for mailbox setup",
"providerHelpTitle": "Mailbox setup help",
"providerHelpGmail": "Create a Google OAuth Client and connect via the OAuth button. The password field is not required when OAuth is used.",
"providerHelpGmx": "Enable IMAP in your GMX settings and create an app password. Use that password here.",
"providerHelpWebde": "Enable IMAP in your web.de account settings and create an app password. Use that password here.",
"wizardStepProvider": "Provider",
"wizardStepDetails": "Details",
"wizardStepReview": "Review",
"wizardChooseProvider": "Choose a provider",
"wizardNext": "Next",
"wizardBack": "Back",
"wizardCreate": "Create mailbox",
"wizardConnectNow": "Connect Gmail OAuth now",
"close": "Close",
"providerGmail": "Gmail",
"providerGmx": "GMX",
@@ -197,6 +246,8 @@
"toastLoginSuccess": "Logged in successfully.",
"toastRegisterSuccess": "Account created successfully.",
"toastMailboxAdded": "Mailbox added.",
"toastMailboxUpdated": "Mailbox updated.",
"toastMailboxDeleted": "Mailbox deleted.",
"toastRuleSaved": "Rule saved.",
"toastRuleDeleted": "Rule deleted.",
"toastCleanupStarted": "Cleanup job started.",
@@ -215,6 +266,9 @@
"toastPasswordReset": "Password reset.",
"toastJobCanceled": "Job canceled.",
"toastJobRetry": "Job retried.",
"toastJobDeleted": "Job deleted.",
"toastSettingsSaved": "Settings saved.",
"toastSettingsFailed": "Settings save failed."
"toastSettingsFailed": "Settings save failed.",
"confirmMailboxDelete": "Delete mailbox {{email}}? This will remove all related data.",
"adminDeleteJobConfirm": "Delete this job and all its events?"
}

View File

@@ -303,6 +303,14 @@ button.ghost {
align-items: stretch;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
margin-bottom: 28px;
align-items: stretch;
}
.card {
background: var(--card);
border: 1px solid var(--border);
@@ -314,6 +322,25 @@ button.ghost {
gap: 10px;
}
.card-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.count-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
background: rgba(37, 99, 235, 0.12);
color: var(--primary-strong);
}
.card-actions {
margin-top: auto;
display: grid;
@@ -374,6 +401,16 @@ button.ghost {
gap: 12px;
}
.workspace-header {
align-items: center;
}
.workspace-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.section-header h3 {
font-size: 18px;
}
@@ -492,6 +529,13 @@ select {
border-bottom: 1px solid var(--border);
}
.list-actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.events {
display: grid;
gap: 8px;
@@ -553,6 +597,289 @@ select {
gap: 14px;
}
.modal-wide {
width: min(860px, 94vw);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.wizard-steps {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.wizard-step {
display: grid;
grid-template-columns: 22px 1fr;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(37, 99, 235, 0.05);
font-size: 12px;
color: var(--muted);
}
.wizard-step span {
display: grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(37, 99, 235, 0.12);
color: var(--primary-strong);
font-weight: 600;
font-size: 11px;
}
.wizard-step.active {
background: rgba(37, 99, 235, 0.16);
border-color: rgba(37, 99, 235, 0.35);
color: var(--primary-strong);
}
.wizard-panel {
display: grid;
gap: 12px;
margin-top: 6px;
}
.wizard-choices {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.choice {
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
background: #fff;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.choice:hover {
transform: translateY(-1px);
border-color: rgba(37, 99, 235, 0.4);
box-shadow: 0 10px 18px rgba(37, 99, 235, 0.12);
}
.choice.active {
border-color: rgba(37, 99, 235, 0.6);
background: rgba(37, 99, 235, 0.1);
color: var(--primary-strong);
}
.summary-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 14px;
}
.modal-fullscreen {
width: min(1200px, 96vw);
height: min(92vh, 1000px);
display: grid;
grid-template-rows: auto auto 1fr;
}
.job-hero {
display: grid;
gap: 16px;
background: rgba(37, 99, 235, 0.08);
border-radius: 16px;
padding: 16px;
}
.job-progress {
display: grid;
gap: 8px;
}
.progress-bar {
background: rgba(37, 99, 235, 0.15);
border-radius: 999px;
height: 10px;
overflow: hidden;
}
.progress-bar span {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--primary), var(--primary-strong));
border-radius: 999px;
}
.job-progress-meta {
display: flex;
justify-content: space-between;
font-size: 13px;
color: var(--muted);
}
.job-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
}
.job-summary p {
margin: 0;
font-size: 12px;
color: var(--muted);
}
.job-summary strong {
font-size: 14px;
}
.job-phase-bar {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
margin-bottom: 8px;
}
.phase-step {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.35);
color: var(--muted);
font-size: 12px;
background: rgba(148, 163, 184, 0.08);
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.phase-fill {
position: absolute;
inset: 0 auto 0 0;
background: rgba(37, 99, 235, 0.16);
width: 0%;
transition: width 0.35s ease;
}
.phase-step .phase-dot {
width: 9px;
height: 9px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.6);
box-shadow: 0 0 0 4px rgba(148, 163, 184, 0.1);
position: relative;
z-index: 1;
}
.phase-step span:not(.phase-fill):not(.phase-dot) {
position: relative;
z-index: 1;
}
.phase-step.active {
border-color: rgba(37, 99, 235, 0.6);
color: var(--text);
background: rgba(37, 99, 235, 0.12);
}
.phase-step.active .phase-dot {
background: var(--primary);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.18);
animation: phasePulse 1.4s ease infinite;
}
.phase-step.complete {
border-color: rgba(37, 99, 235, 0.35);
color: var(--text);
background: rgba(37, 99, 235, 0.08);
}
.phase-step.complete .phase-dot {
background: var(--primary-strong);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1);
}
.phase-step.disabled {
opacity: 0.55;
}
@keyframes phasePulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.18);
}
70% {
transform: scale(1.06);
box-shadow: 0 0 0 10px rgba(37, 99, 235, 0.08);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.18);
}
}
/* Phase summary cards */
.phase-summary {
display: grid;
gap: 12px;
margin-top: 8px;
}
.phase-card {
padding: 14px 16px;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.25);
background: rgba(148, 163, 184, 0.08);
color: var(--text);
transition: all 0.3s ease;
}
.phase-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 13px;
color: var(--muted);
}
.phase-card p {
margin: 0 0 8px;
font-size: 14px;
}
.phase-time {
font-size: 12px;
color: var(--muted);
}
.phase-card.active {
border-color: rgba(37, 99, 235, 0.55);
background: rgba(37, 99, 235, 0.12);
box-shadow: 0 10px 24px rgba(37, 99, 235, 0.15);
}
.phase-card.complete {
border-color: rgba(37, 99, 235, 0.35);
background: rgba(37, 99, 235, 0.08);
}
.phase-card.disabled {
opacity: 0.6;
}
.modal-header {
display: flex;
align-items: center;
@@ -652,6 +979,27 @@ select {
color: var(--muted);
}
.list-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.checkbox {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
}
.checkbox-input {
width: 16px;
height: 16px;
}
.badge {
display: inline-flex;
align-items: center;