From abb6402f86ccd463fa3f4499a0681e5f7a9fa1b5 Mon Sep 17 00:00:00 2001 From: NPS Agent Date: Wed, 13 May 2026 09:55:19 +0930 Subject: [PATCH] Added esc functionality to open windows, added 'n' keybind to create new tasks, fixed Accounts and settings page to allow for edits to be made as well as profile picture to be updated --- PROGRESS.md | 5 ++++- api.js | 13 +++++++++++ app.jsx | 40 ++++++++++++++++++++++++++++++--- backend/main.py | 50 ++++++++++++++++++++++++++++------------- backend/schemas.py | 2 ++ dashy.db | Bin 86016 -> 98304 bytes screens.jsx | 55 ++++++++++++++++++++++++++++++++++++--------- 7 files changed, 135 insertions(+), 30 deletions(-) diff --git a/PROGRESS.md b/PROGRESS.md index 5969834..ce46470 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -66,6 +66,9 @@ 22. **Persistent Task Reordering:** Implemented drag-and-drop reordering within and between columns. - **Backend:** Added a `position` (Float) column to the `tasks` table and updated API endpoints to support position updates and sorted fetching. - **UI:** Enhanced the Kanban board with a "drop-between-cards" detection logic and a visual blue drop indicator. Positions are persisted to the database instantly on drop. +23. **Keyboard Shortcuts:** Added global support for keyboard navigation: + - **`Escape`**: Instantly close any open modal (Task Detail, Add Task, Settings, or Audit Logs). + - **`n`**: Open the "Add Task" modal from the main dashboard (disabled while typing in inputs). ### Phase 3: Advanced Features - **Real-time Notifications:** Explore WebSockets for task assignments. @@ -74,5 +77,5 @@ --- -**Last Updated:** Monday, May 11, 2026 +**Last Updated:** Wednesday, May 13, 2026 **Status:** Phase 2 Complete / Ready for Phase 3 diff --git a/api.js b/api.js index 67850ce..1f2a97b 100644 --- a/api.js +++ b/api.js @@ -168,6 +168,19 @@ class ApiService { return data; } + async getTaskNotes(taskId) { + return this.request(`/tasks/${taskId}/notes`); + } + + async createTaskNote(taskId, body) { + const data = await this.request(`/tasks/${taskId}/notes`, { + method: 'POST', + body: JSON.stringify({ body }), + }); + this.notify(); + return data; + } + async addAudit(auditData) { const data = await this.request('/audit', { method: 'POST', diff --git a/app.jsx b/app.jsx index 6067ec9..8d9a169 100644 --- a/app.jsx +++ b/app.jsx @@ -104,6 +104,25 @@ function App() { React.useEffect(() => { window.dbUsers = dbUsers; }, [dbUsers]); + + React.useEffect(() => { + const handleKeyDown = (e) => { + const isTyping = ['INPUT', 'TEXTAREA'].includes(e.target.tagName) || e.target.isContentEditable; + + if (e.key === 'Escape') { + setAdding(null); + setOpenTaskId(null); + setShowLogs(false); + setShowSettings(false); + } else if (e.key.toLowerCase() === 'n' && !isTyping) { + e.preventDefault(); + setAdding(meId); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [meId]); + const [adding, setAdding] = React.useState(null); const [openTaskId, setOpenTaskId] = React.useState(null); const [showLogs, setShowLogs] = React.useState(false); @@ -243,6 +262,16 @@ function App() { } }; + const addNote = async (taskId, body) => { + try { + await api.createTaskNote(taskId, body); + await api.addAudit({ actor: meId, action: 'note_added', summary: 'Added a note to the task', target: taskId }); + } catch(e) { + console.error(e); + alert("Failed to add note"); + } + }; + const dismissHU = (id) => setHeadsUp(h => h.filter(x => x.id !== id)); const openTaskFromAnywhere = (id) => { setOpenTaskId(id); setShowLogs(false); }; @@ -302,7 +331,7 @@ function App() { setAdding(null)} onSubmit={addTask} defaultAssignee={adding} me={me} dbUsers={dbUsers} /> {mappedOpenTask && ( - setOpenTaskId(null)} onMove={moveTask} onPriority={setPriority} onComplete={() => completeTask(mappedOpenTask.id)} onReopen={() => reopenTask(mappedOpenTask.id)} onEditDesc={(newDesc) => editTaskDesc(mappedOpenTask.id, newDesc)} onDeleteTask={() => deleteTask(mappedOpenTask.id)} /> + setOpenTaskId(null)} onMove={moveTask} onPriority={setPriority} onComplete={() => completeTask(mappedOpenTask.id)} onReopen={() => reopenTask(mappedOpenTask.id)} onEditDesc={(newDesc) => editTaskDesc(mappedOpenTask.id, newDesc)} onDeleteTask={() => deleteTask(mappedOpenTask.id)} onAddNote={(body) => addNote(mappedOpenTask.id, body)} /> )} {showLogs && ( setShowLogs(false)} wide> @@ -316,8 +345,13 @@ function App() { isAdmin={isAdmin} onClose={() => setShowSettings(false)} onSave={async (edits) => { - // Not implemented on backend yet for user updating, mock success - setShowSettings(false); + try { + await api.updateUser(meId, edits); + setShowSettings(false); + } catch (e) { + console.error(e); + alert("Failed to save changes: " + e.message); + } }} onLogout={() => { api.logout(); diff --git a/backend/main.py b/backend/main.py index 8f2cfcb..fac61fd 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,7 @@ from fastapi import FastAPI, Depends, HTTPException, status from fastapi.middleware.cors import CORSMiddleware from sqlalchemy.orm import Session +from sqlalchemy.sql import func from typing import List import uuid @@ -50,9 +51,8 @@ def create_user(user: schemas.UserCreate, db: Session = Depends(get_db), current initials=user.initials, email=user.email, phone=user.phone, - photo=user.photo, - account_type=user.account_type, - password_hash=auth.get_password_hash(user.password) + password_hash=auth.get_password_hash(user.password) if user.password else None, + account_type=user.account_type ) db.add(db_user) db.commit() @@ -61,6 +61,9 @@ def create_user(user: schemas.UserCreate, db: Session = Depends(get_db), current @app.patch("/users/{user_id}", response_model=schemas.User) def update_user(user_id: str, user_update: schemas.UserUpdate, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): + if current_user.account_type != "admin" and current_user.id != user_id: + raise HTTPException(status_code=403, detail="Not enough permissions") + db_user = db.query(models.User).filter(models.User.id == user_id).first() if not db_user: raise HTTPException(status_code=404, detail="User not found") @@ -74,25 +77,26 @@ def update_user(user_id: str, user_update: schemas.UserUpdate, db: Session = Dep return db_user @app.post("/users/{user_id}/password") -def change_password(user_id: str, payload: schemas.PasswordChange, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): - db_user = db.query(models.User).filter(models.User.id == user_id).first() - if not db_user: - raise HTTPException(status_code=404, detail="User not found") - if not auth.verify_password(payload.old_password, db_user.password_hash): - raise HTTPException(status_code=401, detail="Current password is incorrect") - db_user.password_hash = auth.get_password_hash(payload.new_password) +def change_password(user_id: str, pwd_data: schemas.PasswordChange, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): + if current_user.id != user_id: + raise HTTPException(status_code=403, detail="Cannot change another user's password") + + if not auth.verify_password(pwd_data.old_password, current_user.password_hash): + raise HTTPException(status_code=400, detail="Incorrect current password") + + current_user.password_hash = auth.get_password_hash(pwd_data.new_password) db.commit() - return {"message": "Password updated"} + return {"message": "Password updated successfully"} @app.delete("/users/{user_id}") def delete_user(user_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): + if current_user.account_type != "admin": + raise HTTPException(status_code=403, detail="Not enough permissions") + db_user = db.query(models.User).filter(models.User.id == user_id).first() if not db_user: raise HTTPException(status_code=404, detail="User not found") - # Reassign tasks to rod - db.query(models.Task).filter(models.Task.assignee_id == user_id).update({"assignee_id": "rod"}) - db.delete(db_user) db.commit() return {"message": "User deleted"} @@ -160,11 +164,26 @@ def delete_task(task_id: str, db: Session = Depends(get_db), current_user: model if not db_task: raise HTTPException(status_code=404, detail="Task not found") - from sqlalchemy.sql import func db_task.deleted_at = func.now() db.commit() return {"message": "Task moved to trash"} +@app.get("/tasks/{task_id}/notes", response_model=List[schemas.TaskNote]) +def read_task_notes(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): + return db.query(models.TaskNote).filter(models.TaskNote.task_id == task_id).order_by(models.TaskNote.created_at.desc()).all() + +@app.post("/tasks/{task_id}/notes", response_model=schemas.TaskNote) +def create_task_note(task_id: str, note: schemas.TaskNoteBase, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): + db_note = models.TaskNote( + task_id=task_id, + author_id=current_user.id, + body=note.body + ) + db.add(db_note) + db.commit() + db.refresh(db_note) + return db_note + @app.post("/tasks/{task_id}/restore", response_model=schemas.Task) def restore_task(task_id: str, db: Session = Depends(get_db), current_user: models.User = Depends(auth.get_current_user)): if current_user.account_type != "admin": @@ -178,6 +197,7 @@ def restore_task(task_id: str, db: Session = Depends(get_db), current_user: mode db.commit() db.refresh(db_task) return db_task + @app.get("/workspace", response_model=schemas.Workspace) def read_workspace(db: Session = Depends(get_db)): ws = db.query(models.Workspace).first() diff --git a/backend/schemas.py b/backend/schemas.py index db2058e..c28f98a 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -25,6 +25,8 @@ class UserUpdate(BaseModel): role: Optional[str] = None account_type: Optional[str] = None photo: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None class PasswordChange(BaseModel): old_password: str diff --git a/dashy.db b/dashy.db index 8072a83ea87b5c98f2a4779fd0b6ac5c6aa73597..7123f479358c3aff1d2fa2237820b0b5eb36f18d 100644 GIT binary patch delta 8013 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 diff --git a/screens.jsx b/screens.jsx index 0a1a65c..457b3fe 100644 --- a/screens.jsx +++ b/screens.jsx @@ -608,9 +608,10 @@ function Modal({ children, onClose, title, eyebrow, wide = false }) { ); } -function TaskDetail({ task, allAudit = [], onClose, onMove, onPriority, onComplete, onReopen, onEditDesc, onDeleteTask }) { +function TaskDetail({ task, allAudit = [], onClose, onMove, onPriority, onComplete, onReopen, onEditDesc, onDeleteTask, onAddNote }) { const [editingDesc, setEditingDesc] = React.useState(false); const [descValue, setDescValue] = React.useState(task ? task.description || '' : ''); + const [noteValue, setNoteValue] = React.useState(''); React.useEffect(() => { setDescValue(task ? task.description || '' : ''); @@ -623,6 +624,13 @@ function TaskDetail({ task, allAudit = [], onClose, onMove, onPriority, onComple if (audit.length === 0) { audit.push({ at: task.addedAt, actor: task.addedBy, action: 'task_created', summary: '' }); } + + const handleAddNote = async () => { + if (!noteValue.trim()) return; + await onAddNote(noteValue); + setNoteValue(''); + }; + return (
@@ -671,13 +679,32 @@ function TaskDetail({ task, allAudit = [], onClose, onMove, onPriority, onComple
-

Notes & reminders

+

Notes

    - {/* Dynamic notes will go here */} + {task.notes && task.notes.map(note => { + const noteAuthor = findUser(note.author_id); + return ( +
  • + +
    +
    {note.body}
    +
    + {noteAuthor ? noteAuthor.name : 'Unknown'} · {fmtDateTime(note.created_at)} +
    +
    +
  • + ); + })}
- - + setNoteValue(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleAddNote(); }} + /> +
@@ -921,6 +948,8 @@ function FilterChip({ on, onClick, children }) { 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 [email, setEmail] = React.useState(user.email || ''); + const [phone, setPhone] = React.useState(user.phone || ''); const [photo, setPhoto] = React.useState(user.photo || null); const [tab, setTab] = React.useState('profile'); const [pwOld, setPwOld] = React.useState(''); @@ -948,7 +977,11 @@ function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onS }; React.useEffect(() => { - setName(user.name); setRole(user.role); setPhoto(user.photo || null); + setName(user.name); + setRole(user.role); + setEmail(user.email || ''); + setPhone(user.phone || ''); + setPhoto(user.photo || null); }, [user.id]); const fileInputRef = React.useRef(null); @@ -961,12 +994,12 @@ function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onS }; const save = () => { - onSave({ name, role, photo }); + onSave({ name, role, photo, email, phone }); setSaved(true); setTimeout(() => setSaved(false), 1600); }; - const dirty = name !== user.name || role !== user.role || photo !== (user.photo || null); + const dirty = name !== user.name || role !== user.role || photo !== (user.photo || null) || email !== (user.email || '') || phone !== (user.phone || ''); return ( @@ -1025,17 +1058,17 @@ function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onS
{saved && Saved} - +