Files
data-entry-app/frontend/src/routes/settings/+page.svelte
T

952 lines
27 KiB
Svelte
Raw Normal View History

2026-04-27 21:53:36 +12:00
<script lang="ts">
2026-05-08 09:06:14 +12:00
import { api } from '$lib/api';
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
2026-06-17 21:55:04 +12:00
import RoleManagementPanel from '$lib/components/settings/RoleManagementPanel.svelte';
import UserManagementPanel from '$lib/components/settings/UserManagementPanel.svelte';
import { clientSession, hasPermission } from '$lib/session';
import { canEditThroughput } from '$lib/workspace-access';
2026-05-08 09:06:14 +12:00
import { toast } from '$lib/toast';
import type { ThroughputImportResult } from '$lib/types';
2026-06-17 21:55:04 +12:00
import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert, Trash2, Users } from 'lucide-svelte';
2026-04-27 21:53:36 +12:00
2026-06-17 21:55:04 +12:00
type Section = 'profile' | 'security' | 'import' | 'users' | 'roles';
2026-05-08 09:06:14 +12:00
let activeSection = $state<Section>('profile');
2026-04-27 21:53:36 +12:00
// Only operators who can edit throughput see (and can use) the import tool.
const canImportThroughput = $derived(canEditThroughput($clientSession));
2026-06-17 21:55:04 +12:00
// Lean owners and admins (manage_users permission) get the User management
// section. Visibility is a convenience — every endpoint enforces the
// permission itself.
const canManageUsers = $derived(hasPermission($clientSession, 'manage_users'));
const canManageRoles = $derived(
$clientSession?.role === 'internal' && ['lean', 'admin'].includes($clientSession?.role_name?.toLowerCase() ?? '')
);
2026-05-08 09:06:14 +12:00
let name = $state($clientSession?.name ?? '');
let email = $state($clientSession?.email ?? '');
2026-04-27 21:53:36 +12:00
2026-05-08 09:06:14 +12:00
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
2026-04-27 21:53:36 +12:00
2026-05-08 09:06:14 +12:00
let profileSaving = $state(false);
let passwordSaving = $state(false);
let passwordError = $state('');
async function saveProfile() {
profileSaving = true;
const tid = toast.loading('Saving profile…');
try {
const updated = await api.updateMe({ name: name.trim(), email: email.trim().toLowerCase() });
clientSession.set({
...$clientSession!,
name: updated.name,
email: updated.email,
});
toast.dismiss(tid);
toast.success('Profile updated');
} catch (err: unknown) {
toast.dismiss(tid);
toast.error(err instanceof Error ? err.message : 'An error occurred');
} finally {
profileSaving = false;
}
2026-04-27 21:53:36 +12:00
}
2026-05-08 09:06:14 +12:00
async function savePassword() {
passwordError = '';
if (newPassword !== confirmPassword) {
passwordError = 'New passwords do not match';
return;
}
if (newPassword.length < 8) {
passwordError = 'Password must be at least 8 characters';
return;
}
passwordSaving = true;
const tid = toast.loading('Updating password…');
try {
await api.updateMe({ current_password: currentPassword, new_password: newPassword });
currentPassword = '';
newPassword = '';
confirmPassword = '';
toast.dismiss(tid);
toast.success('Password updated');
} catch (err: unknown) {
toast.dismiss(tid);
const msg = err instanceof Error ? err.message : 'An error occurred';
passwordError = msg;
toast.error(msg);
} finally {
passwordSaving = false;
}
}
// ── Throughput import ─────────────────────────────────────────
let importFile = $state<File | null>(null);
let importing = $state(false);
let importResult = $state<ThroughputImportResult | null>(null);
let importError = $state('');
function onImportFileChange(event: Event) {
const input = event.currentTarget as HTMLInputElement;
importFile = input.files?.[0] ?? null;
importResult = null;
importError = '';
}
async function runImport() {
if (!importFile) {
importError = 'Choose a CSV or spreadsheet file first.';
return;
}
importing = true;
importError = '';
importResult = null;
const tid = toast.loading('Importing entries…');
try {
const result = await api.importThroughputEntries(importFile);
importResult = result;
toast.dismiss(tid);
if (result.entries_imported > 0) {
toast.success(
`Imported ${result.entries_imported} ${result.entries_imported === 1 ? 'entry' : 'entries'}`
);
} else {
toast.error('No entries were imported. Check the file and try again.');
}
} catch (err: unknown) {
toast.dismiss(tid);
const msg = err instanceof Error ? err.message : 'Import failed';
importError = msg;
toast.error(msg);
} finally {
importing = false;
}
}
2026-06-17 21:55:04 +12:00
// ── Delete all throughput entries ─────────────────────────────
// A destructive maintenance action — clearing a bad import in one go rather
// than deleting runs one by one. Guarded behind a typed confirmation.
let showDeleteAll = $state(false);
let deleteAllConfirmText = $state('');
let deletingAll = $state(false);
let deleteAllResult = $state<number | null>(null);
function openDeleteAll() {
deleteAllConfirmText = '';
deleteAllResult = null;
showDeleteAll = true;
}
function cancelDeleteAll() {
if (deletingAll) return;
showDeleteAll = false;
deleteAllConfirmText = '';
}
async function confirmDeleteAll() {
if (deleteAllConfirmText.trim().toUpperCase() !== 'DELETE') return;
deletingAll = true;
const tid = toast.loading('Deleting all entries…');
try {
const result = await api.deleteAllThroughputEntries();
deleteAllResult = result.entries_deleted;
showDeleteAll = false;
deleteAllConfirmText = '';
toast.dismiss(tid);
toast.success(
`Deleted ${result.entries_deleted} ${result.entries_deleted === 1 ? 'entry' : 'entries'}`
);
} catch (err: unknown) {
toast.dismiss(tid);
toast.error(err instanceof Error ? err.message : 'Failed to delete entries');
} finally {
deletingAll = false;
}
}
// Build a small sample CSV in the browser so operators have a working header
// row to copy from. The backend matches these headers case-insensitively.
function downloadTemplate() {
const headers = [
'Date',
'Product',
'Item ID',
'Quantity',
'Type',
'Bag Size',
'Packed By',
'For Order',
'Job Number',
'For Stock',
'Stock Quantity',
'Notes'
];
const example = [
'2026-06-12',
'Specialty Pigeon Breeder',
'',
'40',
'bags',
'20',
'Jane Doe',
'yes',
'JOB1234',
'no',
'',
'First run of the day'
];
const csv = `${headers.join(',')}\n${example.join(',')}\n`;
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'throughput-import-template.csv';
link.click();
URL.revokeObjectURL(url);
}
2026-05-08 09:06:14 +12:00
const initials = $derived(
($clientSession?.name ?? '')
.split(' ')
.slice(0, 2)
.map((w) => w[0])
.join('')
.toUpperCase() || '?'
);
const navItems = $derived<{ id: Section; label: string; icon: typeof CircleUserRound }[]>([
2026-05-08 09:06:14 +12:00
{ id: 'profile', label: 'Profile', icon: CircleUserRound },
{ id: 'security', label: 'Security', icon: LockKeyhole },
...(canImportThroughput ? [{ id: 'import' as Section, label: 'Import', icon: Upload }] : []),
2026-06-17 21:55:04 +12:00
...(canManageUsers ? [{ id: 'users' as Section, label: 'Users', icon: Users }] : []),
...(canManageRoles ? [{ id: 'roles' as Section, label: 'Roles', icon: Users }] : []),
]);
const railGroups = $derived([{ items: navItems }]);
2026-05-08 09:06:14 +12:00
</script>
<AppSecondaryRailLayout>
{#snippet rail()}
<AppSecondaryRail
sectionLabel="Settings"
identityAvatarText={initials}
identityTitle={$clientSession?.name ?? 'Unknown'}
identitySubtitle={$clientSession?.role_name ?? $clientSession?.role ?? 'User'}
groups={railGroups}
activeId={activeSection}
onSelect={(id) => (activeSection = id as Section)}
/>
{/snippet}
2026-05-08 09:06:14 +12:00
<div class="settings-panel">
{#if activeSection === 'profile'}
<div class="panel-section">
<header class="panel-header">
<h2>Profile</h2>
<p>Update your display name and email address.</p>
</header>
<form class="panel-form" onsubmit={(e) => { e.preventDefault(); saveProfile(); }}>
<div class="field-row">
<div class="field">
<label for="name">Full name</label>
<input id="name" type="text" bind:value={name} autocomplete="name" required />
</div>
<div class="field">
<label for="email">Email address</label>
<input id="email" type="email" bind:value={email} autocomplete="email" required />
</div>
</div>
<div class="form-footer">
<button class="btn-primary" type="submit" disabled={profileSaving}>
{profileSaving ? 'Saving…' : 'Save changes'}
</button>
</div>
</form>
</div>
{:else if activeSection === 'security'}
<div class="panel-section">
<header class="panel-header">
<h2>Security</h2>
<p>Choose a strong password with at least 8 characters.</p>
</header>
<form class="panel-form" onsubmit={(e) => { e.preventDefault(); savePassword(); }}>
<div class="field">
<label for="current-password">Current password</label>
<input id="current-password" type="password" bind:value={currentPassword} autocomplete="current-password" required />
</div>
<div class="divider" aria-hidden="true"></div>
<div class="field-row">
<div class="field">
<label for="new-password">New password</label>
<input id="new-password" type="password" bind:value={newPassword} autocomplete="new-password" required />
</div>
<div class="field">
<label for="confirm-password">Confirm new password</label>
<input id="confirm-password" type="password" bind:value={confirmPassword} autocomplete="new-password" required />
</div>
</div>
{#if passwordError}
<p class="form-error">{passwordError}</p>
{/if}
<div class="form-footer">
<button class="btn-primary" type="submit" disabled={passwordSaving}>
{passwordSaving ? 'Updating…' : 'Update password'}
</button>
</div>
</form>
</div>
{:else if activeSection === 'import' && canImportThroughput}
<div class="panel-section">
<header class="panel-header">
<h2>Import throughput entries</h2>
<p>Upload a CSV or Excel (.xlsx) file of packing runs. Each row is saved as a throughput entry.</p>
</header>
<div class="import-body">
<div class="import-help">
<h3>Required columns</h3>
<p>
Your file needs a header row with at least <strong>Date</strong>, <strong>Product</strong>
and <strong>Quantity</strong> columns. These optional columns are also recognised:
</p>
<ul>
<li><strong>Type</strong><code>bags</code> or <code>kg</code> (inferred from bag size if omitted)</li>
<li><strong>Bag Size</strong> — kg per bag (required when packing as bags)</li>
<li><strong>Item ID</strong> — matches an existing product; otherwise matched by name</li>
<li><strong>Packed By</strong>, <strong>Notes</strong></li>
<li><strong>For Order</strong>, <strong>Job Number</strong>, <strong>For Stock</strong>, <strong>Stock Quantity</strong></li>
</ul>
<p class="import-note">
Products that don't already exist are created automatically. Dates accept
<code>YYYY-MM-DD</code> or <code>DD/MM/YYYY</code>.
</p>
<button type="button" class="btn-link" onclick={downloadTemplate}>
<FileSpreadsheet size={15} strokeWidth={2.2} /> Download CSV template
</button>
</div>
<div class="import-control">
<label class="file-drop" class:has-file={!!importFile}>
<input
type="file"
accept=".csv,.xlsx,.xlsm,.xls,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onchange={onImportFileChange}
/>
<Upload size={22} strokeWidth={2} />
<span class="file-drop-label">
{importFile ? importFile.name : 'Choose a CSV or .xlsx file'}
</span>
{#if importFile}
<span class="file-drop-size">{(importFile.size / 1024).toFixed(1)} KB</span>
{/if}
</label>
{#if importError}
<p class="form-error"><TriangleAlert size={15} strokeWidth={2.2} /> {importError}</p>
{/if}
{#if importResult}
<div class="import-result" role="status">
<p class="import-result-head">
Imported <strong>{importResult.entries_imported}</strong>
{importResult.entries_imported === 1 ? 'entry' : 'entries'}.
</p>
<ul class="import-result-stats">
{#if importResult.products_created > 0}
<li>{importResult.products_created} new product{importResult.products_created === 1 ? '' : 's'} created</li>
{/if}
{#if importResult.entries_skipped > 0}
<li>{importResult.entries_skipped} row{importResult.entries_skipped === 1 ? '' : 's'} skipped</li>
{/if}
</ul>
{#if importResult.errors.length > 0}
<details class="import-errors">
<summary>{importResult.errors.length} issue{importResult.errors.length === 1 ? '' : 's'} to review</summary>
<ul>
{#each importResult.errors as err (err)}
<li>{err}</li>
{/each}
</ul>
</details>
{/if}
</div>
{/if}
<div class="form-footer">
<button class="btn-primary" type="button" disabled={importing || !importFile} onclick={runImport}>
{importing ? 'Importing…' : 'Import entries'}
</button>
</div>
</div>
</div>
2026-06-17 21:55:04 +12:00
<div class="danger-zone">
<div class="danger-copy">
<h3><TriangleAlert size={16} strokeWidth={2.2} /> Delete all entries</h3>
<p>
Permanently remove every throughput entry. Products are kept, but all
packing runs are erased. This cannot be undone — use it to clear a bad
import before re-uploading.
</p>
{#if deleteAllResult !== null}
<p class="danger-result" role="status">
Deleted <strong>{deleteAllResult}</strong>
{deleteAllResult === 1 ? 'entry' : 'entries'}.
</p>
{/if}
</div>
<button type="button" class="btn-danger" onclick={openDeleteAll}>
<Trash2 size={15} strokeWidth={2.2} /> Delete all entries
</button>
</div>
</div>
2026-06-17 21:55:04 +12:00
{:else if activeSection === 'users' && canManageUsers}
<UserManagementPanel />
{:else if activeSection === 'roles' && canManageRoles}
<RoleManagementPanel />
2026-05-08 09:06:14 +12:00
{/if}
</div>
</AppSecondaryRailLayout>
2026-05-08 09:06:14 +12:00
2026-06-17 21:55:04 +12:00
{#if showDeleteAll}
<div class="modal-backdrop" role="presentation" onclick={cancelDeleteAll}>
<div
class="modal-card"
role="dialog"
aria-modal="true"
aria-labelledby="delete-all-title"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => { if (event.key === 'Escape') cancelDeleteAll(); }}
>
<div class="modal-icon"><Trash2 size={22} strokeWidth={2.2} /></div>
<h2 id="delete-all-title" class="modal-title">Delete all throughput entries?</h2>
<p class="modal-text">
This permanently removes <strong>every</strong> throughput entry for your
workspace. Products are kept, but the packing-run history cannot be recovered.
</p>
<label class="modal-confirm-field">
<span>Type <strong>DELETE</strong> to confirm</span>
<input
type="text"
bind:value={deleteAllConfirmText}
autocomplete="off"
spellcheck="false"
placeholder="DELETE"
/>
</label>
<div class="modal-actions">
<button type="button" class="modal-cancel" onclick={cancelDeleteAll} disabled={deletingAll}>
Cancel
</button>
<button
type="button"
class="modal-confirm"
disabled={deletingAll || deleteAllConfirmText.trim().toUpperCase() !== 'DELETE'}
onclick={confirmDeleteAll}
>
{deletingAll ? 'Deleting…' : 'Delete all entries'}
</button>
</div>
</div>
</div>
{/if}
2026-05-08 09:06:14 +12:00
<style>
.settings-panel {
display: flex;
flex-direction: column;
min-width: 0;
background: var(--panel);
}
.panel-section {
display: flex;
flex-direction: column;
}
.panel-header {
padding: 1.5rem 1.75rem 1.25rem;
border-bottom: 1px solid var(--line);
}
.panel-header h2 {
margin: 0 0 0.3rem;
font-size: 1.1rem;
2026-04-27 21:53:36 +12:00
font-weight: 700;
}
2026-05-08 09:06:14 +12:00
.panel-header p {
margin: 0;
font-size: 0.85rem;
2026-04-27 21:53:36 +12:00
color: var(--muted);
}
2026-05-08 09:06:14 +12:00
/* ── Form ───────────────────────────────────────────────────── */
.panel-form {
display: grid;
gap: 1rem;
width: 100%;
padding: 1.5rem 1.75rem;
max-width: 42rem;
}
.field-row {
2026-04-27 21:53:36 +12:00
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
2026-05-08 09:06:14 +12:00
.field {
2026-04-27 21:53:36 +12:00
display: grid;
2026-05-08 09:06:14 +12:00
gap: 0.42rem;
}
.field label {
font-size: 0.82rem;
font-weight: 600;
color: var(--text);
}
.field input {
width: 100%;
padding: 0.62rem 0.85rem;
2026-04-27 21:53:36 +12:00
border: 1px solid var(--line);
2026-05-08 09:06:14 +12:00
border-radius: 0.6rem;
2026-04-27 21:53:36 +12:00
background: var(--panel-soft);
2026-05-08 09:06:14 +12:00
color: var(--text);
font-size: 0.9rem;
transition: border-color 140ms ease, box-shadow 140ms ease;
box-sizing: border-box;
2026-04-27 21:53:36 +12:00
}
2026-05-08 09:06:14 +12:00
.field input:focus {
outline: none;
border-color: var(--green-deep);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
}
.divider {
height: 1px;
background: var(--line);
margin: 0.25rem 0;
}
.form-error {
margin: 0;
padding: 0.65rem 0.85rem;
background: color-mix(in srgb, #e53e3e 8%, transparent);
border: 1px solid color-mix(in srgb, #e53e3e 25%, transparent);
border-radius: 0.6rem;
color: #c53030;
2026-04-27 21:53:36 +12:00
font-size: 0.84rem;
}
2026-05-08 09:06:14 +12:00
.form-footer {
display: flex;
justify-content: flex-end;
padding-top: 0.25rem;
border-top: 1px solid var(--line);
margin-top: 0.5rem;
2026-04-27 21:53:36 +12:00
}
2026-05-08 09:06:14 +12:00
.btn-primary {
padding: 0.58rem 1.4rem;
background: var(--color-brand);
color: #fff;
border: none;
border-radius: 0.6rem;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: opacity 140ms ease;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.88;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ── Import ─────────────────────────────────────────────────── */
.import-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 1.5rem;
padding: 1.5rem 1.75rem;
}
.import-help h3 {
margin: 0 0 0.5rem;
font-size: 0.92rem;
font-weight: 700;
color: var(--text);
}
.import-help p {
margin: 0 0 0.65rem;
font-size: 0.85rem;
line-height: 1.5;
color: var(--muted);
}
.import-help ul {
margin: 0 0 0.65rem;
padding-left: 1.1rem;
display: grid;
gap: 0.3rem;
font-size: 0.84rem;
color: var(--muted);
}
.import-help code {
padding: 0.05rem 0.32rem;
border-radius: 0.35rem;
background: var(--panel-soft);
border: 1px solid var(--line);
font-size: 0.8rem;
}
.import-note {
font-size: 0.82rem;
}
.btn-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0;
background: none;
border: none;
color: var(--color-brand);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.btn-link:hover {
text-decoration: underline;
}
.import-control {
display: grid;
gap: 1rem;
align-content: start;
}
.file-drop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem 1.25rem;
border: 1.5px dashed var(--line);
border-radius: 0.75rem;
background: var(--panel-soft);
color: var(--muted);
cursor: pointer;
text-align: center;
transition: border-color 140ms ease, color 140ms ease;
}
.file-drop:hover {
border-color: var(--color-brand);
color: var(--text);
}
.file-drop.has-file {
border-style: solid;
border-color: var(--color-brand);
color: var(--text);
}
.file-drop input {
display: none;
}
.file-drop-label {
font-size: 0.88rem;
font-weight: 600;
word-break: break-all;
}
.file-drop-size {
font-size: 0.78rem;
color: var(--muted);
}
.form-error {
display: flex;
align-items: center;
gap: 0.4rem;
}
.import-result {
padding: 0.85rem 1rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 25%, transparent);
border-radius: 0.6rem;
background: color-mix(in srgb, var(--color-brand) 7%, transparent);
}
.import-result-head {
margin: 0;
font-size: 0.9rem;
color: var(--text);
}
.import-result-stats {
margin: 0.45rem 0 0;
padding-left: 1.1rem;
display: grid;
gap: 0.2rem;
font-size: 0.83rem;
color: var(--muted);
}
.import-errors {
margin-top: 0.6rem;
font-size: 0.83rem;
color: var(--muted);
}
.import-errors summary {
cursor: pointer;
font-weight: 600;
color: var(--text);
}
.import-errors ul {
margin: 0.45rem 0 0;
padding-left: 1.1rem;
display: grid;
gap: 0.25rem;
max-height: 12rem;
overflow-y: auto;
}
2026-06-17 21:55:04 +12:00
/* ── Danger zone ────────────────────────────────────────────── */
.danger-zone {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.25rem;
margin: 0 1.75rem 1.75rem;
padding: 1.1rem 1.25rem;
border: 1px solid color-mix(in srgb, #e53e3e 30%, transparent);
border-radius: 0.75rem;
background: color-mix(in srgb, #e53e3e 5%, transparent);
}
.danger-copy {
min-width: 0;
}
.danger-copy h3 {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0 0 0.35rem;
font-size: 0.92rem;
font-weight: 700;
color: #c53030;
}
.danger-copy p {
margin: 0;
font-size: 0.83rem;
line-height: 1.5;
color: var(--muted);
max-width: 34rem;
}
.danger-result {
margin-top: 0.5rem !important;
color: var(--text) !important;
}
.btn-danger {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.58rem 1.1rem;
background: #b3261e;
color: #fff;
border: 1px solid #b3261e;
border-radius: 0.6rem;
font-size: 0.86rem;
font-weight: 600;
cursor: pointer;
transition: background-color 140ms ease;
}
.btn-danger:hover {
background: #95201a;
}
/* ── Confirmation modal ─────────────────────────────────────── */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
padding: 1.5rem;
background: color-mix(in srgb, var(--color-text-primary, #000) 32%, transparent);
backdrop-filter: blur(6px);
}
.modal-card {
width: min(28rem, 100%);
display: grid;
gap: 0.7rem;
padding: 1.6rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: var(--panel);
box-shadow: 0 24px 60px -20px rgba(0, 0, 0, 0.45);
}
.modal-card:focus {
outline: none;
}
.modal-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.8rem;
height: 2.8rem;
border-radius: 0.8rem;
background: #fdecee;
color: #b3261e;
}
.modal-title {
margin: 0;
font-size: 1.2rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.modal-text {
margin: 0;
font-size: 0.92rem;
line-height: 1.5;
color: var(--muted);
}
.modal-confirm-field {
display: grid;
gap: 0.4rem;
margin-top: 0.2rem;
}
.modal-confirm-field span {
font-size: 0.82rem;
color: var(--muted);
}
.modal-confirm-field input {
width: 100%;
padding: 0.55rem 0.8rem;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--panel-soft);
color: var(--text);
font-size: 0.9rem;
letter-spacing: 0.06em;
box-sizing: border-box;
}
.modal-confirm-field input:focus {
outline: none;
border-color: #b3261e;
box-shadow: 0 0 0 3px color-mix(in srgb, #b3261e 18%, transparent);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
margin-top: 0.55rem;
}
.modal-cancel,
.modal-confirm {
padding: 0.55rem 1.1rem;
border-radius: 0.6rem;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: background-color 150ms ease, border-color 150ms ease, opacity 150ms ease;
}
.modal-cancel {
background: var(--panel);
border: 1px solid var(--line);
color: var(--muted);
}
.modal-cancel:hover:not(:disabled) {
color: var(--text);
}
.modal-confirm {
background: #b3261e;
border: 1px solid #b3261e;
color: #fff;
}
.modal-confirm:hover:not(:disabled) {
background: #95201a;
}
.modal-confirm:disabled,
.modal-cancel:disabled {
opacity: 0.55;
cursor: not-allowed;
}
2026-05-08 09:06:14 +12:00
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 720px) {
.field-row {
2026-04-27 21:53:36 +12:00
grid-template-columns: 1fr;
}
.import-body {
grid-template-columns: 1fr;
}
2026-06-17 21:55:04 +12:00
.danger-zone {
flex-direction: column;
align-items: flex-start;
}
2026-04-27 21:53:36 +12:00
}
</style>