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
This commit is contained in:
+4
-1
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<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)} />
|
||||
<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)} onAddNote={(body) => addNote(mappedOpenTask.id, body)} />
|
||||
)}
|
||||
{showLogs && (
|
||||
<Modal title="Audit log" onClose={() => 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();
|
||||
|
||||
+35
-15
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
+44
-11
@@ -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 (
|
||||
<Modal onClose={onClose} wide title={task.title} eyebrow={(task.tags && task.tags.join(' · ')) || 'Task'}>
|
||||
<div className="detail">
|
||||
@@ -671,13 +679,32 @@ function TaskDetail({ task, allAudit = [], onClose, onMove, onPriority, onComple
|
||||
</div>
|
||||
|
||||
<div className="detail__notes">
|
||||
<h3 className="detail__h">Notes & reminders</h3>
|
||||
<h3 className="detail__h">Notes</h3>
|
||||
<ul className="notes">
|
||||
{/* Dynamic notes will go here */}
|
||||
{task.notes && task.notes.map(note => {
|
||||
const noteAuthor = findUser(note.author_id);
|
||||
return (
|
||||
<li key={note.id} className="notes__item">
|
||||
<span className="notes__bullet"><Icon.Pin /></span>
|
||||
<div>
|
||||
<div>{note.body}</div>
|
||||
<div className="notes__meta mono">
|
||||
{noteAuthor ? noteAuthor.name : 'Unknown'} · {fmtDateTime(note.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="notes__add">
|
||||
<input className="field__input" placeholder="Add a note or @mention…" />
|
||||
<button className="btn btn--soft btn--sm">Add</button>
|
||||
<input
|
||||
className="field__input"
|
||||
placeholder="Add a note…"
|
||||
value={noteValue}
|
||||
onChange={e => setNoteValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddNote(); }}
|
||||
/>
|
||||
<button className="btn btn--soft btn--sm" onClick={handleAddNote}>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<Modal onClose={onClose} title="Account & settings" eyebrow={"Signed in as " + user.name} wide>
|
||||
@@ -1025,17 +1058,17 @@ function SettingsScreen({ user, dbUsers, isAdmin, onClose, onSave, onLogout, onS
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field__label">Email</span>
|
||||
<input className="field__input" defaultValue={user.id + "@murchison-auto.co"} />
|
||||
<input className="field__input" value={email} onChange={e => setEmail(e.target.value)} placeholder="yourname@example.com" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field__label">Phone</span>
|
||||
<input className="field__input" defaultValue="+64 27 555 0184" />
|
||||
<input className="field__input" value={phone} onChange={e => setPhone(e.target.value)} placeholder="+64 ..." />
|
||||
</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--ghost" onClick={() => { setName(user.name); setRole(user.role); setPhoto(user.photo||null); setEmail(user.email||''); setPhone(user.phone||''); }} disabled={!dirty}>Discard</button>
|
||||
<button className="btn btn--primary" onClick={save} disabled={!dirty}>Save changes</button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user