Files
plumbing-dashy/app.jsx
T

425 lines
15 KiB
React

// Dashy — main app (API-backed)
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "light",
"accent": "#2A6FDB",
"density": "cozy",
"showTags": true
}/*EDITMODE-END*/;
const ACCENTS = ['#2A6FDB', '#1F8A5B', '#D97757', '#7A5AF8'];
function useApiData(authed) {
const [data, setData] = React.useState({ tasks: [], users: [], audit: [], workspace: null, deletedTasks: [] });
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
let mounted = true;
const load = async () => {
try {
if (!authed) {
// Fetch only public data (workspace info and user list for login)
const [users, workspace] = await Promise.all([
api.getUsers().catch(() => []),
api.getWorkspace().catch(() => null)
]);
if (mounted) {
setData(prev => ({ ...prev, users, workspace }));
setLoading(false);
}
return;
}
const [tasks, users, audit, workspace, deletedTasks] = await Promise.all([
api.getTasks(),
api.getUsers(),
api.getAudit(),
api.getWorkspace(),
api.getDeletedTasks().catch(() => [])
]);
if (mounted) {
setData({ tasks, users, audit, workspace, deletedTasks });
setLoading(false);
}
} catch (e) {
console.error("Failed to load data:", e);
}
};
load();
const off = api.subscribe(load);
return () => { mounted = false; off(); };
}, [authed]);
return { ...data, loading };
}
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const [authed, setAuthed] = React.useState(!!api.token);
// Optional: Decode token to get meId if needed, but for prototype we just assume rod if not known yet,
// or decode JWT using a simple base64 parse.
const [meId, setMeId] = React.useState(() => {
if (api.token) {
try {
return JSON.parse(atob(api.token.split('.')[1])).sub;
} catch(e) {}
}
return 'rod';
});
const { tasks, users: dbUsers, audit, workspace, deletedTasks, loading } = useApiData(authed);
const [tab, setTab] = React.useState('overview');
const [searchQuery, setSearchQuery] = React.useState('');
const [showSearch, setShowSearch] = React.useState(false);
// Map API fields to frontend component expectations
const frontendTasks = React.useMemo(() => tasks.map(t => ({
...t,
assignee: t.assignee_id,
addedBy: t.added_by,
addedAt: t.added_at,
tags: t.tags.map(tagObj => tagObj.tag)
})), [tasks]);
const filteredTasks = React.useMemo(() => {
if (!searchQuery.trim()) return frontendTasks;
const q = searchQuery.toLowerCase();
return frontendTasks.filter(t =>
t.title.toLowerCase().includes(q) ||
(t.description && t.description.toLowerCase().includes(q)) ||
t.tags.some(tag => tag.toLowerCase().includes(q))
);
}, [frontendTasks, searchQuery]);
const frontendAudit = React.useMemo(() => audit.map(a => ({
...a,
actor: a.actor,
action: a.action,
summary: a.summary,
target: a.target,
at: a.at
})), [audit]);
React.useEffect(() => {
window.dbUsers = dbUsers;
}, [dbUsers]);
const [adding, setAdding] = React.useState(null);
const [openTaskId, setOpenTaskId] = React.useState(null);
const [showLogs, setShowLogs] = React.useState(false);
const [showSettings, setShowSettings] = React.useState(false);
const [headsUp, setHeadsUp] = React.useState([
{ id: 'h1', kind: 'unsuccessful', taskId: 't7',
title: 'WO #2188 auto-marked Unsuccessful',
sub: 'Two missed bookings — assigned to Kirra for review' },
{ id: 'h2', kind: 'billing', taskId: 't4',
title: 'Form response switched to Billing',
sub: 'K. Wynne · originally Service Booking' },
]);
if (!authed) {
return <LoginScreen dbUsers={dbUsers} workspace={workspace} onLogin={async (id, pwd) => {
const data = await api.login(id, pwd);
// Extract actual User ID from token payload
try {
const payload = JSON.parse(atob(data.access_token.split('.')[1]));
setMeId(payload.sub);
} catch(e) {
setMeId(id);
}
setAuthed(true);
api.addAudit({ actor: id, action: 'login', summary: 'Signed in' }).catch(console.error);
}} />;
}
if (loading) return <BootSplash />;
const userMap = Object.fromEntries(dbUsers.map(u => [u.id, u]));
const merge = (id) => {
const base = findUser(id);
if (!base && !userMap[id]) return null;
const live = userMap[id] || {};
if (!base) {
return { id, name: live.name, role: live.role, hue: live.hue, initials: live.initials,
photo: live.photo || null, account_type: live.account_type || 'standard' };
}
return { ...base, name: live.name || base.name, role: live.role || base.role,
photo: live.photo || null, account_type: live.account_type || 'standard' };
};
const me = merge(meId) || merge('rod');
const isAdmin = me && me.account_type === 'admin';
const openTask = tasks.find(x => x.id === openTaskId);
const addTask = async ({ title, description, assignee, priority }) => {
try {
const t = await api.createTask({
title, description, assignee_id: assignee, priority,
added_by: meId, source: 'manual', status: 'open', tags: []
});
await api.addAudit({
actor: meId, action: 'task_created',
summary: 'Created task "' + title + '" for ' + (merge(assignee)||{}).name,
target: t.id
});
setAdding(null);
} catch(e) {
console.error(e);
alert("Failed to create task");
}
};
const moveTask = async (taskId, toUserId) => {
try {
await api.updateTask(taskId, { assignee_id: toUserId });
await api.addAudit({ actor: meId, action: 'task_moved', summary: 'Moved task to ' + (merge(toUserId)||{}).name, target: taskId });
} catch(e) {}
};
const setPriority = async (taskId, p) => {
try {
await api.updateTask(taskId, { priority: p });
} catch(e) {}
};
const completeTask = async (taskId) => {
try {
await api.updateTask(taskId, { status: 'closed' });
await api.addAudit({ actor: meId, action: 'task_completed', summary: 'Marked task as completed', target: taskId });
setOpenTaskId(null);
} catch(e) {
console.error(e);
alert("Failed to complete task: " + e.message);
}
};
const reopenTask = async (taskId) => {
try {
await api.updateTask(taskId, { status: 'open' });
await api.addAudit({ actor: meId, action: 'task_reopened', summary: 'Reopened task', target: taskId });
setOpenTaskId(null);
} catch(e) {
console.error(e);
alert("Failed to reopen task: " + e.message);
}
};
const editTaskDesc = async (taskId, newDesc) => {
try {
await api.updateTask(taskId, { description: newDesc });
await api.addAudit({ actor: meId, action: 'task_edited', summary: 'Updated task description', target: taskId });
} catch(e) {
console.error(e);
alert("Failed to update description: " + e.message);
}
};
const deleteTask = async (taskId) => {
try {
const t = tasks.find(x => x.id === taskId);
await api.deleteTask(taskId);
await api.addAudit({ actor: meId, action: 'task_deleted', summary: 'Permanently deleted task' + (t ? ': ' + t.title : ''), target: taskId });
setOpenTaskId(null);
} catch(e) {
console.error(e);
alert("Failed to delete task: " + e.message);
}
};
const restoreTask = async (taskId) => {
try {
await api.restoreTask(taskId);
await api.addAudit({ actor: meId, action: 'task_restored', summary: 'Restored task from trash', target: taskId });
if (tab === 'deleted') setTab('overview');
} catch(e) {
console.error(e);
alert("Failed to restore task: " + e.message);
}
};
const dismissHU = (id) => setHeadsUp(h => h.filter(x => x.id !== id));
const openTaskFromAnywhere = (id) => { setOpenTaskId(id); setShowLogs(false); };
const mappedOpenTask = frontendTasks.find(x => x.id === openTaskId);
return (
<div className="app">
<TopBar
me={me}
dbUsers={dbUsers}
isAdmin={isAdmin}
tab={tab}
setTab={setTab}
onAdd={() => setAdding(meId)}
onLogs={() => setShowLogs(true)}
onProfile={() => setShowSettings(true)}
workspace={workspace}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
showSearch={showSearch}
onToggleSearch={() => { setShowSearch(!showSearch); if (showSearch) setSearchQuery(''); }}
/>
<HeadsUp items={headsUp} onDismiss={dismissHU} onOpenTask={openTaskFromAnywhere} />
<main className="main">
{tab === 'overview' && (
<OverviewScreen
tasks={filteredTasks} density={t.density}
dbUsers={dbUsers}
onOpen={(task) => setOpenTaskId(task.id)}
onAddFor={(uid) => setAdding(uid)}
onMoveTask={moveTask}
/>
)}
{tab === 'deleted' && (
<DeletedScreen
tasks={deletedTasks.map(t => ({
...t,
assignee: t.assignee_id,
addedBy: t.added_by,
addedAt: t.added_at,
tags: t.tags.map(tagObj => tagObj.tag)
}))}
onRestore={restoreTask}
/>
)}
{tab !== 'overview' && tab !== 'deleted' && (
<UserScreen
user={merge(tab)} tasks={filteredTasks} density={t.density}
onOpen={(task) => setOpenTaskId(task.id)}
onAddFor={(uid) => setAdding(uid)}
/>
)}
</main>
<AddTaskModal open={!!adding} onClose={() => setAdding(null)} onSubmit={addTask} defaultAssignee={adding} me={me} dbUsers={dbUsers} />
{mappedOpenTask && (
<TaskDetail task={mappedOpenTask} allAudit={frontendAudit} onClose={() => setOpenTaskId(null)} onMove={moveTask} onPriority={setPriority} onComplete={() => completeTask(mappedOpenTask.id)} onReopen={() => reopenTask(mappedOpenTask.id)} onEditDesc={(newDesc) => editTaskDesc(mappedOpenTask.id, newDesc)} onDeleteTask={() => deleteTask(mappedOpenTask.id)} />
)}
{showLogs && (
<Modal title="Audit log" onClose={() => setShowLogs(false)} wide>
<AuditScreen entries={frontendAudit} onOpen={openTaskFromAnywhere} />
</Modal>
)}
{showSettings && (
<SettingsScreen
user={me}
dbUsers={dbUsers}
isAdmin={isAdmin}
onClose={() => setShowSettings(false)}
onSave={async (edits) => {
// Not implemented on backend yet for user updating, mock success
setShowSettings(false);
}}
onLogout={() => {
api.logout();
setAuthed(false);
setShowSettings(false);
}}
onSwitchUser={async (id) => {
try {
await api.login(id, "password123");
setMeId(id);
setShowSettings(false);
} catch(e) {
alert("Failed to switch user");
}
}}
onCreateUser={async (u) => {
try {
const id = u.name.split(' ')[0].toLowerCase() + Math.floor(Math.random()*100);
await api.createUser({
id,
name: u.name,
role: u.role,
hue: Math.floor(Math.random() * 360),
initials: u.name.split(' ').map(s=>s[0]).join('').slice(0,2).toUpperCase(),
account_type: u.account_type,
password: "password123"
});
await api.addAudit({ actor: meId, action: 'user_created', summary: 'Added ' + u.name + ' (' + (u.account_type||'standard') + ')', target: id });
} catch(e) {
console.error(e);
alert("Failed to create user: " + e.message);
}
}}
onDeleteUser={async (id) => {
try {
const u = userMap[id];
await api.deleteUser(id);
await api.addAudit({ actor: meId, action: 'user_deleted', summary: 'Removed ' + (u?u.name:id), target: null });
} catch(e) {
console.error(e);
alert("Failed to delete user");
}
}}
onUpdateUserRole={async (id, edits) => {
try {
await api.updateUser(id, edits);
await api.addAudit({ actor: meId, action: 'user_updated', summary: 'Updated ' + (userMap[id]?userMap[id].name:id) + ' permissions', target: null });
} catch(e) {
console.error(e);
alert("Failed to update user");
}
}}
onChangePassword={async (oldPwd, newPwd) => {
await api.changePassword(meId, oldPwd, newPwd);
await api.addAudit({ actor: meId, action: 'password_changed', summary: 'Updated password', target: meId });
}}
workspace={workspace}
onUpdateWorkspace={async (edits) => {
try {
await api.updateWorkspace(edits);
await api.addAudit({ actor: meId, action: 'workspace_updated', summary: 'Updated workspace settings', target: null });
} catch(e) {
alert("Failed to update workspace: " + e.message);
}
}}
/>
)}
<DashyTweaks t={t} setTweak={setTweak} />
</div>
);
}
function BootSplash() {
return (
<div className="boot">
<div className="boot__pulse" />
<div className="boot__label mono">loading from API</div>
</div>
);
}
function DashyTweaks({ t, setTweak }) {
return (
<TweaksPanel title="Tweaks">
<TweakSection title="Appearance">
<TweakRadio label="Theme" value={t.theme}
options={[{value:'light',label:'Light'},{value:'dark',label:'Dark'}]}
onChange={v => setTweak('theme', v)} />
<TweakColor label="Accent" value={t.accent} options={ACCENTS} onChange={v => setTweak('accent', v)} />
<TweakRadio label="Density" value={t.density}
options={[{value:'compact',label:'Compact'},{value:'cozy',label:'Cozy'}]}
onChange={v => setTweak('density', v)} />
<TweakToggle label="Show tags on cards" value={t.showTags} onChange={v => setTweak('showTags', v)} />
</TweakSection>
</TweaksPanel>
);
}
function ThemeBridge() {
const [t] = useTweaks(TWEAK_DEFAULTS);
React.useEffect(() => {
document.documentElement.dataset.theme = t.theme;
document.documentElement.style.setProperty('--accent', t.accent);
document.documentElement.dataset.showTags = t.showTags ? 'on' : 'off';
}, [t.theme, t.accent, t.showTags]);
return null;
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<><ThemeBridge /><App /></>);