1030 lines
42 KiB
React
1030 lines
42 KiB
React
// Screens for Dashy
|
||
|
||
function LoginScreen({ onLogin }) {
|
||
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">murchison-auto</span></p>
|
||
|
||
<div className="login__users">
|
||
{USERS.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, isAdmin, tab, setTab, onAdd, onLogs, onLogout, onProfile, onDB }) {
|
||
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">murchison-auto</span>
|
||
</div>
|
||
|
||
<nav className="tabs" role="tablist">
|
||
<Tab id="overview" label="Overview" tab={tab} setTab={setTab} />
|
||
{USERS.map(u => (
|
||
<Tab key={u.id} id={u.id} label={u.name} tab={tab} setTab={setTab} user={u} />
|
||
))}
|
||
</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 }) {
|
||
const byUser = Object.fromEntries(USERS.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">
|
||
{USERS.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 }) {
|
||
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">
|
||
{USERS.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 & 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">
|
||
{USERS.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>
|
||
{USERS.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 }) {
|
||
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 & 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}
|
||
/>
|
||
)}
|
||
</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 }) {
|
||
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('murchison-auto');
|
||
const [wsTz, setWsTz] = React.useState('Pacific/Auckland');
|
||
|
||
const submit = () => {
|
||
if (!newName.trim()) return;
|
||
onCreateUser({ name: newName.trim(), role: newRole.trim() || 'Team member', account_type: newType });
|
||
setNewName(''); setNewRole(''); setNewType('standard'); setAdding(false);
|
||
};
|
||
|
||
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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
Object.assign(window, {
|
||
LoginScreen, TopBar, OverviewScreen, UserScreen, AddTaskModal, Modal, TaskDetail, AuditScreen, HeadsUp, BrandMark,
|
||
SettingsScreen,
|
||
});
|