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 8072a83..7123f47 100644 Binary files a/dashy.db and b/dashy.db differ 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} - +