2026-04-27 21:53:36 +12:00
|
|
|
<script lang="ts">
|
2026-05-08 09:06:14 +12:00
|
|
|
import { api } from '$lib/api';
|
2026-05-08 23:07:01 +12:00
|
|
|
import AppSecondaryRail from '$lib/components/navigation/AppSecondaryRail.svelte';
|
|
|
|
|
import AppSecondaryRailLayout from '$lib/components/navigation/AppSecondaryRailLayout.svelte';
|
2026-04-27 21:53:36 +12:00
|
|
|
import { clientSession } from '$lib/session';
|
2026-06-13 10:00:15 +12:00
|
|
|
import { canEditThroughput } from '$lib/workspace-access';
|
2026-05-08 09:06:14 +12:00
|
|
|
import { toast } from '$lib/toast';
|
2026-06-13 10:00:15 +12:00
|
|
|
import type { ThroughputImportResult } from '$lib/types';
|
|
|
|
|
import { CircleUserRound, LockKeyhole, Upload, FileSpreadsheet, TriangleAlert } from 'lucide-svelte';
|
2026-04-27 21:53:36 +12:00
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
type Section = 'profile' | 'security' | 'import';
|
2026-05-08 09:06:14 +12:00
|
|
|
let activeSection = $state<Section>('profile');
|
2026-04-27 21:53:36 +12:00
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
// Only operators who can edit throughput see (and can use) the import tool.
|
|
|
|
|
const canImportThroughput = $derived(canEditThroughput($clientSession));
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
// ── 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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() || '?'
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
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 },
|
2026-06-13 10:00:15 +12:00
|
|
|
...(canImportThroughput ? [{ id: 'import' as Section, label: 'Import', icon: Upload }] : []),
|
|
|
|
|
]);
|
2026-05-08 23:07:01 +12:00
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
const railGroups = $derived([{ items: navItems }]);
|
2026-05-08 09:06:14 +12:00
|
|
|
</script>
|
|
|
|
|
|
2026-05-08 23:07:01 +12:00
|
|
|
<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>
|
2026-06-13 10:00:15 +12:00
|
|
|
|
|
|
|
|
{: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>
|
|
|
|
|
</div>
|
2026-05-08 09:06:14 +12:00
|
|
|
{/if}
|
|
|
|
|
</div>
|
2026-05-08 23:07:01 +12:00
|
|
|
</AppSecondaryRailLayout>
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
/* ── 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-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;
|
|
|
|
|
}
|
2026-06-13 10:00:15 +12:00
|
|
|
|
|
|
|
|
.import-body {
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
}
|
2026-04-27 21:53:36 +12:00
|
|
|
}
|
|
|
|
|
</style>
|