Files
plumbing-dashy/screens.jsx
T

1093 lines
44 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Screens for Dashy
function LoginScreen({ onLogin, dbUsers = [], workspace }) {
const [pickedId, setPickedId] = React.useState('rod');
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState('');
const [busy, setBusy] = React.useState(false);
React.useEffect(() => { setPassword(''); setError(''); }, [pickedId]);
const submit = async () => {
if (!password) { setError('Enter your password'); return; }
setError(''); setBusy(true);
try {
await onLogin(pickedId, password);
} catch (e) {
setError('Incorrect password');
} finally {
setBusy(false);
}
};
return (
<div className="login">
<div className="login__card">
<div className="login__brand">
<BrandMark />
<span className="login__wordmark">Dashy</span>
</div>
<h1 className="login__title">Pick up where you left off.</h1>
<p className="login__sub">Sign in to your team workspace · <span className="mono">{workspace ? workspace.name : 'loading…'}</span></p>
<div className="login__users">
{dbUsers.map(u => (
<button
key={u.id}
className={"login__user" + (pickedId === u.id ? " is-picked" : "")}
onClick={() => setPickedId(u.id)}
>
<Avatar user={u} size={40} />
<div className="login__user-meta">
<span className="login__user-name">{u.name}</span>
<span className="login__user-role">{u.role}</span>
</div>
{pickedId === u.id && <span className="login__user-tick"><Icon.Check /></span>}
</button>
))}
</div>
<label className="field">
<span className="field__label">Password</span>
<input
className="field__input"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') submit(); }}
placeholder="Enter password"
autoFocus
/>
</label>
{error && <div className="mono" style={{ color: 'var(--prio-high-dot)', marginTop: '0.25rem' }}>{error}</div>}
<button className="btn btn--primary btn--full" onClick={submit} disabled={busy}>
{busy ? 'Signing in…' : <>Sign in as {findUser(pickedId).name}</>}
<Icon.Arrow />
</button>
<p className="login__foot">
<span className="mono"> </span> to submit · <a href="#" onClick={e => e.preventDefault()}>Forgot password</a>
</p>
</div>
<div className="login__side">
<div className="login__side-eyebrow mono">04 May 08 May</div>
<div className="login__side-stats">
<Stat n="42" label="tasks closed this week" />
<Stat n="14" label="auto-created from email" />
<Stat n="6" label="from iMessage → Dashy" />
<Stat n="2" label="form responses re-routed" />
</div>
<div className="login__side-foot mono">v0.1 · build 26.05.08</div>
</div>
</div>
);
}
function Stat({ n, label }) {
return (
<div className="stat">
<div className="stat__n">{n}</div>
<div className="stat__label">{label}</div>
</div>
);
}
function BrandMark({ size = 22 }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" aria-hidden="true">
<rect x="2" y="2" width="9" height="13" rx="2" fill="currentColor" />
<rect x="13" y="2" width="9" height="6" rx="2" fill="currentColor" opacity="0.55" />
<rect x="2" y="17" width="9" height="5" rx="2" fill="currentColor" opacity="0.55" />
<rect x="13" y="10" width="9" height="12" rx="2" fill="currentColor" opacity="0.85" />
</svg>
);
}
function TopBar({ me, dbUsers = [], isAdmin, tab, setTab, onAdd, onLogs, onLogout, onProfile, workspace }) {
return (
<header className="topbar">
<div className="topbar__left">
<span className="topbar__brand"><BrandMark /><span>Dashy</span></span>
<span className="topbar__divider" />
<span className="topbar__workspace">{workspace ? workspace.name : 'loading…'}</span>
</div>
<nav className="tabs" role="tablist">
<Tab id="overview" label="Overview" tab={tab} setTab={setTab} />
{dbUsers.map(u => (
<Tab key={u.id} id={u.id} label={u.name} tab={tab} setTab={setTab} user={u} />
))}
{isAdmin && <Tab id="deleted" label="Deleted" tab={tab} setTab={setTab} />}
</nav>
<div className="topbar__right">
<button className="btn btn--soft" onClick={onAdd}>
<Icon.Plus /> New task
</button>
<IconBtn label="Search">
<Icon.Search />
</IconBtn>
{isAdmin && (
<IconBtn label="Audit log" onClick={onLogs}>
<Icon.Logs />
</IconBtn>
)}
<button className="topbar__me" onClick={onProfile}>
<Avatar user={me} size={28} ring />
<div className="topbar__me-meta">
<span className="topbar__me-name">
{me.name}
{isAdmin && <span className="admin-badge" title="Admin"></span>}
</span>
<span className="topbar__me-role">{isAdmin ? 'Admin · ' : ''}{me.role}</span>
</div>
</button>
</div>
</header>
);
}
function Tab({ id, label, tab, setTab, user }) {
const active = tab === id;
return (
<button
role="tab"
aria-selected={active}
className={"tab" + (active ? " is-active" : "")}
onClick={() => setTab(id)}
>
{user && <span className="tab__dot" style={{ background: `oklch(70% 0.10 ${user.hue})` }} />}
{label}
</button>
);
}
function HeadsUp({ items, onDismiss, onOpenTask }) {
if (!items.length) return null;
return (
<div className="heads-up" role="status">
{items.map(it => (
<div key={it.id} className={"heads-up__item heads-up__item--" + it.kind}>
<span className="heads-up__icon">
{it.kind === 'unsuccessful' ? '!' : it.kind === 'billing' ? '⇄' : '✦'}
</span>
<div className="heads-up__body">
<div className="heads-up__title">{it.title}</div>
<div className="heads-up__sub">{it.sub}</div>
</div>
<button className="heads-up__open" onClick={() => onOpenTask(it.taskId)}>
View <Icon.Arrow />
</button>
<button className="heads-up__close" onClick={() => onDismiss(it.id)} aria-label="Dismiss">
<Icon.Close />
</button>
</div>
))}
</div>
);
}
function OverviewScreen({ tasks, onOpen, onAddFor, density, onMoveTask, dbUsers = [] }) {
const byUser = Object.fromEntries(dbUsers.map(u => [u.id, []]));
tasks.forEach(t => { if (byUser[t.assignee] && t.status !== 'closed') byUser[t.assignee].push(t); });
const [draggingTask, setDraggingTask] = React.useState(null);
const [dragOverCol, setDragOverCol] = React.useState(null);
return (
<div className="board">
{dbUsers.map(u => (
<Column
key={u.id}
user={u}
tasks={byUser[u.id]}
onOpen={onOpen}
onAdd={() => onAddFor(u.id)}
density={density}
dragOver={dragOverCol === u.id && draggingTask && draggingTask.assignee !== u.id}
onDragOver={(uid) => setDragOverCol(uid)}
onDragLeave={() => setDragOverCol(prev => prev === u.id ? null : prev)}
onDragStartCard={(t) => setDraggingTask(t)}
onDragEndCard={() => { setDraggingTask(null); setDragOverCol(null); }}
draggingId={draggingTask && draggingTask.id}
onDropTask={(toId) => {
if (draggingTask && draggingTask.assignee !== toId) {
onMoveTask && onMoveTask(draggingTask.id, toId);
}
setDraggingTask(null); setDragOverCol(null);
}}
/>
))}
</div>
);
}
function Column({ user, tasks, onOpen, onAdd, density, onDropTask, dragOver, onDragOver, onDragLeave, onDragStartCard, onDragEndCard, draggingId }) {
return (
<section
className={"column" + (dragOver ? " column--over" : "")}
data-comment-anchor={"col-" + user.id}
onDragOver={(e) => { e.preventDefault(); onDragOver && onDragOver(user.id); }}
onDragLeave={(e) => { onDragLeave && onDragLeave(user.id); }}
onDrop={(e) => { e.preventDefault(); onDropTask && onDropTask(user.id); }}
>
<header className="column__head">
<div className="column__head-l">
<Avatar user={user} size={28} />
<div className="column__head-meta">
<h2 className="column__name">{user.name}</h2>
<span className="column__role">{user.role}</span>
</div>
</div>
<div className="column__head-r">
<span className="column__count mono">{tasks.length}</span>
<button className="add-btn" onClick={onAdd} aria-label={"Add task for " + user.name}>
<Icon.Plus />
</button>
</div>
</header>
<div className="column__list">
{tasks.length === 0 && (
<div className="column__empty">
<span className="mono"> inbox zero </span>
</div>
)}
{tasks.map(t => (
<TaskCard
key={t.id}
task={t}
onOpen={onOpen}
density={density}
dragging={draggingId === t.id}
onDragStart={onDragStartCard}
onDragEnd={onDragEndCard}
/>
))}
</div>
</section>
);
}
function UserScreen({ user, tasks, onOpen, onAddFor, density }) {
const mine = tasks.filter(t => t.assignee === user.id);
const open = mine.filter(t => t.status === 'open');
const flagged = mine.filter(t => t.status === 'unsuccessful' || t.status === 'billing');
const closed = mine.filter(t => t.status === 'closed');
return (
<div className="user-view">
<div className="user-view__hero">
<Avatar user={user} size={64} />
<div className="user-view__hero-meta">
<h1 className="user-view__name">{user.name}</h1>
<p className="user-view__role">{user.role} · <span className="mono">{open.length + flagged.length} open tasks</span></p>
</div>
<div className="user-view__hero-cta">
<button className="btn btn--primary" onClick={() => onAddFor(user.id)}>
<Icon.Plus /> New task for {user.name}
</button>
</div>
</div>
{flagged.length > 0 && (
<Section title="Needs review" sub="Auto-flagged by the system">
<div className="user-view__grid">
{flagged.map(t => <TaskCard key={t.id} task={t} onOpen={onOpen} density={density} />)}
</div>
</Section>
)}
<Section title="Open" sub={open.length + ' tasks'}>
<div className="user-view__grid">
{open.map(t => <TaskCard key={t.id} task={t} onOpen={onOpen} density={density} />)}
</div>
</Section>
{closed.length > 0 && (
<Section title="Completed" sub={closed.length + ' tasks'}>
<div className="user-view__grid" style={{ opacity: 0.6 }}>
{closed.map(t => <TaskCard key={t.id} task={t} onOpen={onOpen} density={density} />)}
</div>
</Section>
)}
</div>
);
}
function Section({ title, sub, children }) {
return (
<section className="section">
<header className="section__head">
<h2 className="section__title">{title}</h2>
{sub && <span className="section__sub mono">{sub}</span>}
</header>
{children}
</section>
);
}
function AddTaskModal({ open, onClose, onSubmit, defaultAssignee, me, dbUsers = [] }) {
const [title, setTitle] = React.useState('');
const [desc, setDesc] = React.useState('');
const [assignee, setAssignee] = React.useState(defaultAssignee || 'lani');
const [priority, setPriority] = React.useState('med');
React.useEffect(() => { if (open) setAssignee(defaultAssignee || 'lani'); }, [open, defaultAssignee]);
React.useEffect(() => { if (open) { setTitle(''); setDesc(''); setPriority('med'); } }, [open]);
if (!open) return null;
const submit = (e) => {
e && e.preventDefault();
if (!title.trim()) return;
onSubmit({ title: title.trim(), description: desc.trim(), assignee, priority });
};
return (
<Modal onClose={onClose} title="New task" eyebrow={"From " + me.name}>
<form onSubmit={submit} className="modal__form">
<label className="field">
<span className="field__label">Task</span>
<input
className="field__input field__input--big"
autoFocus
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="What needs doing?"
/>
</label>
<label className="field">
<span className="field__label">Notes <span className="mono"> optional</span></span>
<textarea
className="field__textarea"
rows={3}
value={desc}
onChange={e => setDesc(e.target.value)}
placeholder="Any context, links, work-order numbers…"
/>
</label>
<div className="modal__row">
<div className="field">
<span className="field__label">Assign to</span>
<div className="picker">
{dbUsers.map(u => (
<button
type="button"
key={u.id}
className={"picker__item" + (assignee === u.id ? " is-on" : "")}
onClick={() => setAssignee(u.id)}
>
<Avatar user={u} size={20} />
{u.name}
</button>
))}
</div>
</div>
<div className="field">
<span className="field__label">Priority</span>
<div className="picker">
{Object.entries(PRIORITY).map(([k, v]) => (
<button
type="button"
key={k}
className={"picker__item" + (priority === k ? " is-on" : "")}
onClick={() => setPriority(k)}
>
<span className="prio__dot" style={{ background: v.dot }} />
{v.label}
</button>
))}
</div>
</div>
</div>
<footer className="modal__foot">
<p className="modal__hint mono">
tip say <span className="kbd">"Hey Siri, message Dashy"</span> to add a task from your phone
</p>
<div className="modal__actions">
<button type="button" className="btn btn--ghost" onClick={onClose}>Cancel</button>
<button type="submit" className="btn btn--primary" disabled={!title.trim()}>
Add task <Icon.Arrow />
</button>
</div>
</footer>
</form>
</Modal>
);
}
function Modal({ children, onClose, title, eyebrow, wide = false }) {
return (
<div className="modal-scrim" onClick={onClose}>
<div className={"modal" + (wide ? " modal--wide" : "")} onClick={e => e.stopPropagation()}>
<header className="modal__head">
<div>
{eyebrow && <span className="modal__eyebrow mono">{eyebrow}</span>}
<h2 className="modal__title">{title}</h2>
</div>
<IconBtn label="Close" onClick={onClose}><Icon.Close /></IconBtn>
</header>
{children}
</div>
</div>
);
}
function TaskDetail({ task, allAudit = [], onClose, onMove, onPriority, onComplete, onReopen, onEditDesc, onDeleteTask }) {
const [editingDesc, setEditingDesc] = React.useState(false);
const [descValue, setDescValue] = React.useState(task ? task.description || '' : '');
React.useEffect(() => {
setDescValue(task ? task.description || '' : '');
}, [task]);
if (!task) return null;
const assignee = findUser(task.assignee);
const author = findUser(task.addedBy);
const audit = allAudit.filter(a => a.target === task.id);
if (audit.length === 0) {
audit.push({ at: task.addedAt, actor: task.addedBy, action: 'task_created', summary: '' });
}
return (
<Modal onClose={onClose} wide title={task.title} eyebrow={(task.tags && task.tags.join(' · ')) || 'Task'}>
<div className="detail">
<div className="detail__main">
{task.status === 'unsuccessful' && (
<div className="detail__alert detail__alert--warn">
<strong>Auto-marked Unsuccessful.</strong> Two missed bookings detected by the scheduler. Decide on next step or revert.
<div className="detail__alert-actions">
<button className="btn btn--ghost btn--sm">Revert to Open</button>
<button className="btn btn--soft btn--sm" onClick={onComplete}>Close as unsuccessful</button>
</div>
</div>
)}
{task.status === 'billing' && (
<div className="detail__alert detail__alert--info">
<strong>Form re-routed to Billing.</strong> Originally captured as Service Booking keywords matched billing template.
</div>
)}
<div className="detail__desc-section" style={{ marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
<h3 className="detail__h" style={{ margin: 0 }}>Description</h3>
{!editingDesc && (
<IconBtn label="Edit description" onClick={() => setEditingDesc(true)}>
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M12.5 3.5l-8 8V14h2.5l8-8-2.5-2.5z" strokeLinecap="round" strokeLinejoin="round"/><path d="M10.5 5.5l2 2" strokeLinecap="round" strokeLinejoin="round"/></svg>
</IconBtn>
)}
</div>
{editingDesc ? (
<div className="detail__desc-edit">
<textarea
className="field__textarea"
value={descValue}
onChange={e => setDescValue(e.target.value)}
autoFocus
rows={4}
/>
<div className="detail__alert-actions" style={{ marginTop: '0.5rem' }}>
<button className="btn btn--ghost btn--sm" onClick={() => { setDescValue(task.description || ''); setEditingDesc(false); }}>Cancel</button>
<button className="btn btn--primary btn--sm" onClick={() => { onEditDesc(descValue); setEditingDesc(false); }}>Save</button>
</div>
</div>
) : (
<p className="detail__desc" style={{ marginTop: 0 }}>{task.description || <span className="mono" style={{opacity: 0.5}}>No description</span>}</p>
)}
</div>
<div className="detail__notes">
<h3 className="detail__h">Notes &amp; reminders</h3>
<ul className="notes">
{/* Dynamic notes will go here */}
</ul>
<div className="notes__add">
<input className="field__input" placeholder="Add a note or @mention…" />
<button className="btn btn--soft btn--sm">Add</button>
</div>
</div>
<div className="detail__audit">
<h3 className="detail__h">Activity</h3>
<ol className="timeline">
{audit.slice().reverse().map((row, i) => (
<li key={i} className="timeline__row">
<div className="timeline__rail">
<span className="timeline__dot" data-actor={row.actor} />
</div>
<div className="timeline__body">
<div className="timeline__line">
<strong>
{row.actor === 'system' ? 'System' : (findUser(row.actor) || {}).name}
</strong>
<span> {row.action.replace(/_/g, ' ')}</span>
</div>
{(row.detail || row.summary) && <div className="timeline__detail">{row.detail || row.summary}</div>}
<div className="timeline__time mono">{fmtDateTime(row.at)}</div>
</div>
</li>
))}
</ol>
</div>
</div>
<aside className="detail__side">
{task.status === 'closed' ? (
<button className="btn btn--ghost btn--full" style={{ marginBottom: '1.5rem' }} onClick={onReopen}>
<Icon.Arrow /> Reopen task
</button>
) : (
<button className="btn btn--primary btn--full" style={{ marginBottom: '1.5rem' }} onClick={onComplete}>
<Icon.Check /> Mark as completed
</button>
)}
<Field label="Assigned to">
<div className="picker">
{dbUsers.map(u => (
<button key={u.id}
className={"picker__item" + (task.assignee === u.id ? " is-on" : "")}
onClick={() => onMove(task.id, u.id)}>
<Avatar user={u} size={20} />
{u.name}
</button>
))}
</div>
</Field>
<Field label="Priority">
<div className="picker">
{Object.entries(PRIORITY).map(([k, v]) => (
<button key={k}
className={"picker__item" + (task.priority === k ? " is-on" : "")}
onClick={() => onPriority(task.id, k)}>
<span className="prio__dot" style={{ background: v.dot }} />
{v.label}
</button>
))}
</div>
</Field>
<Field label="Source">
<div className="meta-row">
{task.source === 'imessage' && <><Icon.iMessage /> iMessage from {author && author.name}</>}
{task.source === 'email' && <><span className="mono"></span> Auto-created from email</>}
{task.source === 'automation' && <><span className="mono"></span> System automation</>}
{task.source === 'manual' && <>Created by {author && author.name}</>}
</div>
</Field>
<Field label="Added">
<div className="meta-row mono">{fmtDateTime(task.addedAt)}</div>
</Field>
<Field label="Reminder">
<div className="meta-row">Thu, May 7 · 4:00 pm</div>
</Field>
<div style={{ marginTop: '2rem', paddingTop: '1.5rem', borderTop: '1px solid var(--border-subtle)' }}>
<button
className="btn btn--ghost btn--full"
style={{ color: 'var(--prio-high-dot)', borderColor: 'transparent' }}
onClick={() => { if (confirm('Are you sure you want to permanently delete this task?')) onDeleteTask(); }}
>
<Icon.Close /> Delete task permanently
</button>
</div>
</aside>
</div>
</Modal>
);
}
function Field({ label, children }) {
return (
<div className="side-field">
<div className="side-field__label mono">{label}</div>
<div className="side-field__body">{children}</div>
</div>
);
}
function AuditScreen({ entries, onOpen }) {
const [filter, setFilter] = React.useState('all');
const groups = React.useMemo(() => {
const filtered = filter === 'all'
? entries
: filter === 'system'
? entries.filter(e => e.actor === 'system')
: entries.filter(e => e.actor === filter);
const out = {};
filtered.forEach(e => {
const day = new Date(e.at).toDateString();
(out[day] = out[day] || []).push(e);
});
return out;
}, [filter, entries]);
return (
<div className="audit">
<header className="audit__head">
<div>
<h1 className="audit__title">Audit log</h1>
<p className="audit__sub">Everything that's happened in the workspace · <span className="mono">last 7 days</span></p>
</div>
<div className="audit__filter">
<FilterChip on={filter==='all'} onClick={() => setFilter('all')}>All</FilterChip>
<FilterChip on={filter==='system'} onClick={() => setFilter('system')}>System</FilterChip>
{dbUsers.map(u => (
<FilterChip key={u.id} on={filter===u.id} onClick={() => setFilter(u.id)}>
<Avatar user={u} size={16} /> {u.name}
</FilterChip>
))}
</div>
</header>
<div className="audit__list">
{Object.entries(groups).map(([day, rows]) => (
<div key={day} className="audit__group">
<div className="audit__day mono">{day}</div>
<ul className="audit__rows">
{rows.map(r => {
const actor = r.actor === 'system' ? null : findUser(r.actor);
return (
<li key={r.id} className="audit__row">
<span className="audit__time mono">{new Date(r.at).toLocaleTimeString('en-US',{hour:'numeric', minute:'2-digit'})}</span>
<span className="audit__actor">
{actor ? <Avatar user={actor} size={20} /> : <span className="audit__sys">SYS</span>}
<span>{actor ? actor.name : 'System'}</span>
</span>
<span className="audit__action mono">{r.action.replace(/_/g, ' ')}</span>
<span className="audit__summary">{r.summary}</span>
{r.target && (
<button className="audit__link" onClick={() => onOpen(r.target)}>open <Icon.Arrow /></button>
)}
</li>
);
})}
</ul>
</div>
))}
</div>
</div>
);
}
function FilterChip({ on, onClick, children }) {
return (
<button className={"filter-chip" + (on ? " is-on" : "")} onClick={onClick}>
{children}
</button>
);
}
function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onSwitchUser, onCreateUser, onDeleteUser, onUpdateUserRole, onChangePassword, workspace, onUpdateWorkspace }) {
const [name, setName] = React.useState(user.name);
const [role, setRole] = React.useState(user.role);
const [photo, setPhoto] = React.useState(user.photo || null);
const [tab, setTab] = React.useState('profile');
const [pwOld, setPwOld] = React.useState('');
const [pwNew, setPwNew] = React.useState('');
const [pwConfirm, setPwConfirm] = React.useState('');
const [pwSaved, setPwSaved] = React.useState(false);
const [pwError, setPwError] = React.useState('');
const [pwBusy, setPwBusy] = React.useState(false);
const [saved, setSaved] = React.useState(false);
const submitPasswordChange = async () => {
setPwError('');
if (pwNew !== pwConfirm) { setPwError('New passwords do not match'); return; }
setPwBusy(true);
try {
await onChangePassword(pwOld, pwNew);
setPwOld(''); setPwNew(''); setPwConfirm('');
setPwSaved(true);
setTimeout(() => setPwSaved(false), 2000);
} catch (e) {
setPwError(e.message || 'Failed to update password');
} finally {
setPwBusy(false);
}
};
React.useEffect(() => {
setName(user.name); setRole(user.role); setPhoto(user.photo || null);
}, [user.id]);
const fileInputRef = React.useRef(null);
const handleFile = (e) => {
const f = e.target.files && e.target.files[0];
if (!f) return;
const reader = new FileReader();
reader.onload = ev => setPhoto(ev.target.result);
reader.readAsDataURL(f);
};
const save = () => {
onSave({ name, role, photo });
setSaved(true);
setTimeout(() => setSaved(false), 1600);
};
const dirty = name !== user.name || role !== user.role || photo !== (user.photo || null);
return (
<Modal onClose={onClose} title="Account & settings" eyebrow={"Signed in as " + user.name} wide>
<div className="settings">
<nav className="settings__nav">
<button className={"settings__nav-item" + (tab==='profile'?' is-on':'')} onClick={() => setTab('profile')}>
<span className="settings__nav-icon">○</span> Profile
</button>
<button className={"settings__nav-item" + (tab==='security'?' is-on':'')} onClick={() => setTab('security')}>
<span className="settings__nav-icon">⊙</span> Password
</button>
<button className={"settings__nav-item" + (tab==='notifications'?' is-on':'')} onClick={() => setTab('notifications')}>
<span className="settings__nav-icon">◇</span> Notifications
</button>
<button className={"settings__nav-item" + (tab==='workspace'?' is-on':'')} onClick={() => setTab('workspace')}>
<span className="settings__nav-icon">▣</span> Workspace
</button>
<div className="settings__nav-foot">
<button className="settings__logout" onClick={onLogout}>
<Icon.Arrow /> Log out
</button>
</div>
</nav>
<div className="settings__body">
{tab === 'profile' && (
<>
<h3 className="settings__h">Profile picture</h3>
<div className="settings__photo">
<div className="settings__photo-preview" style={photo ? {backgroundImage:`url(${photo})`} : {}}>
{!photo && <Avatar user={{...user, name, initials: (name||'??').split(' ').map(s=>s[0]).join('').slice(0,2).toUpperCase()}} size={88} />}
</div>
<div className="settings__photo-actions">
<input ref={fileInputRef} type="file" accept="image/*" hidden onChange={handleFile} />
<button className="btn btn--soft btn--sm" onClick={() => fileInputRef.current && fileInputRef.current.click()}>
Upload photo
</button>
{photo && (
<button className="btn btn--ghost btn--sm" onClick={() => setPhoto(null)}>
Remove
</button>
)}
<p className="settings__hint mono">PNG or JPG, square crop recommended · max 2MB</p>
</div>
</div>
<h3 className="settings__h">Personal details</h3>
<div className="settings__grid">
<label className="field">
<span className="field__label">Display name</span>
<input className="field__input" value={name} onChange={e => setName(e.target.value)} />
</label>
<label className="field">
<span className="field__label">Role / title {!isAdmin && <span className="mono lock">— admin only</span>}</span>
<input className="field__input" value={role} onChange={e => setRole(e.target.value)} disabled={!isAdmin} />
</label>
<label className="field">
<span className="field__label">Email</span>
<input className="field__input" defaultValue={user.id + "@murchison-auto.co"} />
</label>
<label className="field">
<span className="field__label">Phone</span>
<input className="field__input" defaultValue="+64 27 555 0184" />
</label>
</div>
<div className="settings__save-row">
{saved && <span className="settings__saved mono"><Icon.Check /> Saved</span>}
<button className="btn btn--ghost" onClick={() => { setName(user.name); setRole(user.role); setPhoto(user.photo||null); }} disabled={!dirty}>Discard</button>
<button className="btn btn--primary" onClick={save} disabled={!dirty}>Save changes</button>
</div>
</>
)}
{tab === 'security' && (
<>
<h3 className="settings__h">Change password</h3>
<div className="settings__col">
<label className="field">
<span className="field__label">Current password</span>
<input className="field__input" type="password" value={pwOld} onChange={e => setPwOld(e.target.value)} placeholder="••••••••" />
</label>
<label className="field">
<span className="field__label">New password</span>
<input className="field__input" type="password" value={pwNew} onChange={e => setPwNew(e.target.value)} />
</label>
<label className="field">
<span className="field__label">Confirm new password</span>
<input className="field__input" type="password" value={pwConfirm} onChange={e => setPwConfirm(e.target.value)} />
</label>
<div className="settings__pw-strength">
<span className="settings__pw-bar" data-strength={pwNew.length > 11 ? 'strong' : pwNew.length > 7 ? 'med' : pwNew.length > 0 ? 'weak' : 'none'} />
<span className="mono settings__pw-label">
{pwNew.length === 0 ? 'enter a new password' : pwNew.length > 11 ? 'strong' : pwNew.length > 7 ? 'medium add a symbol or number' : 'weak needs more characters'}
</span>
</div>
</div>
{pwError && <div className="settings__pw-error mono" style={{ color: 'var(--prio-high-dot)', marginTop: '0.5rem' }}>{pwError}</div>}
<div className="settings__save-row">
{pwSaved && <span className="settings__saved mono"><Icon.Check /> Password updated</span>}
<button className="btn btn--ghost" onClick={() => { setPwOld(''); setPwNew(''); setPwConfirm(''); setPwError(''); }}>Cancel</button>
<button className="btn btn--primary" disabled={!pwOld || !pwNew || pwNew !== pwConfirm || pwBusy} onClick={submitPasswordChange}>{pwBusy ? 'Updating' : 'Update password'}</button>
</div>
<div className="settings__divider" />
<h3 className="settings__h">Sign-in &amp; sessions</h3>
<ul className="settings__sessions">
<li className="settings__session">
<div>
<div className="settings__session-where">MacBook · Chrome · this device</div>
<div className="settings__session-when mono">Active now · Christchurch, NZ</div>
</div>
<span className="chip">current</span>
</li>
<li className="settings__session">
<div>
<div className="settings__session-where">iPhone · Safari</div>
<div className="settings__session-when mono">Last active 2h ago</div>
</div>
<button className="btn btn--ghost btn--sm">Sign out</button>
</li>
</ul>
</>
)}
{tab === 'notifications' && (
<>
<h3 className="settings__h">When should we ping you?</h3>
<ul className="settings__toggles">
<ToggleRow label="A new task is assigned to me" defaultOn />
<ToggleRow label="Someone @mentions me in a note" defaultOn />
<ToggleRow label="A task I created is completed" />
<ToggleRow label="Daily digest at 8am" defaultOn />
<ToggleRow label="Heads-up alerts (auto-flagged tasks)" defaultOn />
</ul>
<h3 className="settings__h">Channels</h3>
<ul className="settings__toggles">
<ToggleRow label="In-app heads-up" defaultOn />
<ToggleRow label="Push to phone (Dashy app)" defaultOn />
<ToggleRow label="Email" />
<ToggleRow label="iMessage relay" />
</ul>
</>
)}
{tab === 'workspace' && (
<WorkspaceTab
user={user} isAdmin={isAdmin} dbUsers={dbUsers}
onSwitchUser={onSwitchUser}
onCreateUser={onCreateUser}
onDeleteUser={onDeleteUser}
onUpdateUserRole={onUpdateUserRole}
workspace={workspace}
onUpdateWorkspace={onUpdateWorkspace}
/>
)}
</div>
</div>
</Modal>
);
}
function ToggleRow({ label, defaultOn = false }) {
const [on, setOn] = React.useState(defaultOn);
return (
<li className="toggle-row">
<span>{label}</span>
<button className={"switch" + (on ? " is-on" : "")} onClick={() => setOn(v => !v)} aria-pressed={on}>
<span className="switch__thumb" />
</button>
</li>
);
}
function WorkspaceTab({ user, isAdmin, dbUsers = [], onSwitchUser, onCreateUser, onDeleteUser, onUpdateUserRole, workspace, onUpdateWorkspace }) {
const [adding, setAdding] = React.useState(false);
const [newName, setNewName] = React.useState('');
const [newRole, setNewRole] = React.useState('');
const [newType, setNewType] = React.useState('standard');
const [wsName, setWsName] = React.useState(workspace ? workspace.name : '');
const [wsTz, setWsTz] = React.useState(workspace ? workspace.timezone : '');
const [wsSaved, setWsSaved] = React.useState(false);
React.useEffect(() => {
if (workspace) {
setWsName(workspace.name);
setWsTz(workspace.timezone);
}
}, [workspace]);
const submit = () => {
if (!newName.trim()) return;
onCreateUser({ name: newName.trim(), role: newRole.trim() || 'Team member', account_type: newType });
setNewName(''); setNewRole(''); setNewType('standard'); setAdding(false);
};
const handleUpdateWorkspace = async () => {
await onUpdateWorkspace({ name: wsName, timezone: wsTz });
setWsSaved(true);
setTimeout(() => setWsSaved(false), 2000);
};
const wsDirty = workspace && (wsName !== workspace.name || wsTz !== workspace.timezone);
return (
<>
<h3 className="settings__h">Switch user</h3>
<p className="settings__sub">Test other accounts without signing out.</p>
<div className="settings__users">
{dbUsers.map(u => (
<button key={u.id} className={"login__user" + (u.id===user.id?" is-picked":"")} onClick={() => onSwitchUser(u.id)}>
<Avatar user={u} size={36} />
<div className="login__user-meta">
<span className="login__user-name">
{u.name}
{u.account_type === 'admin' && <span className="admin-badge">●</span>}
</span>
<span className="login__user-role">{u.account_type === 'admin' ? 'Admin · ' : ''}{u.role}</span>
</div>
</button>
))}
</div>
<div className="settings__divider" />
<div className="settings__h-row">
<h3 className="settings__h">Team members {!isAdmin && <span className="mono lock">— admin only</span>}</h3>
{isAdmin && !adding && (
<button className="btn btn--soft btn--sm" onClick={() => setAdding(true)}>+ Add user</button>
)}
</div>
{isAdmin && adding && (
<div className="member-form">
<div className="settings__grid">
<label className="field">
<span className="field__label">Name</span>
<input className="field__input" value={newName} onChange={e => setNewName(e.target.value)} autoFocus placeholder="Jamie Smith" />
</label>
<label className="field">
<span className="field__label">Role / title</span>
<input className="field__input" value={newRole} onChange={e => setNewRole(e.target.value)} placeholder="Apprentice" />
</label>
</div>
<div className="field">
<span className="field__label">Account type</span>
<div className="picker">
<button type="button" className={"picker__item"+(newType==='standard'?' is-on':'')} onClick={() => setNewType('standard')}>Standard</button>
<button type="button" className={"picker__item"+(newType==='admin'?' is-on':'')} onClick={() => setNewType('admin')}>Admin</button>
</div>
<span className="settings__hint mono">
{newType === 'admin'
? 'Can see audit log, manage users, edit roles & workspace'
: 'Can manage own tasks; cannot see logs or DB'}
</span>
</div>
<div className="settings__save-row">
<button className="btn btn--ghost" onClick={() => setAdding(false)}>Cancel</button>
<button className="btn btn--primary" onClick={submit} disabled={!newName.trim()}>Add user</button>
</div>
</div>
)}
<ul className="member-list">
{dbUsers.map(u => (
<li key={u.id} className="member-row">
<Avatar user={u} size={32} />
<div className="member-row__meta">
<div className="member-row__name">{u.name}</div>
<div className="member-row__role mono">{u.role} · {u.email || (u.id + '@murchison-auto.co')}</div>
</div>
{isAdmin ? (
<div className="picker member-row__type">
<button className={"picker__item"+(u.account_type==='standard'?' is-on':'')}
onClick={() => onUpdateUserRole(u.id, { account_type: 'standard' })}>Standard</button>
<button className={"picker__item"+(u.account_type==='admin'?' is-on':'')}
onClick={() => onUpdateUserRole(u.id, { account_type: 'admin' })}>Admin</button>
</div>
) : (
<span className="chip">{u.account_type}</span>
)}
{isAdmin && u.id !== user.id && (
<button className="member-row__del" onClick={() => {
if (confirm('Remove ' + u.name + '? Their tasks will move to ROD.')) onDeleteUser(u.id);
}} title="Remove user">×</button>
)}
</li>
))}
</ul>
<div className="settings__divider" />
<h3 className="settings__h">Workspace {!isAdmin && <span className="mono lock">— admin only</span>}</h3>
<div className="settings__grid">
<label className="field">
<span className="field__label">Workspace name</span>
<input className="field__input" value={wsName} onChange={e => setWsName(e.target.value)} disabled={!isAdmin} />
</label>
<label className="field">
<span className="field__label">Timezone</span>
<input className="field__input" value={wsTz} onChange={e => setWsTz(e.target.value)} disabled={!isAdmin} />
</label>
</div>
{isAdmin && (
<div className="settings__save-row" style={{ marginTop: '1rem' }}>
{wsSaved && <span className="settings__saved mono"><Icon.Check /> Saved</span>}
<button className="btn btn--ghost" onClick={() => { setWsName(workspace.name); setWsTz(workspace.timezone); }} disabled={!wsDirty}>Discard</button>
<button className="btn btn--primary" onClick={handleUpdateWorkspace} disabled={!wsDirty}>Update workspace</button>
</div>
)}
</>
);
}
function DeletedScreen({ tasks, onRestore }) {
return (
<div className="audit">
<header className="audit__head">
<div>
<h1 className="audit__title">Trash</h1>
<p className="audit__sub">Recently deleted tasks · <span className="mono">Admins only</span></p>
</div>
</header>
<div className="audit__list">
{tasks.length === 0 ? (
<div className="column__empty" style={{ marginTop: '4rem' }}>
<span className="mono">— trash is empty —</span>
</div>
) : (
<div className="user-view__grid" style={{ padding: '0 2rem' }}>
{tasks.map(t => (
<div key={t.id} style={{ position: 'relative' }}>
<TaskCard task={t} />
<button
className="btn btn--primary btn--sm"
style={{ position: 'absolute', top: '1rem', right: '1rem', zIndex: 10 }}
onClick={() => onRestore(t.id)}
>
Restore
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}
Object.assign(window, {
LoginScreen, TopBar, OverviewScreen, UserScreen, AddTaskModal, Modal, TaskDetail, AuditScreen, HeadsUp, BrandMark,
SettingsScreen,
DeletedScreen,
});