diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 96b2940..c8eff1d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,10 +5,16 @@ import IosInstallHint from './components/IosInstallHint' import { useVersionCheck } from './hooks/useVersionCheck' import Layout from './components/Layout' import { ListsProvider } from './context/ListsContext' +import { StatusesProvider } from './context/StatusesContext' +import { CategoriesProvider } from './context/CategoriesContext' +import { ProjectsProvider } from './context/ProjectsContext' import { TaskDrawerProvider } from './components/TaskDrawer' import TodayView from './pages/TodayView' import ListView from './pages/ListView' import BoardView from './pages/BoardView' +import ProjectsView from './pages/ProjectsView' +import ProjectDetailView from './pages/ProjectDetailView' +import DispatchView from './pages/DispatchView' import Settings from './pages/Settings' export default function App() { @@ -18,18 +24,27 @@ export default function App() { - - - - } /> - } /> - } /> - } /> - } /> - } /> - - - + + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + + diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b4f8fa2..d2f13e6 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig } from './types' +import type { List, Task, TaskDetail, TaskItem, TaskComment, AppConfig, AppStatus, Category, Project, ProjectDetail } from './types' const BASE = '/tasks/api' @@ -35,7 +35,14 @@ export function deleteList(id: number): Promise<{ ok: boolean }> { } // ── Tasks ──────────────────────────────────────────────────── -export function fetchTasks(params: { list_id?: number; view?: 'today'; status?: string } = {}): Promise { +export interface TaskFilters { + list_id?: number + view?: 'today' | 'assigned' | 'shared' | 'all' + status?: string + category_id?: number + project_id?: number +} +export function fetchTasks(params: TaskFilters = {}): Promise { const qs = new URLSearchParams() for (const [k, v] of Object.entries(params)) if (v !== undefined) qs.set(k, String(v)) const s = qs.toString() @@ -56,6 +63,62 @@ export function deleteTask(id: number): Promise<{ ok: boolean }> { export function reorderTasks(ids: number[]): Promise<{ ok: boolean }> { return request('/tasks/reorder', { method: 'POST', body: JSON.stringify({ ids }) }) } +export function reallocateTask(id: number, to: string): Promise { + return request(`/tasks/${id}/reallocate`, { method: 'POST', body: JSON.stringify({ to }) }) +} + +// ── Tags (share/watch) ─────────────────────────────────────── +export function tagTask(id: number, email: string): Promise<{ ok: boolean }> { + return request(`/tasks/${id}/tags`, { method: 'POST', body: JSON.stringify({ email }) }) +} +export function untagTask(id: number, email: string): Promise<{ ok: boolean }> { + return request(`/tasks/${id}/tags/${encodeURIComponent(email)}`, { method: 'DELETE' }) +} + +// ── Statuses ───────────────────────────────────────────────── +export function fetchStatuses(): Promise { + return request('/statuses') +} +export function createStatus(body: { label: string; value: string; is_hold?: boolean }): Promise { + return request('/statuses', { method: 'POST', body: JSON.stringify(body) }) +} +export function updateStatus(id: number, body: Partial): Promise { + return request(`/statuses/${id}`, { method: 'PATCH', body: JSON.stringify(body) }) +} +export function deleteStatus(id: number): Promise<{ ok: boolean }> { + return request(`/statuses/${id}`, { method: 'DELETE' }) +} + +// ── Categories ─────────────────────────────────────────────── +export function fetchCategories(): Promise { + return request('/categories') +} +export function createCategory(body: { name: string; colour?: string }): Promise { + return request('/categories', { method: 'POST', body: JSON.stringify(body) }) +} +export function updateCategory(id: number, body: Partial): Promise { + return request(`/categories/${id}`, { method: 'PATCH', body: JSON.stringify(body) }) +} +export function deleteCategory(id: number): Promise<{ ok: boolean }> { + return request(`/categories/${id}`, { method: 'DELETE' }) +} + +// ── Projects ───────────────────────────────────────────────── +export function fetchProjects(): Promise { + return request('/projects') +} +export function fetchProject(id: number): Promise { + return request(`/projects/${id}`) +} +export function createProject(body: { name: string; description?: string; colour?: string }): Promise { + return request('/projects', { method: 'POST', body: JSON.stringify(body) }) +} +export function updateProject(id: number, body: Partial): Promise { + return request(`/projects/${id}`, { method: 'PATCH', body: JSON.stringify(body) }) +} +export function deleteProject(id: number): Promise<{ ok: boolean }> { + return request(`/projects/${id}`, { method: 'DELETE' }) +} // ── Checklist items ────────────────────────────────────────── export function addItem(taskId: number, label: string): Promise { diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index c804344..498b1e4 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { NavLink, useLocation } from 'react-router-dom' -import { ListChecks, CalendarDays, Columns3, Settings, Menu, LogOut, Plus } from 'lucide-react' +import { ListChecks, CalendarDays, Columns3, FolderKanban, Send, Settings, Menu, LogOut, Plus } from 'lucide-react' import { useAuth } from './AuthGate' import { useLists } from '../context/ListsContext' import { can } from '../types' @@ -38,6 +38,14 @@ export default function Layout({ children }: { children: React.ReactNode }) { isActive ? 'active' : ''}> Board + isActive ? 'active' : ''}> + Projects + + {can(user, 'reallocate') && ( + isActive ? 'active' : ''}> + Dispatch + + )}
diff --git a/frontend/src/components/ProjectEditor.tsx b/frontend/src/components/ProjectEditor.tsx new file mode 100644 index 0000000..abd470b --- /dev/null +++ b/frontend/src/components/ProjectEditor.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { X, Trash2 } from 'lucide-react' +import type { Project } from '../types' +import { LIST_COLOURS } from '../types' +import { createProject, updateProject, deleteProject } from '../api' +import { useProjects } from '../context/ProjectsContext' + +export default function ProjectEditor({ project, onClose }: { project: Project | null; onClose: () => void }) { + const { reload } = useProjects() + const navigate = useNavigate() + const [name, setName] = useState(project?.name ?? '') + const [description, setDescription] = useState(project?.description ?? '') + const [colour, setColour] = useState(project?.colour ?? LIST_COLOURS[0]) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + async function save() { + if (!name.trim()) return + setBusy(true); setError(null) + try { + if (project) { + await updateProject(project.id, { name: name.trim(), description: description.trim() || null, colour }) + await reload() + onClose() + } else { + const created = await createProject({ name: name.trim(), description: description.trim() || undefined, colour }) + await reload() + navigate(`/project/${created.id}`) + onClose() + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Save failed') + setBusy(false) + } + } + + async function remove() { + if (!project) return + if (!confirm(`Delete "${project.name}"? Tasks inside it won't be deleted, they just lose their project.`)) return + setBusy(true) + try { + await deleteProject(project.id) + await reload() + navigate('/projects') + onClose() + } catch (e) { + setError(e instanceof Error ? e.message : 'Delete failed') + setBusy(false) + } + } + + return ( +
+
e.stopPropagation()}> +
+

{project ? 'Edit project' : 'New project'}

+ +
+ + {error &&
{error}
} + +
+ + setName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') save() }} + placeholder="e.g. Refurb — Rooms 1-12" /> +
+ +
+ +