Compare commits
No commits in common. "e1c537d3f43b4ec9e3755d84fb9dfc06ace8fb09" and "3145c1fc07de35c2e403ca641c008c5f1ab13d38" have entirely different histories.
e1c537d3f4
...
3145c1fc07
18 changed files with 238 additions and 988 deletions
|
|
@ -50,7 +50,7 @@ export async function initDb() {
|
|||
|
||||
-- A grouping container for tasks that can span multiple owners (once a task
|
||||
-- is assigned elsewhere it keeps its project_id). Progress is inferred from
|
||||
-- child tasks' statuses.
|
||||
-- child tasks' statuses — projects don't get their own status pipeline.
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_by TEXT NOT NULL,
|
||||
|
|
@ -62,35 +62,6 @@ export async function initDb() {
|
|||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Explicit project membership, separate from the task-level tag/assign/
|
||||
-- reallocate tiers. A project is otherwise only visible to its creator and
|
||||
-- anyone who happens to own/be tagged on a task inside it — membership
|
||||
-- lets someone see (viewer) or edit any task in the project (editor)
|
||||
-- without needing to already own something there.
|
||||
CREATE TABLE IF NOT EXISTS project_members (
|
||||
project_id INT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
user_email TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'editor', -- viewer | editor
|
||||
added_by TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (project_id, user_email)
|
||||
);
|
||||
|
||||
-- Optional per-project status pipeline override. Most projects have none
|
||||
-- of these rows and just use the shared global 'statuses' table; a project
|
||||
-- that defines any row here switches entirely to its own pipeline (see the
|
||||
-- task_statuses view below for the resolution logic).
|
||||
CREATE TABLE IF NOT EXISTS project_statuses (
|
||||
id SERIAL PRIMARY KEY,
|
||||
project_id INT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
label TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
is_done BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
is_hold BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
UNIQUE (project_id, value)
|
||||
);
|
||||
|
||||
-- A single to-do. assigned_to is a real ownership transfer (gated behind the
|
||||
-- 'assign' cap) — when set, owner_id is moved to match it and the task is
|
||||
-- unfiled (list_id NULL) since the assignee doesn't share the old owner's
|
||||
|
|
@ -114,7 +85,6 @@ export async function initDb() {
|
|||
reminded_at TIMESTAMPTZ,
|
||||
recur_rule TEXT, -- null | daily | weekdays | weekly | monthly
|
||||
completed_at TIMESTAMPTZ,
|
||||
pinned_today BOOLEAN NOT NULL DEFAULT FALSE, -- forced into the Today agenda regardless of due_at
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
|
|
@ -177,33 +147,6 @@ export async function initDb() {
|
|||
// if the constraint is already gone).
|
||||
await pool.query(`ALTER TABLE tasks ALTER COLUMN list_id DROP NOT NULL`)
|
||||
|
||||
// Safety net for databases created before pinned_today / per-project statuses.
|
||||
await pool.query(`ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pinned_today BOOLEAN NOT NULL DEFAULT FALSE`)
|
||||
// status used to FK straight to the global statuses table. A project can now
|
||||
// define its own status values (project_statuses), which that FK would
|
||||
// reject outright, so validity is checked in application code instead (see
|
||||
// getEffectiveStatus in routes/tasks.js).
|
||||
await pool.query(`ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_status_fkey`)
|
||||
|
||||
// Resolves each task's status against its project's own pipeline if it has
|
||||
// one defined, falling back to the shared global pipeline otherwise (which
|
||||
// is what every task without a project, or in a project with no custom
|
||||
// statuses, always uses).
|
||||
await pool.query(`
|
||||
CREATE OR REPLACE VIEW task_statuses AS
|
||||
SELECT t.id AS task_id,
|
||||
COALESCE(ps.label, s.label) AS status_label,
|
||||
COALESCE(ps.is_done, s.is_done, FALSE) AS is_done,
|
||||
COALESCE(ps.is_hold, s.is_hold, FALSE) AS is_hold,
|
||||
COALESCE(ps.position, s.position) AS status_position
|
||||
FROM tasks t
|
||||
LEFT JOIN project_statuses ps ON ps.project_id = t.project_id AND ps.value = t.status
|
||||
LEFT JOIN statuses s ON s.value = t.status AND (
|
||||
t.project_id IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM project_statuses ps2 WHERE ps2.project_id = t.project_id)
|
||||
)
|
||||
`)
|
||||
|
||||
await seedDefaults()
|
||||
await seedStatuses()
|
||||
}
|
||||
|
|
@ -243,21 +186,6 @@ export async function getStatuses() {
|
|||
return rows
|
||||
}
|
||||
|
||||
// A project's own pipeline if it has defined any custom statuses, else the
|
||||
// shared global one — the same resolution the task_statuses view applies
|
||||
// per-task, exposed here for callers that need the whole ordered list (the
|
||||
// board's columns, a new task's default status).
|
||||
export async function getEffectiveStatuses(projectId) {
|
||||
if (projectId) {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT * FROM project_statuses WHERE project_id = $1 ORDER BY position, id',
|
||||
[projectId]
|
||||
)
|
||||
if (rows.length) return rows
|
||||
}
|
||||
return getStatuses()
|
||||
}
|
||||
|
||||
export async function getConfig() {
|
||||
const { rows } = await pool.query('SELECT key, value FROM config')
|
||||
return Object.fromEntries(rows.map(r => [r.key, r.value]))
|
||||
|
|
|
|||
|
|
@ -2,16 +2,12 @@ import { requireAuth, requireCap } from '../auth.js'
|
|||
import { pool } from '../db.js'
|
||||
|
||||
// A project is visible to whoever created it, plus anyone who owns or is
|
||||
// tagged on at least one task inside it, plus anyone added as a project
|
||||
// member (viewer or editor) — membership exists specifically so someone can
|
||||
// see a project (and everything in it) before they own anything there.
|
||||
// `alias` lets the same clause be reused against different table aliases.
|
||||
// tagged on at least one task inside it — so a project stays visible to
|
||||
// collaborators even though it isn't "theirs". `alias` lets the same clause
|
||||
// be reused against different table aliases in one query.
|
||||
const visibleTask = (alias) => `(${alias}.owner_id = $1 OR ${alias}.assigned_to = $1 OR EXISTS (
|
||||
SELECT 1 FROM task_tags tt WHERE tt.task_id = ${alias}.id AND tt.user_email = $1
|
||||
))`
|
||||
const isMember = (projectAlias) => `EXISTS (
|
||||
SELECT 1 FROM project_members pm WHERE pm.project_id = ${projectAlias}.id AND pm.user_email = $1
|
||||
)`
|
||||
|
||||
async function canManageProject(email, isAdmin, projectId) {
|
||||
if (isAdmin) return true
|
||||
|
|
@ -19,15 +15,6 @@ async function canManageProject(email, isAdmin, projectId) {
|
|||
return p?.created_by === email
|
||||
}
|
||||
|
||||
async function canEditProject(email, isAdmin, projectId) {
|
||||
if (await canManageProject(email, isAdmin, projectId)) return true
|
||||
const { rows: [m] } = await pool.query(
|
||||
"SELECT 1 FROM project_members WHERE project_id = $1 AND user_email = $2 AND role = 'editor'",
|
||||
[projectId, email]
|
||||
)
|
||||
return !!m
|
||||
}
|
||||
|
||||
export async function projectRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
|
|
@ -35,12 +22,12 @@ export async function projectRoutes(app) {
|
|||
const { rows } = await pool.query(
|
||||
`SELECT p.*,
|
||||
COUNT(t.id) AS task_count,
|
||||
COUNT(t.id) FILTER (WHERE ts.is_done) AS done_count
|
||||
COUNT(t.id) FILTER (WHERE s.is_done) AS done_count
|
||||
FROM projects p
|
||||
LEFT JOIN tasks t ON t.project_id = p.id AND ${visibleTask('t')}
|
||||
LEFT JOIN task_statuses ts ON ts.task_id = t.id
|
||||
LEFT JOIN statuses s ON s.value = t.status
|
||||
WHERE p.is_archived = FALSE
|
||||
AND (p.created_by = $1 OR ${isMember('p')} OR EXISTS (
|
||||
AND (p.created_by = $1 OR EXISTS (
|
||||
SELECT 1 FROM tasks t2 WHERE t2.project_id = p.id AND ${visibleTask('t2')}
|
||||
))
|
||||
GROUP BY p.id
|
||||
|
|
@ -53,26 +40,12 @@ export async function projectRoutes(app) {
|
|||
app.get('/api/projects/:id', { preHandler: requireCap('use') }, async (req, reply) => {
|
||||
const { rows: [project] } = await pool.query('SELECT * FROM projects WHERE id = $1', [req.params.id])
|
||||
if (!project) return reply.status(404).send({ error: 'project not found' })
|
||||
if (!(req.user.is_admin || project.created_by === req.user.email)) {
|
||||
const { rows: [visible] } = await pool.query(
|
||||
`SELECT 1 FROM project_members pm WHERE pm.project_id = $2 AND pm.user_email = $1
|
||||
UNION ALL
|
||||
SELECT 1 FROM tasks t2 WHERE t2.project_id = $2 AND ${visibleTask('t2')}
|
||||
LIMIT 1`,
|
||||
[req.user.email, req.params.id]
|
||||
)
|
||||
if (!visible) return reply.status(404).send({ error: 'project not found' })
|
||||
}
|
||||
// Project members (viewer or editor) see every task inside, not just the
|
||||
// ones they'd otherwise own/be tagged on — that's the point of membership.
|
||||
const { rows: tasks } = await pool.query(
|
||||
`SELECT t.*, ts.status_label, ts.is_done, ts.is_hold
|
||||
`SELECT t.*, s.label AS status_label, s.is_done, s.is_hold
|
||||
FROM tasks t
|
||||
JOIN task_statuses ts ON ts.task_id = t.id
|
||||
WHERE t.project_id = $2 AND (${visibleTask('t')} OR EXISTS (
|
||||
SELECT 1 FROM project_members pm WHERE pm.project_id = t.project_id AND pm.user_email = $1
|
||||
))
|
||||
ORDER BY ts.status_position, t.sort_order, t.id`,
|
||||
JOIN statuses s ON s.value = t.status
|
||||
WHERE t.project_id = $2 AND ${visibleTask('t')}
|
||||
ORDER BY s.position, t.sort_order, t.id`,
|
||||
[req.user.email, req.params.id]
|
||||
)
|
||||
return { ...project, tasks }
|
||||
|
|
@ -116,104 +89,4 @@ export async function projectRoutes(app) {
|
|||
if (!rowCount) return reply.status(404).send({ error: 'project not found' })
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// ── Members (viewer/editor) — creator/admin manage, members themselves
|
||||
// just need to be listed so the editor UI can show who's on a project ────
|
||||
app.get('/api/projects/:id/members', { preHandler: requireCap('use') }, async (req, reply) => {
|
||||
const { rows: [project] } = await pool.query('SELECT id FROM projects WHERE id = $1', [req.params.id])
|
||||
if (!project) return reply.status(404).send({ error: 'project not found' })
|
||||
const { rows } = await pool.query(
|
||||
'SELECT user_email, role, added_by, created_at FROM project_members WHERE project_id = $1 ORDER BY created_at',
|
||||
[req.params.id]
|
||||
)
|
||||
return rows
|
||||
})
|
||||
|
||||
app.post('/api/projects/:id/members', { preHandler: requireCap('use') }, async (req, reply) => {
|
||||
if (!(await canManageProject(req.user.email, req.user.is_admin, req.params.id)))
|
||||
return reply.status(403).send({ error: 'only the project creator can add members' })
|
||||
const email = req.body?.email?.trim()
|
||||
const role = req.body?.role === 'viewer' ? 'viewer' : 'editor'
|
||||
if (!email) return reply.status(400).send({ error: 'email required' })
|
||||
const { rows: [m] } = await pool.query(
|
||||
`INSERT INTO project_members (project_id, user_email, role, added_by) VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (project_id, user_email) DO UPDATE SET role = EXCLUDED.role
|
||||
RETURNING user_email, role, added_by, created_at`,
|
||||
[req.params.id, email, role, req.user.email]
|
||||
)
|
||||
return m
|
||||
})
|
||||
|
||||
app.delete('/api/projects/:id/members/:email', { preHandler: requireCap('use') }, async (req, reply) => {
|
||||
if (!(await canManageProject(req.user.email, req.user.is_admin, req.params.id)))
|
||||
return reply.status(403).send({ error: 'only the project creator can remove members' })
|
||||
await pool.query(
|
||||
'DELETE FROM project_members WHERE project_id = $1 AND user_email = $2',
|
||||
[req.params.id, req.params.email]
|
||||
)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
// ── Per-project status pipeline (optional — most projects have none and
|
||||
// just use the shared global pipeline; see task_statuses view in db.js) ──
|
||||
app.get('/api/projects/:id/statuses', { preHandler: requireCap('use') }, async (req, reply) => {
|
||||
const { rows: custom } = await pool.query(
|
||||
'SELECT * FROM project_statuses WHERE project_id = $1 ORDER BY position, id',
|
||||
[req.params.id]
|
||||
)
|
||||
if (custom.length) return custom
|
||||
const { rows: global } = await pool.query('SELECT * FROM statuses ORDER BY position, id')
|
||||
return global
|
||||
})
|
||||
|
||||
app.post('/api/projects/:id/statuses', { preHandler: requireCap('use') }, async (req, reply) => {
|
||||
if (!(await canEditProject(req.user.email, req.user.is_admin, req.params.id)))
|
||||
return reply.status(403).send({ error: 'only the project creator or an editor can manage its pipeline' })
|
||||
const { label, value, is_hold } = req.body || {}
|
||||
if (!label?.trim() || !value?.trim()) return reply.status(400).send({ error: 'label and value required' })
|
||||
if (!/^[a-z0-9_]+$/.test(value)) return reply.status(400).send({ error: 'value must be lowercase letters, numbers, underscores' })
|
||||
const { rows: [max] } = await pool.query('SELECT COALESCE(MAX(position), -1) AS m FROM project_statuses WHERE project_id = $1', [req.params.id])
|
||||
const { rows: [status] } = await pool.query(
|
||||
`INSERT INTO project_statuses (project_id, label, value, position, is_hold) VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[req.params.id, label.trim(), value.trim(), Number(max.m) + 1, !!is_hold]
|
||||
)
|
||||
return status
|
||||
})
|
||||
|
||||
app.patch('/api/projects/:id/statuses/:statusId', { preHandler: requireCap('use') }, async (req, reply) => {
|
||||
if (!(await canEditProject(req.user.email, req.user.is_admin, req.params.id)))
|
||||
return reply.status(403).send({ error: 'only the project creator or an editor can manage its pipeline' })
|
||||
const fields = []
|
||||
const vals = []
|
||||
let i = 1
|
||||
for (const key of ['label', 'position', 'is_done', 'is_hold']) {
|
||||
if (req.body?.[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(req.body[key]) }
|
||||
}
|
||||
if (!fields.length) return reply.status(400).send({ error: 'nothing to update' })
|
||||
vals.push(req.params.statusId, req.params.id)
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE project_statuses SET ${fields.join(', ')} WHERE id = $${i++} AND project_id = $${i} RETURNING *`,
|
||||
vals
|
||||
)
|
||||
if (!rows.length) return reply.status(404).send({ error: 'status not found' })
|
||||
return rows[0]
|
||||
})
|
||||
|
||||
// Removing the last custom status silently drops the project back onto the
|
||||
// shared global pipeline (see task_statuses view) — any task still holding
|
||||
// one of the deleted values just reads as "not done" until re-filed.
|
||||
app.delete('/api/projects/:id/statuses/:statusId', { preHandler: requireCap('use') }, async (req, reply) => {
|
||||
if (!(await canEditProject(req.user.email, req.user.is_admin, req.params.id)))
|
||||
return reply.status(403).send({ error: 'only the project creator or an editor can manage its pipeline' })
|
||||
const { rows: [status] } = await pool.query(
|
||||
'SELECT value FROM project_statuses WHERE id = $1 AND project_id = $2', [req.params.statusId, req.params.id]
|
||||
)
|
||||
if (!status) return reply.status(404).send({ error: 'status not found' })
|
||||
const { rows: [inUse] } = await pool.query(
|
||||
'SELECT 1 FROM tasks WHERE project_id = $1 AND status = $2 LIMIT 1', [req.params.id, status.value]
|
||||
)
|
||||
if (inUse) return reply.status(409).send({ error: 'status is still in use by tasks in this project' })
|
||||
await pool.query('DELETE FROM project_statuses WHERE id = $1', [req.params.statusId])
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,23 +1,10 @@
|
|||
import { requireAuth, requireCap, hasCap } from '../auth.js'
|
||||
import { pool, getEffectiveStatuses } from '../db.js'
|
||||
import { pool } from '../db.js'
|
||||
import { nextOccurrence, RECUR_RULES } from '../lib/recurrence.js'
|
||||
|
||||
const PRIORITIES = ['low', 'medium', 'high']
|
||||
|
||||
// Resolves a status value against a project's own pipeline if it has one
|
||||
// defined, else the shared global pipeline — same rule as the task_statuses
|
||||
// view, for the one-off lookups (old/new status on a PATCH) that don't want
|
||||
// a full join.
|
||||
async function getStatus(value, projectId) {
|
||||
if (projectId) {
|
||||
const { rows: [ps] } = await pool.query(
|
||||
'SELECT * FROM project_statuses WHERE project_id = $1 AND value = $2',
|
||||
[projectId, value]
|
||||
)
|
||||
if (ps) return ps
|
||||
const { rows: [any] } = await pool.query('SELECT 1 FROM project_statuses WHERE project_id = $1', [projectId])
|
||||
if (any) return null // project has its own pipeline and this value isn't in it
|
||||
}
|
||||
async function getStatus(value) {
|
||||
const { rows: [s] } = await pool.query('SELECT * FROM statuses WHERE value = $1', [value])
|
||||
return s || null
|
||||
}
|
||||
|
|
@ -33,31 +20,8 @@ async function loadOwned(id, email) {
|
|||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Load a task the caller can edit: they own it, or the task sits in a
|
||||
// project they created or are an 'editor' member of. Narrower than it sounds
|
||||
// — see the PATCH route, which still blocks a non-owner editor from touching
|
||||
// list_id/project_id/assigned_to (those stay reserved for the owner or the
|
||||
// assign/reallocate caps).
|
||||
async function loadEditable(id, email) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT t.* FROM tasks t
|
||||
WHERE t.id = $1 AND (
|
||||
t.owner_id = $2
|
||||
OR (t.project_id IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM projects p WHERE p.id = t.project_id AND p.created_by = $2
|
||||
))
|
||||
OR (t.project_id IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM project_members pm WHERE pm.project_id = t.project_id AND pm.user_email = $2 AND pm.role = 'editor'
|
||||
))
|
||||
)`,
|
||||
[id, email]
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
// Load a task the caller can see: they own it, it was assigned to them,
|
||||
// they've been tagged in, or it sits in a project they're a member of
|
||||
// (viewer or editor). Used for read access and collaborative actions
|
||||
// Load a task the caller can see: they own it, it was assigned to them, or
|
||||
// they've been tagged in. Used for read access and collaborative actions
|
||||
// (comments, checklist items) that a tagged-in collaborator should be able
|
||||
// to do without full edit rights.
|
||||
async function loadVisible(id, email) {
|
||||
|
|
@ -65,9 +29,7 @@ async function loadVisible(id, email) {
|
|||
`SELECT t.* FROM tasks t
|
||||
WHERE t.id = $1 AND (t.owner_id = $2 OR t.assigned_to = $2 OR EXISTS (
|
||||
SELECT 1 FROM task_tags tt WHERE tt.task_id = t.id AND tt.user_email = $2
|
||||
) OR (t.project_id IS NOT NULL AND EXISTS (
|
||||
SELECT 1 FROM project_members pm WHERE pm.project_id = t.project_id AND pm.user_email = $2
|
||||
)))`,
|
||||
))`,
|
||||
[id, email]
|
||||
)
|
||||
return rows[0] || null
|
||||
|
|
@ -107,11 +69,8 @@ async function spawnRecurrence(task) {
|
|||
export async function taskRoutes(app) {
|
||||
app.addHook('preHandler', requireAuth)
|
||||
|
||||
// List tasks. ?view=today = every open task I own (the frontend buckets
|
||||
// these into overdue/today/next-7-days/pinned/other — the backend only
|
||||
// excludes done ones, so the view can show an "other open tasks" section
|
||||
// instead of looking empty whenever nothing happens to be due).
|
||||
// ?view=assigned = tasks handed to me via the 'assign' cap.
|
||||
// List tasks. ?view=today = cross-list agenda (non-done, due within 8 days
|
||||
// or overdue). ?view=assigned = tasks handed to me via the 'assign' cap.
|
||||
// ?view=shared = tasks I've been tagged into (not mine, not assigned to me).
|
||||
// ?view=all = every user's tasks, dispatcher board for the 'reallocate' cap.
|
||||
// Otherwise defaults to everything I own. ?list_id/?status/?category_id/
|
||||
|
|
@ -145,23 +104,25 @@ export async function taskRoutes(app) {
|
|||
if (search) { const idx = i++; clauses.push(`(t.title ILIKE $${idx} OR t.notes ILIKE $${idx})`); vals.push(`%${search}%`) }
|
||||
if (view === 'today') {
|
||||
clauses.push(`NOT s.is_done`)
|
||||
clauses.push(`t.due_at IS NOT NULL`)
|
||||
clauses.push(`t.due_at < (NOW() + INTERVAL '8 days')`)
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
`SELECT t.*, l.name AS list_name, l.colour AS list_colour,
|
||||
s.status_label AS status_label, s.is_done, s.is_hold,
|
||||
s.label AS status_label, s.is_done, s.is_hold,
|
||||
c.name AS category_name, c.colour AS category_colour,
|
||||
p.name AS project_name, p.colour AS project_colour,
|
||||
(SELECT COUNT(*) FROM task_items ti WHERE ti.task_id = t.id) AS item_count,
|
||||
(SELECT COUNT(*) FROM task_items ti WHERE ti.task_id = t.id AND ti.is_done) AS item_done
|
||||
FROM tasks t
|
||||
${joinTags}
|
||||
LEFT JOIN lists l ON l.id = t.list_id
|
||||
JOIN task_statuses s ON s.task_id = t.id
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
LEFT JOIN projects p ON p.id = t.project_id
|
||||
LEFT JOIN lists l ON l.id = t.list_id
|
||||
JOIN statuses s ON s.value = t.status
|
||||
LEFT JOIN categories c ON c.id = t.category_id
|
||||
LEFT JOIN projects p ON p.id = t.project_id
|
||||
WHERE ${clauses.join(' AND ')}
|
||||
ORDER BY s.is_done, s.status_position, t.sort_order, t.due_at NULLS LAST, t.id`,
|
||||
ORDER BY s.is_done, s.position, t.sort_order, t.due_at NULLS LAST, t.id`,
|
||||
vals
|
||||
)
|
||||
return rows.map(r => ({ ...r, item_count: Number(r.item_count), item_done: Number(r.item_done) }))
|
||||
|
|
@ -188,18 +149,12 @@ export async function taskRoutes(app) {
|
|||
app.post('/api/tasks', { preHandler: requireCap('use') }, async (req, reply) => {
|
||||
const { list_id, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id } = req.body || {}
|
||||
if (!title?.trim()) return reply.status(400).send({ error: 'title required' })
|
||||
// list_id is optional — omitting it lands the task "unfiled" (list_id
|
||||
// NULL), the same state assign/reallocate already use. Projects need this
|
||||
// so a task can be created straight into a project without first needing
|
||||
// a personal list, since projects are a cross-cutting grouping, not a
|
||||
// list replacement.
|
||||
if (list_id) {
|
||||
const { rows: [list] } = await pool.query(
|
||||
'SELECT id FROM lists WHERE id = $1 AND owner_id = $2',
|
||||
[list_id, req.user.email]
|
||||
)
|
||||
if (!list) return reply.status(400).send({ error: 'invalid list' })
|
||||
}
|
||||
// Verify the target list belongs to the caller.
|
||||
const { rows: [list] } = await pool.query(
|
||||
'SELECT id FROM lists WHERE id = $1 AND owner_id = $2',
|
||||
[list_id, req.user.email]
|
||||
)
|
||||
if (!list) return reply.status(400).send({ error: 'invalid list' })
|
||||
if (priority && !PRIORITIES.includes(priority)) return reply.status(400).send({ error: 'bad priority' })
|
||||
if (recur_rule && !RECUR_RULES.includes(recur_rule)) return reply.status(400).send({ error: 'bad recur_rule' })
|
||||
if (category_id) {
|
||||
|
|
@ -211,43 +166,27 @@ export async function taskRoutes(app) {
|
|||
if (!p) return reply.status(400).send({ error: 'invalid project' })
|
||||
}
|
||||
const { rows: [max] } = await pool.query(
|
||||
list_id
|
||||
? 'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id = $1'
|
||||
: 'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id IS NULL',
|
||||
list_id ? [list_id] : []
|
||||
'SELECT COALESCE(MAX(sort_order), 0) AS m FROM tasks WHERE list_id = $1',
|
||||
[list_id]
|
||||
)
|
||||
// A project with its own custom pipeline won't necessarily have a 'todo'
|
||||
// value in it — default to its own lowest-position non-done status instead.
|
||||
const effectiveStatuses = await getEffectiveStatuses(project_id || null)
|
||||
const defaultStatus = effectiveStatuses.find(s => !s.is_done)?.value ?? effectiveStatuses[0]?.value ?? 'todo'
|
||||
const { rows: [task] } = await pool.query(
|
||||
`INSERT INTO tasks (list_id, owner_id, created_by, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id, status, sort_order)
|
||||
VALUES ($1, $2, $2, $3, $4, COALESCE($5, 'medium'), $6, $7, $8, $9, $10, $11, $12)
|
||||
`INSERT INTO tasks (list_id, owner_id, created_by, title, notes, priority, due_at, remind_at, recur_rule, category_id, project_id, sort_order)
|
||||
VALUES ($1, $2, $2, $3, $4, COALESCE($5, 'medium'), $6, $7, $8, $9, $10, $11)
|
||||
RETURNING *`,
|
||||
[list_id || null, req.user.email, title.trim(), notes || null, priority || null,
|
||||
due_at || null, remind_at || null, recur_rule || null, category_id || null, project_id || null,
|
||||
defaultStatus, Number(max.m) + 1]
|
||||
[list_id, req.user.email, title.trim(), notes || null, priority || null,
|
||||
due_at || null, remind_at || null, recur_rule || null, category_id || null, project_id || null, Number(max.m) + 1]
|
||||
)
|
||||
return task
|
||||
})
|
||||
|
||||
app.patch('/api/tasks/:id', { preHandler: requireCap('use') }, async (req, reply) => {
|
||||
// A non-owner can only get here via a project 'editor' membership — see
|
||||
// loadEditable. They can change a task's content/status, but not its
|
||||
// container identity (list/project) or who owns/is assigned to it; those
|
||||
// stay reserved for the owner or the assign/reallocate caps below.
|
||||
const task = await loadEditable(req.params.id, req.user.email)
|
||||
const task = await loadOwned(req.params.id, req.user.email)
|
||||
if (!task) return reply.status(404).send({ error: 'task not found' })
|
||||
const isOwner = task.owner_id === req.user.email
|
||||
|
||||
const b = req.body || {}
|
||||
if (!isOwner && (b.list_id !== undefined || b.project_id !== undefined || b.assigned_to !== undefined)) {
|
||||
return reply.status(403).send({ error: 'Only the owner can change list, project, or assignment' })
|
||||
}
|
||||
|
||||
let newStatus = null
|
||||
if (b.status !== undefined) {
|
||||
newStatus = await getStatus(b.status, task.project_id)
|
||||
newStatus = await getStatus(b.status)
|
||||
if (!newStatus) return reply.status(400).send({ error: 'bad status' })
|
||||
}
|
||||
if (b.priority && !PRIORITIES.includes(b.priority)) return reply.status(400).send({ error: 'bad priority' })
|
||||
|
|
@ -274,7 +213,7 @@ export async function taskRoutes(app) {
|
|||
const fields = []
|
||||
const vals = []
|
||||
let i = 1
|
||||
for (const key of ['title', 'notes', 'priority', 'due_at', 'remind_at', 'recur_rule', 'sort_order', 'category_id', 'project_id', 'pinned_today']) {
|
||||
for (const key of ['title', 'notes', 'priority', 'due_at', 'remind_at', 'recur_rule', 'sort_order', 'category_id', 'project_id']) {
|
||||
if (b[key] !== undefined) { fields.push(`${key} = $${i++}`); vals.push(b[key] === '' ? null : b[key]) }
|
||||
}
|
||||
// list_id can be explicitly nulled (unfiled) as well as changed.
|
||||
|
|
@ -296,7 +235,7 @@ export async function taskRoutes(app) {
|
|||
}
|
||||
}
|
||||
|
||||
const oldStatus = newStatus ? await getStatus(task.status, task.project_id) : null
|
||||
const oldStatus = newStatus ? await getStatus(task.status) : null
|
||||
const wasDone = !!oldStatus?.is_done
|
||||
if (newStatus) {
|
||||
fields.push(`status = $${i++}`); vals.push(b.status)
|
||||
|
|
@ -305,9 +244,9 @@ export async function taskRoutes(app) {
|
|||
}
|
||||
fields.push('updated_at = NOW()')
|
||||
|
||||
vals.push(task.id)
|
||||
vals.push(task.id, req.user.email)
|
||||
const { rows: [updated] } = await pool.query(
|
||||
`UPDATE tasks SET ${fields.join(', ')} WHERE id = $${i} RETURNING *`,
|
||||
`UPDATE tasks SET ${fields.join(', ')} WHERE id = $${i++} AND owner_id = $${i} RETURNING *`,
|
||||
vals
|
||||
)
|
||||
if (newStatus?.is_done && !wasDone) await spawnRecurrence(task)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { ListsProvider } from './context/ListsContext'
|
|||
import { StatusesProvider } from './context/StatusesContext'
|
||||
import { CategoriesProvider } from './context/CategoriesContext'
|
||||
import { ProjectsProvider } from './context/ProjectsContext'
|
||||
import { UsersProvider } from './context/UsersContext'
|
||||
import { SearchProvider } from './context/SearchContext'
|
||||
import { TaskFiltersProvider } from './context/TaskFiltersContext'
|
||||
import { TaskDrawerProvider } from './components/TaskDrawer'
|
||||
|
|
@ -31,29 +30,27 @@ export default function App() {
|
|||
<StatusesProvider>
|
||||
<CategoriesProvider>
|
||||
<ProjectsProvider>
|
||||
<UsersProvider>
|
||||
<SearchProvider>
|
||||
<TaskFiltersProvider>
|
||||
<TaskDrawerProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/today" replace />} />
|
||||
<Route path="/today" element={<TodayView />} />
|
||||
<Route path="/board" element={<BoardView />} />
|
||||
<Route path="/list/:id" element={<ListView />} />
|
||||
<Route path="/projects" element={<ProjectsView />} />
|
||||
<Route path="/project/:id" element={<ProjectDetailView />} />
|
||||
<Route path="/shared" element={<FilteredListView view="shared" title="Shared with me" />} />
|
||||
<Route path="/assigned" element={<FilteredListView view="assigned" title="Assigned to me" />} />
|
||||
<Route path="/dispatch" element={<DispatchView />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/today" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</TaskDrawerProvider>
|
||||
</TaskFiltersProvider>
|
||||
</SearchProvider>
|
||||
</UsersProvider>
|
||||
<SearchProvider>
|
||||
<TaskFiltersProvider>
|
||||
<TaskDrawerProvider>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/today" replace />} />
|
||||
<Route path="/today" element={<TodayView />} />
|
||||
<Route path="/board" element={<BoardView />} />
|
||||
<Route path="/list/:id" element={<ListView />} />
|
||||
<Route path="/projects" element={<ProjectsView />} />
|
||||
<Route path="/project/:id" element={<ProjectDetailView />} />
|
||||
<Route path="/shared" element={<FilteredListView view="shared" title="Shared with me" />} />
|
||||
<Route path="/assigned" element={<FilteredListView view="assigned" title="Assigned to me" />} />
|
||||
<Route path="/dispatch" element={<DispatchView />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/today" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</TaskDrawerProvider>
|
||||
</TaskFiltersProvider>
|
||||
</SearchProvider>
|
||||
</ProjectsProvider>
|
||||
</CategoriesProvider>
|
||||
</StatusesProvider>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail, ProjectMember, ProjectRole, AuthUser } from './types'
|
||||
import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail } from './types'
|
||||
|
||||
const BASE = '/tasks/api'
|
||||
|
||||
|
|
@ -121,32 +121,6 @@ export function deleteProject(id: number): Promise<{ ok: boolean }> {
|
|||
return request(`/projects/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ── Project members (viewer/editor) ──────────────────────────
|
||||
export function fetchProjectMembers(projectId: number): Promise<ProjectMember[]> {
|
||||
return request(`/projects/${projectId}/members`)
|
||||
}
|
||||
export function addProjectMember(projectId: number, email: string, role: ProjectRole): Promise<ProjectMember> {
|
||||
return request(`/projects/${projectId}/members`, { method: 'POST', body: JSON.stringify({ email, role }) })
|
||||
}
|
||||
export function removeProjectMember(projectId: number, email: string): Promise<{ ok: boolean }> {
|
||||
return request(`/projects/${projectId}/members/${encodeURIComponent(email)}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ── Per-project status pipeline (falls back to the global one if a project
|
||||
// has none of its own — see task_statuses view on the backend) ──────────
|
||||
export function fetchProjectStatuses(projectId: number): Promise<AppStatus[]> {
|
||||
return request(`/projects/${projectId}/statuses`)
|
||||
}
|
||||
export function createProjectStatus(projectId: number, body: { label: string; value: string; is_hold?: boolean }): Promise<AppStatus> {
|
||||
return request(`/projects/${projectId}/statuses`, { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
export function updateProjectStatus(projectId: number, statusId: number, body: Partial<AppStatus>): Promise<AppStatus> {
|
||||
return request(`/projects/${projectId}/statuses/${statusId}`, { method: 'PATCH', body: JSON.stringify(body) })
|
||||
}
|
||||
export function deleteProjectStatus(projectId: number, statusId: number): Promise<{ ok: boolean }> {
|
||||
return request(`/projects/${projectId}/statuses/${statusId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ── Checklist items ──────────────────────────────────────────
|
||||
export function addItem(taskId: number, label: string): Promise<TaskItem> {
|
||||
return request(`/tasks/${taskId}/items`, { method: 'POST', body: JSON.stringify({ label }) })
|
||||
|
|
@ -163,11 +137,6 @@ export function addComment(taskId: number, body: string): Promise<TaskComment> {
|
|||
return request(`/tasks/${taskId}/comments`, { method: 'POST', body: JSON.stringify({ body }) })
|
||||
}
|
||||
|
||||
// ── Users (for tag/assign/reallocate pickers) ───────────────────
|
||||
export function fetchAssignableUsers(): Promise<AuthUser[]> {
|
||||
return request('/auth/users?app=tasks')
|
||||
}
|
||||
|
||||
// ── Config ───────────────────────────────────────────────────
|
||||
export function fetchConfig(): Promise<AppConfig> {
|
||||
return request('/config')
|
||||
|
|
|
|||
|
|
@ -1,15 +1,10 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { X, Trash2, ChevronDown, PauseCircle, Plus } from 'lucide-react'
|
||||
import type { Project, ProjectMember, ProjectRole, AppStatus } from '../types'
|
||||
import { X, Trash2 } from 'lucide-react'
|
||||
import type { Project } from '../types'
|
||||
import { LIST_COLOURS } from '../types'
|
||||
import {
|
||||
createProject, updateProject, deleteProject,
|
||||
fetchProjectMembers, addProjectMember, removeProjectMember,
|
||||
fetchProjectStatuses, createProjectStatus, updateProjectStatus, deleteProjectStatus,
|
||||
} from '../api'
|
||||
import { createProject, updateProject, deleteProject } from '../api'
|
||||
import { useProjects } from '../context/ProjectsContext'
|
||||
import { UserPicker } from './shared'
|
||||
|
||||
export default function ProjectEditor({ project, onClose }: { project: Project | null; onClose: () => void }) {
|
||||
const { reload } = useProjects()
|
||||
|
|
@ -92,9 +87,6 @@ export default function ProjectEditor({ project, onClose }: { project: Project |
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{project && <MembersManager projectId={project.id} />}
|
||||
{project && <ProjectStatusManager projectId={project.id} />}
|
||||
|
||||
<div className="modal-footer-actions">
|
||||
{project && (
|
||||
<button className="btn-ghost-sm btn-ghost-danger" onClick={remove} disabled={busy} style={{ marginRight: 'auto' }}>
|
||||
|
|
@ -110,164 +102,3 @@ export default function ProjectEditor({ project, onClose }: { project: Project |
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsed by default — membership is a lesser-used, occasional action
|
||||
// compared to the name/description/colour fields above it.
|
||||
function MembersManager({ projectId }: { projectId: number }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [members, setMembers] = useState<ProjectMember[]>([])
|
||||
const [email, setEmail] = useState('')
|
||||
const [role, setRole] = useState<ProjectRole>('editor')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => { if (open) fetchProjectMembers(projectId).then(setMembers).catch(e => setError(e.message)) }, [open, projectId])
|
||||
|
||||
async function add() {
|
||||
if (!email) return
|
||||
try {
|
||||
const m = await addProjectMember(projectId, email, role)
|
||||
setMembers(ms => [...ms.filter(x => x.user_email !== m.user_email), m])
|
||||
setEmail('')
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Failed to add') }
|
||||
}
|
||||
async function remove(userEmail: string) {
|
||||
await removeProjectMember(projectId, userEmail).catch(e => setError(e.message))
|
||||
setMembers(ms => ms.filter(m => m.user_email !== userEmail))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
|
||||
onClick={() => setOpen(o => !o)}>
|
||||
<span>Members {members.length > 0 && `(${members.length})`}</span>
|
||||
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${open ? ' open' : ''}`} />
|
||||
</div>
|
||||
{open && (
|
||||
<div className="card" style={{ padding: '12px 14px' }}>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
<div className="field-hint" style={{ marginBottom: 10 }}>
|
||||
Viewers can see this project and everything in it. Editors can also edit any task inside it — not just
|
||||
their own — but still can't move a task out of the project or reassign it; that stays with the task's owner.
|
||||
</div>
|
||||
{members.length > 0 && (
|
||||
<div className="chip-bar" style={{ marginBottom: 8 }}>
|
||||
{members.map(m => (
|
||||
<span key={m.user_email} className="chip">
|
||||
{m.user_email} · {m.role}
|
||||
<X size={12} strokeWidth={2} style={{ cursor: 'pointer' }} onClick={() => remove(m.user_email)} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<select value={role} onChange={e => setRole(e.target.value as ProjectRole)} style={{ flexShrink: 0, width: 90 }}>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="viewer">Viewer</option>
|
||||
</select>
|
||||
<UserPicker value={email} onChange={setEmail} onSubmit={add} buttonLabel="Add"
|
||||
exclude={members.map(m => m.user_email)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Collapsed by default — most projects never need their own pipeline and
|
||||
// just use the shared global one (see the task_statuses view on the backend).
|
||||
function ProjectStatusManager({ projectId }: { projectId: number }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [statuses, setStatuses] = useState<AppStatus[]>([])
|
||||
const [isCustom, setIsCustom] = useState(false)
|
||||
const [newLabel, setNewLabel] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function reload() {
|
||||
const data = await fetchProjectStatuses(projectId)
|
||||
setStatuses(data)
|
||||
// The endpoint returns global statuses verbatim (no project_id field) as
|
||||
// a fallback when the project has none of its own — that's the cheapest
|
||||
// way to tell "this project has its own pipeline" from "borrowing the
|
||||
// shared one" without a second request.
|
||||
setIsCustom(data.length > 0 && 'project_id' in data[0])
|
||||
}
|
||||
useEffect(() => { if (open) reload().catch(e => setError(e.message)) }, [open, projectId])
|
||||
|
||||
async function add() {
|
||||
if (!newLabel.trim()) return
|
||||
const value = newLabel.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
||||
if (!value) return
|
||||
try {
|
||||
await createProjectStatus(projectId, { label: newLabel.trim(), value })
|
||||
setNewLabel('')
|
||||
await reload()
|
||||
} catch (e) { setError(e instanceof Error ? e.message : 'Failed to add') }
|
||||
}
|
||||
async function rename(id: number, label: string) {
|
||||
await updateProjectStatus(projectId, id, { label }).catch(e => setError(e.message))
|
||||
await reload()
|
||||
}
|
||||
async function toggleHold(id: number, is_hold: boolean) {
|
||||
await updateProjectStatus(projectId, id, { is_hold }).catch(e => setError(e.message))
|
||||
await reload()
|
||||
}
|
||||
async function toggleDone(id: number, is_done: boolean) {
|
||||
await updateProjectStatus(projectId, id, { is_done }).catch(e => setError(e.message))
|
||||
await reload()
|
||||
}
|
||||
async function remove(id: number) {
|
||||
if (!confirm('Delete this status? Only possible if no tasks in this project currently use it.')) return
|
||||
try { await deleteProjectStatus(projectId, id); await reload() }
|
||||
catch (e) { setError(e instanceof Error ? e.message : 'Could not delete — still in use by tasks') }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
|
||||
onClick={() => setOpen(o => !o)}>
|
||||
<span>Status pipeline</span>
|
||||
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${open ? ' open' : ''}`} />
|
||||
</div>
|
||||
{open && (
|
||||
<div className="card" style={{ padding: '12px 14px' }}>
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
<div className="field-hint" style={{ marginBottom: 10 }}>
|
||||
{isCustom
|
||||
? "This project has its own pipeline, separate from the shared one — tasks here only use these statuses."
|
||||
: 'Currently using the shared pipeline (Settings → Status pipeline). Add a status below to give this project its own.'}
|
||||
</div>
|
||||
{statuses.map(s => (
|
||||
<div key={s.id} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||
<input type="text" defaultValue={s.label} style={{ flex: 1 }}
|
||||
disabled={!isCustom}
|
||||
onBlur={e => isCustom && e.target.value.trim() && e.target.value !== s.label && rename(s.id, e.target.value.trim())} />
|
||||
{isCustom && (
|
||||
<>
|
||||
<label className="field-check" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
|
||||
<input type="checkbox" checked={s.is_done} onChange={e => toggleDone(s.id, e.target.checked)} />
|
||||
Done
|
||||
</label>
|
||||
<label className="field-check" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
|
||||
<input type="checkbox" checked={s.is_hold} onChange={e => toggleHold(s.id, e.target.checked)} />
|
||||
<PauseCircle size={13} strokeWidth={1.75} /> Hold
|
||||
</label>
|
||||
<button className="btn-ghost-sm btn-ghost-danger" onClick={() => remove(s.id)}>
|
||||
<Trash2 size={14} strokeWidth={1.75} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
|
||||
<input type="text" value={newLabel} placeholder={isCustom ? 'New status label…' : 'Add a status to start this project\'s own pipeline…'}
|
||||
onChange={e => setNewLabel(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') add() }} />
|
||||
<button className="btn btn-sm" onClick={add} disabled={!newLabel.trim()}>
|
||||
<Plus size={14} strokeWidth={1.75} /> Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
import { useState, useRef } from 'react'
|
||||
import { PauseCircle } from 'lucide-react'
|
||||
import type { Task, AppStatus } from '../types'
|
||||
import { PriorityBadge, dueInfo } from './shared'
|
||||
|
||||
// Kanban columns over a given task list + status pipeline — used by the
|
||||
// global Board page, and as a per-list/per-project view-mode toggle (see
|
||||
// ListView/ProjectDetailView). Parameterized on `statuses` so callers can
|
||||
// pass either the app-wide pipeline or a project's own (useProjectStatuses).
|
||||
export default function TaskBoard({ tasks, statuses, onOpen, onDrop, showList = true }: {
|
||||
tasks: Task[]
|
||||
statuses: AppStatus[]
|
||||
onOpen: (id: number) => void
|
||||
onDrop: (id: number, status: string) => void
|
||||
showList?: boolean
|
||||
}) {
|
||||
const [over, setOver] = useState<string | null>(null)
|
||||
// Done columns collapse by default — otherwise the board fills up with
|
||||
// finished cards the longer a list/project stays in use. Anything a user
|
||||
// has explicitly expanded this session stays expanded.
|
||||
const [openDone, setOpenDone] = useState<Set<string>>(new Set())
|
||||
const dragId = useRef<number | null>(null)
|
||||
|
||||
function drop(status: string) {
|
||||
const id = dragId.current
|
||||
dragId.current = null
|
||||
setOver(null)
|
||||
if (id !== null) onDrop(id, status)
|
||||
}
|
||||
|
||||
function toggleDoneColumn(value: string) {
|
||||
setOpenDone(s => {
|
||||
const next = new Set(s)
|
||||
if (next.has(value)) next.delete(value); else next.add(value)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="board">
|
||||
{statuses.map(status => {
|
||||
const items = tasks.filter(t => t.status === status.value)
|
||||
const collapsed = status.is_done && !openDone.has(status.value)
|
||||
return (
|
||||
<div key={status.value}
|
||||
className={`board-col${over === status.value ? ' drop-target' : ''}${status.is_hold ? ' board-col-hold' : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); setOver(status.value) }}
|
||||
onDragLeave={() => setOver(o => o === status.value ? null : o)}
|
||||
onDrop={() => drop(status.value)}>
|
||||
<div className="board-col-head" style={status.is_done ? { cursor: 'pointer' } : undefined}
|
||||
onClick={() => status.is_done && toggleDoneColumn(status.value)}>
|
||||
{status.is_hold && <PauseCircle size={13} strokeWidth={1.75} />}
|
||||
{status.label}
|
||||
<span className="board-col-count">{items.length}</span>
|
||||
</div>
|
||||
{collapsed ? (
|
||||
items.length > 0 && (
|
||||
<div className="muted" style={{ fontSize: 12, padding: '4px 2px', cursor: 'pointer' }}
|
||||
onClick={() => toggleDoneColumn(status.value)}>
|
||||
Show {items.length} done
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{items.map(t => (
|
||||
<div key={t.id} className="card" style={{ cursor: 'grab', padding: '10px 12px' }}
|
||||
draggable
|
||||
onDragStart={() => { dragId.current = t.id }}
|
||||
onClick={() => onOpen(t.id)}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 500, marginBottom: 4 }}>{t.title}</div>
|
||||
<div className="task-row-meta">
|
||||
{showList && t.list_name && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span className="list-dot" style={{ background: t.list_colour, width: 8, height: 8 }} />
|
||||
{t.list_name}
|
||||
</span>
|
||||
)}
|
||||
{t.category_name && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span className="list-dot" style={{ background: t.category_colour, width: 8, height: 8 }} />
|
||||
{t.category_name}
|
||||
</span>
|
||||
)}
|
||||
{(() => { const d = dueInfo(t.due_at); return d && <span className={`due-chip${d.overdue ? ' overdue' : ''}`}>{d.label}</span> })()}
|
||||
<PriorityBadge priority={t.priority} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <div className="muted" style={{ fontSize: 12, padding: '4px 2px' }}>—</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput, Tag, FolderKanban, UserPlus, Send, ChevronDown, Pin } from 'lucide-react'
|
||||
import { X, Trash2, Plus, Bell, Repeat, CalendarClock, FolderInput, Tag, FolderKanban, UserPlus, Send } from 'lucide-react'
|
||||
import type { TaskDetail as TDetail, Priority, RecurRule } from '../types'
|
||||
import { PRIORITIES, PRIORITY_LABELS, RECUR_RULES, RECUR_LABELS } from '../types'
|
||||
import {
|
||||
fetchTask, updateTask, deleteTask,
|
||||
addItem, updateItem, deleteItem, addComment,
|
||||
tagTask, untagTask, reallocateTask, fetchProjectMembers,
|
||||
tagTask, untagTask, reallocateTask,
|
||||
} from '../api'
|
||||
import { useLists } from '../context/ListsContext'
|
||||
import { useCategories } from '../context/CategoriesContext'
|
||||
import { useProjects } from '../context/ProjectsContext'
|
||||
import { useProjectStatuses } from '../hooks/useProjectStatuses'
|
||||
import { useStatuses } from '../context/StatusesContext'
|
||||
import { useAuth } from './AuthGate'
|
||||
import { can } from '../types'
|
||||
import { Check_, formatDateTime, toLocalInput, UserPicker } from './shared'
|
||||
import { Check_, formatDateTime, toLocalInput } from './shared'
|
||||
|
||||
export default function TaskDetail({ taskId, onChange, onClose }:
|
||||
{ taskId: number; onChange: () => void; onClose: () => void }) {
|
||||
|
|
@ -21,8 +21,8 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
const { lists } = useLists()
|
||||
const { categories } = useCategories()
|
||||
const { projects } = useProjects()
|
||||
const { statuses } = useStatuses()
|
||||
const [task, setTask] = useState<TDetail | null>(null)
|
||||
const { statuses } = useProjectStatuses(task?.project_id ?? null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
const [notes, setNotes] = useState('')
|
||||
|
|
@ -31,33 +31,18 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
const [tagEmail, setTagEmail] = useState('')
|
||||
const [assignEmail, setAssignEmail] = useState('')
|
||||
const [reallocateEmail, setReallocateEmail] = useState('')
|
||||
const [sharingOpen, setSharingOpen] = useState(false)
|
||||
// Project 'editor' members can edit a task's content/status without
|
||||
// owning it (see loadEditable on the backend) — not resolvable from the
|
||||
// task payload alone, so check membership once the task's project is known.
|
||||
const [editorViaProject, setEditorViaProject] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchTask(taskId).then(t => { setTask(t); setTitle(t.title); setNotes(t.notes ?? '') })
|
||||
.catch(e => setError(e.message))
|
||||
}, [taskId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!task?.project_id || task.owner_id === user.email) { setEditorViaProject(false); return }
|
||||
fetchProjectMembers(task.project_id)
|
||||
.then(members => setEditorViaProject(members.some(m => m.user_email === user.email && m.role === 'editor')))
|
||||
.catch(() => setEditorViaProject(false))
|
||||
}, [task?.project_id, task?.owner_id, user.email])
|
||||
|
||||
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>
|
||||
|
||||
const isOwner = task.owner_id === user.email
|
||||
// Content/status fields; list/project/assignment stay owner-only (see the
|
||||
// matching 403 on the backend).
|
||||
const canEdit = isOwner || editorViaProject
|
||||
|
||||
// Patch task fields, merge the result locally, and notify the parent view.
|
||||
async function patch(fields: Record<string, unknown>) {
|
||||
|
|
@ -140,7 +125,7 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
<div style={{ marginTop: 4 }}><Check_ isDone={!!currentStatus?.is_done} onClick={toggleDone} /></div>
|
||||
<textarea
|
||||
value={title}
|
||||
disabled={!canEdit}
|
||||
disabled={!isOwner}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
onBlur={() => title.trim() && title !== task.title && patch({ title: title.trim() })}
|
||||
rows={1}
|
||||
|
|
@ -149,18 +134,10 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
fontSize: 17, fontWeight: 600, fontFamily: 'var(--font)', color: 'var(--text-dark)',
|
||||
}}
|
||||
/>
|
||||
<button className="btn-ghost-sm"
|
||||
title={task.pinned_today ? 'Unpin from Today' : 'Pin to Today'}
|
||||
style={task.pinned_today ? { color: 'var(--gold)' } : undefined}
|
||||
onClick={() => patch({ pinned_today: !task.pinned_today })}>
|
||||
<Pin size={16} strokeWidth={1.75} fill={task.pinned_today ? 'var(--gold)' : 'none'} />
|
||||
</button>
|
||||
</div>
|
||||
{!isOwner && (
|
||||
<div className="field-hint" style={{ marginBottom: 10 }}>
|
||||
{editorViaProject
|
||||
? "You're a collaborator on this task's project, owned by " + task.owner_id + ". You can edit it, but can't change its list, project, or assignment."
|
||||
: "You're tagged on this task, owned by " + task.owner_id + ". You can comment and check off items, but editing is owner-only."}
|
||||
You're tagged on this task, owned by {task.owner_id}. You can comment and check off items, but editing is owner-only.
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -168,7 +145,7 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
<div className="chip-bar">
|
||||
{statuses.map(s => (
|
||||
<button key={s.value} className={`chip${task.status === s.value ? ' active' : ''}`}
|
||||
disabled={!canEdit} onClick={() => canEdit && patch({ status: s.value })}>
|
||||
disabled={!isOwner} onClick={() => isOwner && patch({ status: s.value })}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
|
|
@ -183,12 +160,11 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
{!task.list_id && <option value="">Unfiled</option>}
|
||||
{lists.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
|
||||
</select>
|
||||
{!isOwner && canEdit && <div className="field-hint">Only the owner can move this between lists.</div>}
|
||||
</div>
|
||||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label><Tag size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Category</label>
|
||||
<select value={task.category_id ?? ''} disabled={!canEdit}
|
||||
<select value={task.category_id ?? ''} disabled={!isOwner}
|
||||
onChange={e => patch({ category_id: e.target.value ? Number(e.target.value) : null })}>
|
||||
<option value="">None</option>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
|
|
@ -206,13 +182,13 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
<div className="field-row">
|
||||
<div className="field">
|
||||
<label>Priority</label>
|
||||
<select value={task.priority} disabled={!canEdit} onChange={e => patch({ priority: e.target.value as Priority })}>
|
||||
<select value={task.priority} disabled={!isOwner} 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 ?? ''} disabled={!canEdit} onChange={e => patch({ recur_rule: e.target.value || null })}>
|
||||
<select value={task.recur_rule ?? ''} disabled={!isOwner} 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>
|
||||
|
|
@ -221,79 +197,80 @@ export default function TaskDetail({ taskId, onChange, onClose }:
|
|||
<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)} disabled={!canEdit}
|
||||
<input type="datetime-local" value={toLocalInput(task.due_at)} disabled={!isOwner}
|
||||
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)} disabled={!canEdit}
|
||||
<input type="datetime-local" value={toLocalInput(task.remind_at)} disabled={!isOwner}
|
||||
onChange={e => patch({ remind_at: toLocalIso(e.target.value) })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sharing — tag (anyone with edit rights) / assign (owner + assign cap) / reallocate (dispatcher cap).
|
||||
Collapsed by default: a lesser-used function that otherwise eats space on every task. */}
|
||||
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
|
||||
onClick={() => setSharingOpen(o => !o)}>
|
||||
<span>Sharing</span>
|
||||
<span className="muted" style={{ textTransform: 'none', fontWeight: 400, letterSpacing: 0 }}>
|
||||
{task.tags.length > 0 || task.assigned_to
|
||||
? [task.assigned_to && `assigned to ${task.assigned_to}`, task.tags.length > 0 && `${task.tags.length} tagged`].filter(Boolean).join(', ')
|
||||
: 'nobody'}
|
||||
</span>
|
||||
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${sharingOpen ? ' open' : ''}`} style={{ marginLeft: 'auto' }} />
|
||||
</div>
|
||||
{sharingOpen && (
|
||||
<div className="card" style={{ padding: '12px 14px' }}>
|
||||
<div className="field" style={{ marginBottom: task.tags.length || isOwner ? 8 : 0 }}>
|
||||
<label><Tag size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Tagged (view + comment)</label>
|
||||
{task.tags.length > 0 && (
|
||||
<div className="chip-bar" style={{ margin: '4px 0' }}>
|
||||
{task.tags.map(tg => (
|
||||
<span key={tg.user_email} className="chip">
|
||||
{tg.user_email}
|
||||
{isOwner && (
|
||||
<X size={12} strokeWidth={2} style={{ cursor: 'pointer' }} onClick={() => removeTag(tg.user_email)} />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isOwner && (
|
||||
<UserPicker value={tagEmail} onChange={setTagEmail} onSubmit={addTag} buttonLabel="Tag"
|
||||
exclude={[user.email, ...task.tags.map(tg => tg.user_email)]} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOwner && can(user, 'assign') && (
|
||||
<div className="field" style={{ marginBottom: can(user, 'reallocate') ? 8 : 0 }}>
|
||||
<label><UserPlus size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Assign (hands off ownership)</label>
|
||||
<UserPicker value={assignEmail} onChange={setAssignEmail} onSubmit={assignTo} buttonLabel="Assign"
|
||||
exclude={[user.email]} />
|
||||
{task.assigned_to && <div className="field-hint">Currently assigned to {task.assigned_to}</div>}
|
||||
{/* Sharing — tag (anyone with edit rights) / assign (owner + assign cap) / reallocate (dispatcher cap) */}
|
||||
<div className="section-title">Sharing</div>
|
||||
<div className="card" style={{ padding: '12px 14px' }}>
|
||||
<div className="field" style={{ marginBottom: task.tags.length || isOwner ? 8 : 0 }}>
|
||||
<label><Tag size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Tagged (view + comment)</label>
|
||||
{task.tags.length > 0 && (
|
||||
<div className="chip-bar" style={{ margin: '4px 0' }}>
|
||||
{task.tags.map(tg => (
|
||||
<span key={tg.user_email} className="chip">
|
||||
{tg.user_email}
|
||||
{isOwner && (
|
||||
<X size={12} strokeWidth={2} style={{ cursor: 'pointer' }} onClick={() => removeTag(tg.user_email)} />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{can(user, 'reallocate') && (
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label><Send size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Reallocate (dispatcher — moves to anyone)</label>
|
||||
<UserPicker value={reallocateEmail} onChange={setReallocateEmail} onSubmit={reallocateTo} buttonLabel="Move" />
|
||||
{isOwner && (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input type="email" value={tagEmail} placeholder="colleague@hotel.com"
|
||||
onChange={e => setTagEmail(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') addTag() }} />
|
||||
<button className="btn btn-sm" onClick={addTag} disabled={!tagEmail.trim()}>Tag</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isOwner && !can(user, 'reallocate') && task.tags.length === 0 && (
|
||||
<div className="muted" style={{ fontSize: 12 }}>Nobody else is tagged on this task.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOwner && can(user, 'assign') && (
|
||||
<div className="field" style={{ marginBottom: can(user, 'reallocate') ? 8 : 0 }}>
|
||||
<label><UserPlus size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Assign (hands off ownership)</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input type="email" value={assignEmail} placeholder={task.assigned_to ?? 'someone@hotel.com'}
|
||||
onChange={e => setAssignEmail(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') assignTo() }} />
|
||||
<button className="btn btn-sm" onClick={assignTo} disabled={!assignEmail.trim()}>Assign</button>
|
||||
</div>
|
||||
{task.assigned_to && <div className="field-hint">Currently assigned to {task.assigned_to}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{can(user, 'reallocate') && (
|
||||
<div className="field" style={{ marginBottom: 0 }}>
|
||||
<label><Send size={12} strokeWidth={1.75} style={{ verticalAlign: -1 }} /> Reallocate (dispatcher — moves to anyone)</label>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<input type="email" value={reallocateEmail} placeholder="someone@hotel.com"
|
||||
onChange={e => setReallocateEmail(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') reallocateTo() }} />
|
||||
<button className="btn btn-sm" onClick={reallocateTo} disabled={!reallocateEmail.trim()}>Move</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isOwner && !can(user, 'reallocate') && task.tags.length === 0 && (
|
||||
<div className="muted" style={{ fontSize: 12 }}>Nobody else is tagged on this task.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="section-title">Notes</div>
|
||||
<div className="field">
|
||||
<textarea value={notes} placeholder="Add notes…" disabled={!canEdit}
|
||||
<textarea value={notes} placeholder="Add notes…" disabled={!isOwner}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
onBlur={() => canEdit && notes !== (task.notes ?? '') && patch({ notes })} />
|
||||
onBlur={() => isOwner && notes !== (task.notes ?? '') && patch({ notes })} />
|
||||
</div>
|
||||
|
||||
{/* Checklist */}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { GripVertical, Bell, Repeat, ListChecks, Pin } from 'lucide-react'
|
||||
import { GripVertical, Bell, Repeat, ListChecks } from 'lucide-react'
|
||||
import type { Task } from '../types'
|
||||
import { Check_, PriorityBadge, dueInfo } from './shared'
|
||||
|
||||
|
|
@ -52,7 +52,6 @@ export default function TaskRow({
|
|||
</span>
|
||||
)}
|
||||
{due && <span className={`due-chip${due.overdue ? ' overdue' : ''}`}>{due.label}</span>}
|
||||
{task.pinned_today && <Pin size={12} strokeWidth={1.75} fill="var(--gold)" color="var(--gold)" />}
|
||||
{task.remind_at && <Bell size={12} strokeWidth={1.75} />}
|
||||
{task.recur_rule && <Repeat size={12} strokeWidth={1.75} />}
|
||||
{!!task.item_count && (
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { useCategories } from '../context/CategoriesContext'
|
|||
import { useProjects } from '../context/ProjectsContext'
|
||||
import { useLists } from '../context/ListsContext'
|
||||
import { useTaskFilters } from '../context/TaskFiltersContext'
|
||||
import { useUsers } from '../context/UsersContext'
|
||||
|
||||
export function PriorityBadge({ priority }: { priority: Priority }) {
|
||||
if (priority === 'medium') return null
|
||||
|
|
@ -23,30 +22,6 @@ export function Check_({ isDone, onClick }: { isDone: boolean; onClick: (e: Reac
|
|||
)
|
||||
}
|
||||
|
||||
// User lookup + submit button, replacing a freetext email input wherever a
|
||||
// task gets tagged/assigned/reallocated — avoids typo'd addresses silently
|
||||
// matching nobody (assigned_to/task_tags.user_email have no FK to users).
|
||||
export function UserPicker({ value, onChange, onSubmit, exclude = [], placeholder = 'Choose a person…', buttonLabel }: {
|
||||
value: string
|
||||
onChange: (email: string) => void
|
||||
onSubmit: () => void
|
||||
exclude?: string[]
|
||||
placeholder?: string
|
||||
buttonLabel: string
|
||||
}) {
|
||||
const { users } = useUsers()
|
||||
const options = users.filter(u => !exclude.includes(u.email))
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<select value={value} onChange={e => onChange(e.target.value)}>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map(u => <option key={u.email} value={u.email}>{u.name} ({u.email})</option>)}
|
||||
</select>
|
||||
<button className="btn btn-sm" onClick={onSubmit} disabled={!value}>{buttonLabel}</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface FilterableEntity { id: number; name: string; colour: string }
|
||||
|
||||
// Collapsible multi-select filter chip-bar — generic over anything with an
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
|
||||
import type { AuthUser } from '../types'
|
||||
import { fetchAssignableUsers } from '../api'
|
||||
|
||||
interface UsersCtx {
|
||||
users: AuthUser[]
|
||||
loading: boolean
|
||||
reload: () => Promise<void>
|
||||
}
|
||||
|
||||
const Ctx = createContext<UsersCtx | null>(null)
|
||||
|
||||
export function useUsers() {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useUsers must be used inside UsersProvider')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function UsersProvider({ children }: { children: React.ReactNode }) {
|
||||
const [users, setUsers] = useState<AuthUser[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const data = await fetchAssignableUsers().catch(() => [])
|
||||
setUsers(data)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { reload() }, [reload])
|
||||
|
||||
return <Ctx.Provider value={{ users, loading, reload }}>{children}</Ctx.Provider>
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import type { AppStatus } from '../types'
|
||||
import { fetchProjectStatuses } from '../api'
|
||||
import { useStatuses } from '../context/StatusesContext'
|
||||
|
||||
// A project's own pipeline if it has defined any custom statuses, else the
|
||||
// shared global one (same resolution the backend applies) — for anything
|
||||
// that needs to render a specific project's board/status pills rather than
|
||||
// the app-wide one from StatusesContext. Pass null for "no project" to just
|
||||
// mirror the global context.
|
||||
export function useProjectStatuses(projectId: number | null) {
|
||||
const global = useStatuses()
|
||||
const [projectStatuses, setProjectStatuses] = useState<AppStatus[] | null>(null)
|
||||
const [loading, setLoading] = useState(!!projectId)
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!projectId) { setProjectStatuses(null); return }
|
||||
setLoading(true)
|
||||
const data = await fetchProjectStatuses(projectId).catch(() => [])
|
||||
setProjectStatuses(data)
|
||||
setLoading(false)
|
||||
}, [projectId])
|
||||
|
||||
useEffect(() => { reload() }, [reload])
|
||||
|
||||
const statuses = projectId ? (projectStatuses ?? []) : global.statuses
|
||||
const byValue = (value: string) => statuses.find(s => s.value === value)
|
||||
const firstDoneValue = () => statuses.find(s => s.is_done)?.value
|
||||
const firstNotDoneValue = () => statuses.find(s => !s.is_done)?.value
|
||||
|
||||
return { statuses, loading: projectId ? loading : global.loading, reload, byValue, firstDoneValue, firstNotDoneValue }
|
||||
}
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Columns3 } from 'lucide-react'
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { Columns3, PauseCircle } from 'lucide-react'
|
||||
import type { Task } from '../types'
|
||||
import { fetchTasks, updateTask } from '../api'
|
||||
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||
import { useStatuses } from '../context/StatusesContext'
|
||||
import { useSearch } from '../context/SearchContext'
|
||||
import { useTaskFilters } from '../context/TaskFiltersContext'
|
||||
import { TaskFilterBar } from '../components/shared'
|
||||
import TaskBoard from '../components/TaskBoard'
|
||||
import { PriorityBadge, dueInfo, TaskFilterBar } from '../components/shared'
|
||||
|
||||
export default function BoardView() {
|
||||
const { openTask, changeVersion } = useTaskDrawer()
|
||||
|
|
@ -16,13 +15,19 @@ export default function BoardView() {
|
|||
const { matches } = useTaskFilters()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [over, setOver] = useState<string | null>(null)
|
||||
const dragId = useRef<number | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetchTasks({ search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
|
||||
}, [debouncedQuery])
|
||||
useEffect(() => { load() }, [load, changeVersion])
|
||||
|
||||
async function drop(id: number, status: string) {
|
||||
async function drop(status: string) {
|
||||
const id = dragId.current
|
||||
dragId.current = null
|
||||
setOver(null)
|
||||
if (id === null) return
|
||||
const task = tasks.find(t => t.id === id)
|
||||
if (!task || task.status === status) return
|
||||
setTasks(ts => ts.map(t => t.id === id ? { ...t, status } : t))
|
||||
|
|
@ -40,7 +45,49 @@ export default function BoardView() {
|
|||
<TaskFilterBar />
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
<TaskBoard tasks={visibleTasks} statuses={statuses} onOpen={openTask} onDrop={drop} />
|
||||
<div className="board">
|
||||
{statuses.map(status => {
|
||||
const items = visibleTasks.filter(t => t.status === status.value)
|
||||
return (
|
||||
<div key={status.value}
|
||||
className={`board-col${over === status.value ? ' drop-target' : ''}${status.is_hold ? ' board-col-hold' : ''}`}
|
||||
onDragOver={e => { e.preventDefault(); setOver(status.value) }}
|
||||
onDragLeave={() => setOver(o => o === status.value ? null : o)}
|
||||
onDrop={() => drop(status.value)}>
|
||||
<div className="board-col-head">
|
||||
{status.is_hold && <PauseCircle size={13} strokeWidth={1.75} />}
|
||||
{status.label}
|
||||
<span className="board-col-count">{items.length}</span>
|
||||
</div>
|
||||
{items.map(t => (
|
||||
<div key={t.id} className="card" style={{ cursor: 'grab', padding: '10px 12px' }}
|
||||
draggable
|
||||
onDragStart={() => { dragId.current = t.id }}
|
||||
onClick={() => openTask(t.id)}>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 500, marginBottom: 4 }}>{t.title}</div>
|
||||
<div className="task-row-meta">
|
||||
{t.list_name && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span className="list-dot" style={{ background: t.list_colour, width: 8, height: 8 }} />
|
||||
{t.list_name}
|
||||
</span>
|
||||
)}
|
||||
{t.category_name && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<span className="list-dot" style={{ background: t.category_colour, width: 8, height: 8 }} />
|
||||
{t.category_name}
|
||||
</span>
|
||||
)}
|
||||
{(() => { const d = dueInfo(t.due_at); return d && <span className={`due-chip${d.overdue ? ' overdue' : ''}`}>{d.label}</span> })()}
|
||||
<PriorityBadge priority={t.priority} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{items.length === 0 && <div className="muted" style={{ fontSize: 12, padding: '4px 2px' }}>—</div>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useAuth } from '../components/AuthGate'
|
|||
import { useSearch } from '../context/SearchContext'
|
||||
import { useTaskFilters } from '../context/TaskFiltersContext'
|
||||
import { can } from '../types'
|
||||
import { dueInfo, PriorityBadge, TaskFilterBar, UserPicker } from '../components/shared'
|
||||
import { dueInfo, PriorityBadge, TaskFilterBar } from '../components/shared'
|
||||
|
||||
// A flat, cross-user list for the 'reallocate' cap — deliberately not routed
|
||||
// through the shared task drawer, since GET /api/tasks/:id only grants
|
||||
|
|
@ -83,8 +83,10 @@ export default function DispatchView() {
|
|||
</div>
|
||||
{movingId === t.id ? (
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<UserPicker value={target} onChange={setTarget} onSubmit={() => move(t.id)} buttonLabel="Move"
|
||||
exclude={[t.owner_id]} placeholder="reallocate to…" />
|
||||
<input type="email" autoFocus value={target} placeholder="reallocate to…" style={{ width: 180 }}
|
||||
onChange={e => setTarget(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') move(t.id); if (e.key === 'Escape') setMovingId(null) }} />
|
||||
<button className="btn btn-sm btn-primary" onClick={() => move(t.id)} disabled={!target.trim()}>Move</button>
|
||||
<button className="btn btn-sm" onClick={() => setMovingId(null)}>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Plus, Pencil, List as ListIcon, Columns3, ChevronDown } from 'lucide-react'
|
||||
import { Plus, Pencil } from 'lucide-react'
|
||||
import type { Task, List } from '../types'
|
||||
import { fetchTasks, createTask, updateTask, reorderTasks } from '../api'
|
||||
import { useLists } from '../context/ListsContext'
|
||||
|
|
@ -9,7 +9,6 @@ import { useSearch } from '../context/SearchContext'
|
|||
import { useTaskFilters } from '../context/TaskFiltersContext'
|
||||
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||
import TaskRow from '../components/TaskRow'
|
||||
import TaskBoard from '../components/TaskBoard'
|
||||
import ListEditor from '../components/ListEditor'
|
||||
import { TaskFilterBar } from '../components/shared'
|
||||
|
||||
|
|
@ -17,7 +16,7 @@ export default function ListView() {
|
|||
const { id } = useParams()
|
||||
const listId = Number(id)
|
||||
const { lists } = useLists()
|
||||
const { statuses, firstDoneValue, firstNotDoneValue } = useStatuses()
|
||||
const { firstDoneValue, firstNotDoneValue } = useStatuses()
|
||||
const { debouncedQuery } = useSearch()
|
||||
// List filter is irrelevant here — the route itself already scopes to one
|
||||
// list — so only category/project matter for this view.
|
||||
|
|
@ -29,8 +28,6 @@ export default function ListView() {
|
|||
const [error, setError] = useState<string | null>(null)
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [mode, setMode] = useState<'list' | 'board'>('list')
|
||||
const [showDone, setShowDone] = useState(false)
|
||||
const dragId = useRef<number | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
|
|
@ -52,7 +49,7 @@ export default function ListView() {
|
|||
load()
|
||||
}
|
||||
|
||||
// Native drag reorder within the open list (list mode only).
|
||||
// Native drag reorder within the open list.
|
||||
function onDragStart(taskId: number) { dragId.current = taskId }
|
||||
function onDrop(targetId: number) {
|
||||
const from = dragId.current
|
||||
|
|
@ -67,14 +64,6 @@ export default function ListView() {
|
|||
reorderTasks(ids).catch(() => load())
|
||||
}
|
||||
|
||||
async function dropOnStatus(taskId: number, status: string) {
|
||||
const task = tasks.find(t => t.id === taskId)
|
||||
if (!task || task.status === status) return
|
||||
setTasks(ts => ts.map(t => t.id === taskId ? { ...t, status } : t))
|
||||
await updateTask(taskId, { status }).catch(() => load())
|
||||
load()
|
||||
}
|
||||
|
||||
const visible = tasks.filter(t => categoryMatches(t) && projectMatches(t))
|
||||
const open = visible.filter(t => !t.is_done)
|
||||
const done = visible.filter(t => t.is_done)
|
||||
|
|
@ -86,21 +75,11 @@ export default function ListView() {
|
|||
{list && <span className="list-dot" style={{ background: list.colour, width: 12, height: 12, display: 'inline-block', marginRight: 8, verticalAlign: 0 }} />}
|
||||
{list?.name ?? 'List'}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<div className="chip-bar" style={{ margin: 0 }}>
|
||||
<button className={`chip${mode === 'list' ? ' active' : ''}`} onClick={() => setMode('list')}>
|
||||
<ListIcon size={13} strokeWidth={1.75} style={{ verticalAlign: -2, marginRight: 4 }} />List
|
||||
</button>
|
||||
<button className={`chip${mode === 'board' ? ' active' : ''}`} onClick={() => setMode('board')}>
|
||||
<Columns3 size={13} strokeWidth={1.75} style={{ verticalAlign: -2, marginRight: 4 }} />Board
|
||||
</button>
|
||||
</div>
|
||||
{list && (
|
||||
<button className="btn btn-sm" onClick={() => setEditing(true)}>
|
||||
<Pencil size={13} strokeWidth={1.75} /> Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{list && (
|
||||
<button className="btn btn-sm" onClick={() => setEditing(true)}>
|
||||
<Pencil size={13} strokeWidth={1.75} /> Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<TaskFilterBar showList={false} />
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
|
|
@ -112,34 +91,24 @@ export default function ListView() {
|
|||
onKeyDown={e => { if (e.key === 'Enter') add() }} />
|
||||
</div>
|
||||
|
||||
{open.map(t => (
|
||||
<TaskRow key={t.id} task={t} onToggle={toggle} onOpen={() => openTask(t.id)}
|
||||
draggable dragging={dragId.current === t.id}
|
||||
onDragStart={() => onDragStart(t.id)}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={() => onDrop(t.id)} />
|
||||
))}
|
||||
|
||||
{open.length === 0 && done.length === 0 && (
|
||||
<div className="empty-state">No tasks yet — add one above.</div>
|
||||
)}
|
||||
|
||||
{mode === 'board' ? (
|
||||
<TaskBoard tasks={visible} statuses={statuses} onOpen={openTask} onDrop={dropOnStatus} showList={false} />
|
||||
) : (
|
||||
{done.length > 0 && (
|
||||
<>
|
||||
{open.map(t => (
|
||||
<TaskRow key={t.id} task={t} onToggle={toggle} onOpen={() => openTask(t.id)}
|
||||
draggable dragging={dragId.current === t.id}
|
||||
onDragStart={() => onDragStart(t.id)}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
onDrop={() => onDrop(t.id)} />
|
||||
<div className="section-title">Completed ({done.length})</div>
|
||||
{done.map(t => (
|
||||
<TaskRow key={t.id} task={t} onToggle={toggle} onOpen={() => openTask(t.id)} />
|
||||
))}
|
||||
|
||||
{done.length > 0 && (
|
||||
<>
|
||||
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
|
||||
onClick={() => setShowDone(o => !o)}>
|
||||
<span>Completed ({done.length})</span>
|
||||
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${showDone ? ' open' : ''}`} />
|
||||
</div>
|
||||
{showDone && done.map(t => (
|
||||
<TaskRow key={t.id} task={t} onToggle={toggle} onOpen={() => openTask(t.id)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,24 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { FolderKanban, Pencil, Plus, List as ListIcon, Columns3, ChevronDown } from 'lucide-react'
|
||||
import { FolderKanban, Pencil } from 'lucide-react'
|
||||
import type { ProjectDetail, Task } from '../types'
|
||||
import { fetchProject, updateTask, createTask } from '../api'
|
||||
import { fetchProject, updateTask } from '../api'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import { useProjectStatuses } from '../hooks/useProjectStatuses'
|
||||
import { useStatuses } from '../context/StatusesContext'
|
||||
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||
import TaskRow from '../components/TaskRow'
|
||||
import TaskBoard from '../components/TaskBoard'
|
||||
import ProjectEditor from '../components/ProjectEditor'
|
||||
|
||||
export default function ProjectDetailView() {
|
||||
const { id } = useParams()
|
||||
const projectId = Number(id)
|
||||
const { user } = useAuth()
|
||||
const { statuses, firstDoneValue, firstNotDoneValue } = useProjectStatuses(projectId)
|
||||
const { firstDoneValue, firstNotDoneValue } = useStatuses()
|
||||
const navigate = useNavigate()
|
||||
const { openTask, changeVersion } = useTaskDrawer()
|
||||
const [project, setProject] = useState<ProjectDetail | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [mode, setMode] = useState<'list' | 'board'>('list')
|
||||
const [showDone, setShowDone] = useState(false)
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!projectId) return
|
||||
|
|
@ -30,18 +26,8 @@ export default function ProjectDetailView() {
|
|||
}, [projectId])
|
||||
useEffect(() => { load() }, [load, changeVersion])
|
||||
|
||||
// No list_id — the task lands unfiled (same state assign/reallocate already
|
||||
// use), scoped into the project by project_id alone.
|
||||
async function add() {
|
||||
if (!newTitle.trim()) return
|
||||
await createTask({ project_id: projectId, title: newTitle.trim() })
|
||||
setNewTitle('')
|
||||
load()
|
||||
}
|
||||
|
||||
// Works for any task the viewer owns, or any task in the project if
|
||||
// they're an 'editor' member — the backend 404s anything else (e.g. a
|
||||
// 'viewer' member, who can see but not touch other people's tasks).
|
||||
// Only meaningful for tasks the viewer owns — the backend 404s a PATCH on
|
||||
// someone else's task, which is expected for a shared project view.
|
||||
async function toggle(t: Task) {
|
||||
const next = t.is_done ? firstNotDoneValue() : firstDoneValue()
|
||||
if (!next) return
|
||||
|
|
@ -49,11 +35,6 @@ export default function ProjectDetailView() {
|
|||
load()
|
||||
}
|
||||
|
||||
async function dropOnStatus(taskId: number, status: string) {
|
||||
await updateTask(taskId, { status }).catch(() => {})
|
||||
load()
|
||||
}
|
||||
|
||||
if (error) return <div className="page"><div className="error-banner">{error}</div></div>
|
||||
if (!project) return <div className="page"><div className="empty-state">Loading…</div></div>
|
||||
|
||||
|
|
@ -69,21 +50,11 @@ export default function ProjectDetailView() {
|
|||
<span className="list-dot" style={{ background: project.colour, width: 12, height: 12, display: 'inline-block', marginRight: 8, verticalAlign: 0 }} />
|
||||
{project.name}
|
||||
</h1>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<div className="chip-bar" style={{ margin: 0 }}>
|
||||
<button className={`chip${mode === 'list' ? ' active' : ''}`} onClick={() => setMode('list')}>
|
||||
<ListIcon size={13} strokeWidth={1.75} style={{ verticalAlign: -2, marginRight: 4 }} />List
|
||||
</button>
|
||||
<button className={`chip${mode === 'board' ? ' active' : ''}`} onClick={() => setMode('board')}>
|
||||
<Columns3 size={13} strokeWidth={1.75} style={{ verticalAlign: -2, marginRight: 4 }} />Board
|
||||
</button>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button className="btn btn-sm" onClick={() => setEditing(true)}>
|
||||
<Pencil size={13} strokeWidth={1.75} /> Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canManage && (
|
||||
<button className="btn btn-sm" onClick={() => setEditing(true)}>
|
||||
<Pencil size={13} strokeWidth={1.75} /> Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{project.description && <div className="muted" style={{ marginBottom: 12, fontSize: 13.5 }}>{project.description}</div>}
|
||||
|
||||
|
|
@ -92,40 +63,23 @@ export default function ProjectDetailView() {
|
|||
</div>
|
||||
<div className="muted" style={{ fontSize: 12, marginBottom: 16 }}>{done.length}/{project.tasks.length} tasks done</div>
|
||||
|
||||
<div className="task-add">
|
||||
<Plus size={18} strokeWidth={1.75} color="var(--text-mid)" />
|
||||
<input value={newTitle} placeholder="Add a task to this project…"
|
||||
onChange={e => setNewTitle(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') add() }} />
|
||||
</div>
|
||||
|
||||
{project.tasks.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<FolderKanban size={28} strokeWidth={1.5} style={{ marginBottom: 8, opacity: .4 }} /><br />
|
||||
No tasks in this project yet — add one above, or set a task's Project field from its detail view.
|
||||
No tasks in this project yet — set a task's Project field from its detail view.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'board' ? (
|
||||
<TaskBoard tasks={project.tasks} statuses={statuses} onOpen={openTask} onDrop={dropOnStatus} />
|
||||
) : (
|
||||
{open.map(t => (
|
||||
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
|
||||
))}
|
||||
|
||||
{done.length > 0 && (
|
||||
<>
|
||||
{open.map(t => (
|
||||
<div className="section-title">Completed ({done.length})</div>
|
||||
{done.map(t => (
|
||||
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
|
||||
))}
|
||||
|
||||
{done.length > 0 && (
|
||||
<>
|
||||
<div className="section-title" style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
|
||||
onClick={() => setShowDone(o => !o)}>
|
||||
<span>Completed ({done.length})</span>
|
||||
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${showDone ? ' open' : ''}`} />
|
||||
</div>
|
||||
{showDone && done.map(t => (
|
||||
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { CalendarDays, ChevronDown } from 'lucide-react'
|
||||
import { CalendarDays } from 'lucide-react'
|
||||
import type { Task } from '../types'
|
||||
import { fetchTasks, updateTask } from '../api'
|
||||
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||
|
|
@ -18,9 +18,6 @@ export default function TodayView() {
|
|||
const { matches } = useTaskFilters()
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// "Other" (no due date, not pinned) is collapsed by default — otherwise
|
||||
// it's the whole backlog dumped into what's meant to be a short agenda.
|
||||
const [showOther, setShowOther] = useState(false)
|
||||
|
||||
const load = useCallback(() => {
|
||||
fetchTasks({ view: 'today', search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
|
||||
|
|
@ -39,22 +36,10 @@ export default function TodayView() {
|
|||
const now = new Date()
|
||||
const todayStart = startOfDay(now)
|
||||
const tomorrowStart = new Date(todayStart); tomorrowStart.setDate(tomorrowStart.getDate() + 1)
|
||||
const weekEnd = new Date(todayStart); weekEnd.setDate(weekEnd.getDate() + 7)
|
||||
|
||||
const overdue = visible.filter(t => t.due_at && new Date(t.due_at) < todayStart)
|
||||
const overdueIds = new Set(overdue.map(t => t.id))
|
||||
// Pinned tasks land in Today regardless of due date (unless they're
|
||||
// overdue, which is more urgent and stays in that group instead).
|
||||
const today = visible.filter(t => !overdueIds.has(t.id) && (
|
||||
t.pinned_today || (t.due_at && new Date(t.due_at) >= todayStart && new Date(t.due_at) < tomorrowStart)
|
||||
))
|
||||
const todayIds = new Set(today.map(t => t.id))
|
||||
const upcoming = visible.filter(t => !overdueIds.has(t.id) && !todayIds.has(t.id) &&
|
||||
t.due_at && new Date(t.due_at) >= tomorrowStart && new Date(t.due_at) < weekEnd)
|
||||
const upcomingIds = new Set(upcoming.map(t => t.id))
|
||||
// Everything else — no due date (or due further out) and not pinned. This
|
||||
// exists so an empty agenda reads as "nothing due", not "nothing to do".
|
||||
const other = visible.filter(t => !overdueIds.has(t.id) && !todayIds.has(t.id) && !upcomingIds.has(t.id))
|
||||
const today = visible.filter(t => t.due_at && new Date(t.due_at) >= todayStart && new Date(t.due_at) < tomorrowStart)
|
||||
const upcoming = visible.filter(t => t.due_at && new Date(t.due_at) >= tomorrowStart)
|
||||
|
||||
const groups: Array<{ key: string; title: string; items: Task[]; overdue?: boolean }> = [
|
||||
{ key: 'overdue', title: 'Overdue', items: overdue, overdue: true },
|
||||
|
|
@ -71,7 +56,7 @@ export default function TodayView() {
|
|||
{error && <div className="error-banner">{error}</div>}
|
||||
|
||||
{visible.length === 0 && (
|
||||
<div className="empty-state">Nothing on your plate at all. Enjoy the calm. ☕</div>
|
||||
<div className="empty-state">Nothing due in the next 7 days. Enjoy the calm. ☕</div>
|
||||
)}
|
||||
|
||||
{groups.map(g => g.items.length > 0 && (
|
||||
|
|
@ -84,23 +69,6 @@ export default function TodayView() {
|
|||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{other.length > 0 && (
|
||||
<div>
|
||||
<div className="agenda-group-title" style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}
|
||||
onClick={() => setShowOther(o => !o)}>
|
||||
<span>Other open tasks <span className="muted" style={{ fontWeight: 400 }}>· {other.length}</span></span>
|
||||
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${showOther ? ' open' : ''}`} />
|
||||
</div>
|
||||
{showOther && other.map(t => (
|
||||
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visible.length > 0 && overdue.length === 0 && today.length === 0 && upcoming.length === 0 && !showOther && (
|
||||
<div className="empty-state">Nothing due in the next 7 days — {other.length} other open task{other.length === 1 ? '' : 's'} above, if you want a look.</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ export interface Task {
|
|||
reminded_at: string | null
|
||||
recur_rule: RecurRule | null
|
||||
completed_at: string | null
|
||||
pinned_today: boolean
|
||||
sort_order: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
|
@ -100,15 +99,6 @@ export interface ProjectDetail extends Project {
|
|||
tasks: Task[]
|
||||
}
|
||||
|
||||
export type ProjectRole = 'viewer' | 'editor'
|
||||
|
||||
export interface ProjectMember {
|
||||
user_email: string
|
||||
role: ProjectRole
|
||||
added_by: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TaskTagEntry {
|
||||
user_email: string
|
||||
tagged_by: string
|
||||
|
|
@ -151,13 +141,6 @@ export interface User {
|
|||
caps: string[] // bare slugs — verify?app=tasks strips the prefix
|
||||
}
|
||||
|
||||
// Users with access to this app, served by the central auth service.
|
||||
export interface AuthUser {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function can(user: User, cap: string): boolean {
|
||||
return user.is_admin || user.caps.includes(cap)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue