Initial commit: personal task planner with categories, projects, and tiered assignment
Personal-first lists/tasks/reminders/recurrence, plus an admin-editable status pipeline, shared categories, project containers, and a three-tier permission model for cross-user work: assign (hand off your own tasks), reallocate (dispatcher — move anyone's tasks), admin (reserved, full manage-all).
This commit is contained in:
commit
d8dc4e2b81
54 changed files with 10908 additions and 0 deletions
214
frontend/src/components/TaskDetail.tsx
Normal file
214
frontend/src/components/TaskDetail.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput } from 'lucide-react'
|
||||
import type { TaskDetail as TDetail, Status, Priority, RecurRule } from '../types'
|
||||
import { PRIORITIES, PRIORITY_LABELS, STATUSES, STATUS_LABELS, RECUR_RULES, RECUR_LABELS } from '../types'
|
||||
import {
|
||||
fetchTask, updateTask, deleteTask,
|
||||
addItem, updateItem, deleteItem, addComment,
|
||||
} from '../api'
|
||||
import { useLists } from '../context/ListsContext'
|
||||
import { Check_, formatDateTime, toLocalInput } from './shared'
|
||||
|
||||
export default function TaskDetail({ taskId, onChange, onClose }:
|
||||
{ taskId: number; onChange: () => void; onClose: () => void }) {
|
||||
const { lists } = useLists()
|
||||
const [task, setTask] = useState<TDetail | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
const [newItem, setNewItem] = useState('')
|
||||
const [comment, setComment] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetchTask(taskId).then(t => { setTask(t); setTitle(t.title); setNotes(t.notes ?? '') })
|
||||
.catch(e => setError(e.message))
|
||||
}, [taskId])
|
||||
|
||||
if (error) return (
|
||||
<Shell onClose={onClose}><div className="error-banner">{error}</div></Shell>
|
||||
)
|
||||
if (!task) return <Shell onClose={onClose}><div className="empty-state">Loading…</div></Shell>
|
||||
|
||||
// Patch task fields, merge the result locally, and notify the parent view.
|
||||
async function patch(fields: Record<string, unknown>) {
|
||||
const updated = await updateTask(taskId, fields)
|
||||
setTask(t => t ? { ...t, ...updated } : t)
|
||||
onChange()
|
||||
}
|
||||
|
||||
const toLocalIso = (v: string) => v ? new Date(v).toISOString() : ''
|
||||
|
||||
async function toggleDone() {
|
||||
await patch({ status: task!.status === 'done' ? 'todo' : 'done' })
|
||||
}
|
||||
|
||||
async function addChecklistItem() {
|
||||
if (!newItem.trim()) return
|
||||
const item = await addItem(taskId, newItem.trim())
|
||||
setTask(t => t ? { ...t, items: [...t.items, item] } : t)
|
||||
setNewItem('')
|
||||
onChange()
|
||||
}
|
||||
async function toggleItem(id: number, is_done: boolean) {
|
||||
await updateItem(id, { is_done })
|
||||
setTask(t => t ? { ...t, items: t.items.map(i => i.id === id ? { ...i, is_done } : i) } : t)
|
||||
}
|
||||
async function removeItem(id: number) {
|
||||
await deleteItem(id)
|
||||
setTask(t => t ? { ...t, items: t.items.filter(i => i.id !== id) } : t)
|
||||
}
|
||||
async function postComment() {
|
||||
if (!comment.trim()) return
|
||||
const c = await addComment(taskId, comment.trim())
|
||||
setTask(t => t ? { ...t, comments: [...t.comments, c] } : t)
|
||||
setComment('')
|
||||
}
|
||||
async function removeTask() {
|
||||
if (!confirm('Delete this task?')) return
|
||||
await deleteTask(taskId)
|
||||
onChange()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const doneItems = task.items.filter(i => i.is_done).length
|
||||
|
||||
return (
|
||||
<Shell onClose={onClose} onDelete={removeTask}>
|
||||
{/* Title + completion */}
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', marginBottom: 14 }}>
|
||||
<div style={{ marginTop: 4 }}><Check_ status={task.status} onClick={toggleDone} /></div>
|
||||
<textarea
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
onBlur={() => title.trim() && title !== task.title && patch({ title: title.trim() })}
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1, border: 'none', outline: 'none', background: 'none', resize: 'none',
|
||||
fontSize: 17, fontWeight: 600, fontFamily: 'var(--font)', color: 'var(--text-dark)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status pills */}
|
||||
<div className="chip-bar">
|
||||
{STATUSES.map(s => (
|
||||
<button key={s} className={`chip${task.status === s ? ' active' : ''}`} onClick={() => patch({ status: s })}>
|
||||
{STATUS_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Attributes */}
|
||||
<div className="card" style={{ padding: '12px 14px' }}>
|
||||
<div className="field" style={{ marginBottom: 10 }}>
|
||||
<label><FolderInput size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> List</label>
|
||||
<select value={task.list_id} onChange={e => patch({ list_id: Number(e.target.value) })}>
|
||||
{lists.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Priority</label>
|
||||
<select value={task.priority} onChange={e => patch({ priority: e.target.value as Priority })}>
|
||||
{PRIORITIES.map(p => <option key={p} value={p}>{PRIORITY_LABELS[p]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label><Repeat size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Repeat</label>
|
||||
<select value={task.recur_rule ?? ''} onChange={e => patch({ recur_rule: e.target.value || null })}>
|
||||
<option value="">Doesn't repeat</option>
|
||||
{RECUR_RULES.map(r => <option key={r} value={r}>{RECUR_LABELS[r as RecurRule]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label><CalendarClock size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Due</label>
|
||||
<input type="datetime-local" value={toLocalInput(task.due_at)}
|
||||
onChange={e => patch({ due_at: toLocalIso(e.target.value) })} />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label><Bell size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Remind</label>
|
||||
<input type="datetime-local" value={toLocalInput(task.remind_at)}
|
||||
onChange={e => patch({ remind_at: toLocalIso(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="section-title">Notes</div>
|
||||
<div className="field">
|
||||
<textarea value={notes} placeholder="Add notes…"
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
onBlur={() => notes !== (task.notes ?? '') && patch({ notes })} />
|
||||
</div>
|
||||
|
||||
{/* Checklist */}
|
||||
<div className="section-title">Checklist {task.items.length > 0 && `(${doneItems}/${task.items.length})`}</div>
|
||||
{task.items.length > 0 && (
|
||||
<div className="checklist-progress">
|
||||
<div style={{ width: `${(doneItems / task.items.length) * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{task.items.map(i => (
|
||||
<div key={i.id} className={`checklist-item${i.is_done ? ' done' : ''}`}>
|
||||
<button className={`check${i.is_done ? ' checked' : ''}`} onClick={() => toggleItem(i.id, !i.is_done)} type="button" />
|
||||
<span>{i.label}</span>
|
||||
<button className="checklist-del" onClick={() => removeItem(i.id)}><X size={14} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
))}
|
||||
<div className="task-add" style={{ marginTop: 8 }}>
|
||||
<Plus size={16} strokeWidth={1.75} color="var(--text-mid)" />
|
||||
<input value={newItem} placeholder="Add a sub-task"
|
||||
onChange={e => setNewItem(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') addChecklistItem() }} />
|
||||
</div>
|
||||
|
||||
{/* Comments / activity */}
|
||||
<div className="section-title">Activity</div>
|
||||
<div className="timeline">
|
||||
{task.comments.map(c => (
|
||||
<div key={c.id} className="timeline-item">
|
||||
<div className="timeline-body">
|
||||
<div className="timeline-note">{c.body}</div>
|
||||
<div className="timeline-meta">{c.author_name || 'You'} · {formatDateTime(c.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{task.comments.length === 0 && <div className="muted" style={{ fontSize: 12 }}>No activity yet.</div>}
|
||||
</div>
|
||||
<div className="task-add" style={{ marginTop: 8 }}>
|
||||
<input value={comment} placeholder="Add a note…"
|
||||
onChange={e => setComment(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') postComment() }} />
|
||||
<button className="btn btn-sm btn-primary" onClick={postComment} disabled={!comment.trim()}>Post</button>
|
||||
</div>
|
||||
|
||||
<div className="timeline-meta" style={{ marginTop: 14 }}>
|
||||
Created {formatDateTime(task.created_at)}
|
||||
{task.completed_at && ` · Completed ${formatDateTime(task.completed_at)}`}
|
||||
</div>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
function Shell({ children, onClose, onDelete }:
|
||||
{ children: React.ReactNode; onClose: () => void; onDelete?: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<div className="drawer-overlay" onClick={onClose} />
|
||||
<div className="drawer">
|
||||
<div className="drawer-head">
|
||||
<h2 style={{ visibility: 'hidden' }}>Task</h2>
|
||||
{onDelete && (
|
||||
<button className="btn-ghost-sm btn-ghost-danger" onClick={onDelete}>
|
||||
<Trash2 size={15} strokeWidth={1.75} />
|
||||
</button>
|
||||
)}
|
||||
<button className="modal-close" onClick={onClose}><X size={20} strokeWidth={1.75} /></button>
|
||||
</div>
|
||||
<div className="drawer-body">{children}</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue