Add project filter, Shared/Assigned views, and task search
- Backend: GET /api/tasks accepts ?search=, ILIKE on title/notes - Generalized CategoryFilter into a reusable FilterChips component, added ProjectFilter alongside it (both now on Board/List/Today) - New Shared-with-me / Assigned-to-me nav views (FilteredListView), surfacing the view=shared/view=assigned params that already existed backend-side but had no UI - Global debounced (300ms) search bar in the layout header, wired into Board/List/Today/Dispatch's task fetches via SearchContext
This commit is contained in:
parent
017f6dfdc1
commit
4eab3c29ed
12 changed files with 227 additions and 53 deletions
|
|
@ -74,9 +74,9 @@ export async function taskRoutes(app) {
|
||||||
// ?view=shared = tasks I've been tagged into (not mine, not assigned to me).
|
// ?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.
|
// ?view=all = every user's tasks, dispatcher board for the 'reallocate' cap.
|
||||||
// Otherwise defaults to everything I own. ?list_id/?status/?category_id/
|
// Otherwise defaults to everything I own. ?list_id/?status/?category_id/
|
||||||
// ?project_id narrow further.
|
// ?project_id/?search narrow further (search matches title or notes, case-insensitive).
|
||||||
app.get('/api/tasks', { preHandler: requireCap('use') }, async (req, reply) => {
|
app.get('/api/tasks', { preHandler: requireCap('use') }, async (req, reply) => {
|
||||||
const { list_id, view, status, category_id, project_id } = req.query
|
const { list_id, view, status, category_id, project_id, search } = req.query
|
||||||
const vals = [req.user.email]
|
const vals = [req.user.email]
|
||||||
let i = 2
|
let i = 2
|
||||||
const clauses = []
|
const clauses = []
|
||||||
|
|
@ -101,6 +101,7 @@ export async function taskRoutes(app) {
|
||||||
if (status) { clauses.push(`t.status = $${i++}`); vals.push(status) }
|
if (status) { clauses.push(`t.status = $${i++}`); vals.push(status) }
|
||||||
if (category_id) { clauses.push(`t.category_id = $${i++}`); vals.push(category_id) }
|
if (category_id) { clauses.push(`t.category_id = $${i++}`); vals.push(category_id) }
|
||||||
if (project_id) { clauses.push(`t.project_id = $${i++}`); vals.push(project_id) }
|
if (project_id) { clauses.push(`t.project_id = $${i++}`); vals.push(project_id) }
|
||||||
|
if (search) { const idx = i++; clauses.push(`(t.title ILIKE $${idx} OR t.notes ILIKE $${idx})`); vals.push(`%${search}%`) }
|
||||||
if (view === 'today') {
|
if (view === 'today') {
|
||||||
clauses.push(`NOT s.is_done`)
|
clauses.push(`NOT s.is_done`)
|
||||||
clauses.push(`t.due_at IS NOT NULL`)
|
clauses.push(`t.due_at IS NOT NULL`)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { ListsProvider } from './context/ListsContext'
|
||||||
import { StatusesProvider } from './context/StatusesContext'
|
import { StatusesProvider } from './context/StatusesContext'
|
||||||
import { CategoriesProvider } from './context/CategoriesContext'
|
import { CategoriesProvider } from './context/CategoriesContext'
|
||||||
import { ProjectsProvider } from './context/ProjectsContext'
|
import { ProjectsProvider } from './context/ProjectsContext'
|
||||||
|
import { SearchProvider } from './context/SearchContext'
|
||||||
import { TaskDrawerProvider } from './components/TaskDrawer'
|
import { TaskDrawerProvider } from './components/TaskDrawer'
|
||||||
import TodayView from './pages/TodayView'
|
import TodayView from './pages/TodayView'
|
||||||
import ListView from './pages/ListView'
|
import ListView from './pages/ListView'
|
||||||
|
|
@ -15,6 +16,7 @@ import BoardView from './pages/BoardView'
|
||||||
import ProjectsView from './pages/ProjectsView'
|
import ProjectsView from './pages/ProjectsView'
|
||||||
import ProjectDetailView from './pages/ProjectDetailView'
|
import ProjectDetailView from './pages/ProjectDetailView'
|
||||||
import DispatchView from './pages/DispatchView'
|
import DispatchView from './pages/DispatchView'
|
||||||
|
import FilteredListView from './pages/FilteredListView'
|
||||||
import Settings from './pages/Settings'
|
import Settings from './pages/Settings'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
|
@ -27,21 +29,25 @@ export default function App() {
|
||||||
<StatusesProvider>
|
<StatusesProvider>
|
||||||
<CategoriesProvider>
|
<CategoriesProvider>
|
||||||
<ProjectsProvider>
|
<ProjectsProvider>
|
||||||
<TaskDrawerProvider>
|
<SearchProvider>
|
||||||
<Layout>
|
<TaskDrawerProvider>
|
||||||
<Routes>
|
<Layout>
|
||||||
<Route path="/" element={<Navigate to="/today" replace />} />
|
<Routes>
|
||||||
<Route path="/today" element={<TodayView />} />
|
<Route path="/" element={<Navigate to="/today" replace />} />
|
||||||
<Route path="/board" element={<BoardView />} />
|
<Route path="/today" element={<TodayView />} />
|
||||||
<Route path="/list/:id" element={<ListView />} />
|
<Route path="/board" element={<BoardView />} />
|
||||||
<Route path="/projects" element={<ProjectsView />} />
|
<Route path="/list/:id" element={<ListView />} />
|
||||||
<Route path="/project/:id" element={<ProjectDetailView />} />
|
<Route path="/projects" element={<ProjectsView />} />
|
||||||
<Route path="/dispatch" element={<DispatchView />} />
|
<Route path="/project/:id" element={<ProjectDetailView />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/shared" element={<FilteredListView view="shared" title="Shared with me" />} />
|
||||||
<Route path="*" element={<Navigate to="/today" replace />} />
|
<Route path="/assigned" element={<FilteredListView view="assigned" title="Assigned to me" />} />
|
||||||
</Routes>
|
<Route path="/dispatch" element={<DispatchView />} />
|
||||||
</Layout>
|
<Route path="/settings" element={<Settings />} />
|
||||||
</TaskDrawerProvider>
|
<Route path="*" element={<Navigate to="/today" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</Layout>
|
||||||
|
</TaskDrawerProvider>
|
||||||
|
</SearchProvider>
|
||||||
</ProjectsProvider>
|
</ProjectsProvider>
|
||||||
</CategoriesProvider>
|
</CategoriesProvider>
|
||||||
</StatusesProvider>
|
</StatusesProvider>
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ export interface TaskFilters {
|
||||||
status?: string
|
status?: string
|
||||||
category_id?: number
|
category_id?: number
|
||||||
project_id?: number
|
project_id?: number
|
||||||
|
search?: string
|
||||||
}
|
}
|
||||||
export function fetchTasks(params: TaskFilters = {}): Promise<Task[]> {
|
export function fetchTasks(params: TaskFilters = {}): Promise<Task[]> {
|
||||||
const qs = new URLSearchParams()
|
const qs = new URLSearchParams()
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { NavLink, useLocation } from 'react-router-dom'
|
import { NavLink, useLocation } from 'react-router-dom'
|
||||||
import { ListChecks, CalendarDays, Columns3, FolderKanban, Send, Settings, Menu, LogOut, Plus } from 'lucide-react'
|
import { ListChecks, CalendarDays, Columns3, FolderKanban, Send, Share2, UserCheck, Settings, Menu, LogOut, Plus, Search, X } from 'lucide-react'
|
||||||
import { useAuth } from './AuthGate'
|
import { useAuth } from './AuthGate'
|
||||||
import { useLists } from '../context/ListsContext'
|
import { useLists } from '../context/ListsContext'
|
||||||
|
import { useSearch } from '../context/SearchContext'
|
||||||
import { can } from '../types'
|
import { can } from '../types'
|
||||||
import type { List } from '../types'
|
import type { List } from '../types'
|
||||||
import ListEditor from './ListEditor'
|
import ListEditor from './ListEditor'
|
||||||
|
|
@ -12,6 +13,7 @@ const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { lists } = useLists()
|
const { lists } = useLists()
|
||||||
|
const { query, setQuery } = useSearch()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [menuOpen, setMenuOpen] = useState(false)
|
const [menuOpen, setMenuOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<null | 'new' | List>(null)
|
const [editing, setEditing] = useState<null | 'new' | List>(null)
|
||||||
|
|
@ -41,6 +43,12 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
<NavLink to="/projects" className={({ isActive }) => isActive ? 'active' : ''}>
|
<NavLink to="/projects" className={({ isActive }) => isActive ? 'active' : ''}>
|
||||||
<FolderKanban {...ICON_PROPS} /> Projects
|
<FolderKanban {...ICON_PROPS} /> Projects
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink to="/shared" className={({ isActive }) => isActive ? 'active' : ''}>
|
||||||
|
<Share2 {...ICON_PROPS} /> Shared with me
|
||||||
|
</NavLink>
|
||||||
|
<NavLink to="/assigned" className={({ isActive }) => isActive ? 'active' : ''}>
|
||||||
|
<UserCheck {...ICON_PROPS} /> Assigned to me
|
||||||
|
</NavLink>
|
||||||
{can(user, 'reallocate') && (
|
{can(user, 'reallocate') && (
|
||||||
<NavLink to="/dispatch" className={({ isActive }) => isActive ? 'active' : ''}>
|
<NavLink to="/dispatch" className={({ isActive }) => isActive ? 'active' : ''}>
|
||||||
<Send {...ICON_PROPS} /> Dispatch
|
<Send {...ICON_PROPS} /> Dispatch
|
||||||
|
|
@ -100,6 +108,15 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="page-content">
|
<main className="page-content">
|
||||||
|
<div className="search-bar">
|
||||||
|
<Search size={15} strokeWidth={1.75} />
|
||||||
|
<input value={query} placeholder="Search tasks…" onChange={e => setQuery(e.target.value)} />
|
||||||
|
{query && (
|
||||||
|
<button className="btn-ghost-sm" onClick={() => setQuery('')} aria-label="Clear search">
|
||||||
|
<X size={14} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Check, ChevronDown, Tag } from 'lucide-react'
|
import { Check, ChevronDown, Tag, FolderKanban, type LucideIcon } from 'lucide-react'
|
||||||
import type { Priority, Category } from '../types'
|
import type { Priority } from '../types'
|
||||||
import { PRIORITY_LABELS } from '../types'
|
import { PRIORITY_LABELS } from '../types'
|
||||||
import { useCategories } from '../context/CategoriesContext'
|
import { useCategories } from '../context/CategoriesContext'
|
||||||
|
import { useProjects } from '../context/ProjectsContext'
|
||||||
|
|
||||||
export function PriorityBadge({ priority }: { priority: Priority }) {
|
export function PriorityBadge({ priority }: { priority: Priority }) {
|
||||||
if (priority === 'medium') return null
|
if (priority === 'medium') return null
|
||||||
|
|
@ -19,26 +20,31 @@ export function Check_({ isDone, onClick }: { isDone: boolean; onClick: (e: Reac
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapsible multi-select category filter chip-bar — used on Board/List views.
|
interface FilterableEntity { id: number; name: string; colour: string }
|
||||||
// `selected` empty means "no filter" (show everything).
|
|
||||||
export function CategoryFilter({ selected, onChange }: { selected: number[]; onChange: (ids: number[]) => void }) {
|
// Collapsible multi-select filter chip-bar — generic over anything with an
|
||||||
const { categories } = useCategories()
|
// id/name/colour (categories, projects, ...). `selected` empty means "no
|
||||||
|
// filter" (show everything).
|
||||||
|
function FilterChips<T extends FilterableEntity>({ items, selected, onChange, label, icon: Icon }: {
|
||||||
|
items: T[]; selected: number[]; onChange: (ids: number[]) => void
|
||||||
|
label: string; icon: LucideIcon
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
if (categories.length === 0) return null
|
if (items.length === 0) return null
|
||||||
|
|
||||||
function toggle(id: number) {
|
function toggle(id: number) {
|
||||||
onChange(selected.includes(id) ? selected.filter(i => i !== id) : [...selected, id])
|
onChange(selected.includes(id) ? selected.filter(i => i !== id) : [...selected, id])
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary = selected.length === 0
|
const summary = selected.length === 0
|
||||||
? 'All categories'
|
? `All ${label.toLowerCase()}s`
|
||||||
: categories.filter(c => selected.includes(c.id)).map(c => c.name).join(', ')
|
: items.filter(x => selected.includes(x.id)).map(x => x.name).join(', ')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="filter-section">
|
<div className="filter-section">
|
||||||
<div className="filter-section-header" onClick={() => setOpen(o => !o)}>
|
<div className="filter-section-header" onClick={() => setOpen(o => !o)}>
|
||||||
<Tag size={13} strokeWidth={1.75} />
|
<Icon size={13} strokeWidth={1.75} />
|
||||||
<span className="filter-section-label">Category</span>
|
<span className="filter-section-label">{label}</span>
|
||||||
<span className={`filter-section-summary${selected.length === 0 ? ' muted' : ''}`}>{summary}</span>
|
<span className={`filter-section-summary${selected.length === 0 ? ' muted' : ''}`}>{summary}</span>
|
||||||
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${open ? ' open' : ''}`} />
|
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${open ? ' open' : ''}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -47,10 +53,10 @@ export function CategoryFilter({ selected, onChange }: { selected: number[]; onC
|
||||||
{selected.length > 0 && (
|
{selected.length > 0 && (
|
||||||
<button className="chip" onClick={() => onChange([])}>Clear</button>
|
<button className="chip" onClick={() => onChange([])}>Clear</button>
|
||||||
)}
|
)}
|
||||||
{categories.map((c: Category) => (
|
{items.map(x => (
|
||||||
<button key={c.id} className={`chip${selected.includes(c.id) ? ' active' : ''}`} onClick={() => toggle(c.id)}>
|
<button key={x.id} className={`chip${selected.includes(x.id) ? ' active' : ''}`} onClick={() => toggle(x.id)}>
|
||||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: c.colour, display: 'inline-block' }} />
|
<span style={{ width: 8, height: 8, borderRadius: '50%', background: x.colour, display: 'inline-block' }} />
|
||||||
{c.name}
|
{x.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -59,6 +65,16 @@ export function CategoryFilter({ selected, onChange }: { selected: number[]; onC
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CategoryFilter({ selected, onChange }: { selected: number[]; onChange: (ids: number[]) => void }) {
|
||||||
|
const { categories } = useCategories()
|
||||||
|
return <FilterChips items={categories} selected={selected} onChange={onChange} label="Category" icon={Tag} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectFilter({ selected, onChange }: { selected: number[]; onChange: (ids: number[]) => void }) {
|
||||||
|
const { projects } = useProjects()
|
||||||
|
return <FilterChips items={projects} selected={selected} onChange={onChange} label="Project" icon={FolderKanban} />
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDate(iso: string | null | undefined): string {
|
export function formatDate(iso: string | null | undefined): string {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
|
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||||
|
|
|
||||||
28
frontend/src/context/SearchContext.tsx
Normal file
28
frontend/src/context/SearchContext.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { createContext, useContext, useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
interface SearchCtx {
|
||||||
|
query: string
|
||||||
|
setQuery: (q: string) => void
|
||||||
|
// Debounced 300ms so views don't refetch on every keystroke.
|
||||||
|
debouncedQuery: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const Ctx = createContext<SearchCtx | null>(null)
|
||||||
|
|
||||||
|
export function useSearch() {
|
||||||
|
const ctx = useContext(Ctx)
|
||||||
|
if (!ctx) throw new Error('useSearch must be used inside SearchProvider')
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebouncedQuery(query.trim()), 300)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
return <Ctx.Provider value={{ query, setQuery, debouncedQuery }}>{children}</Ctx.Provider>
|
||||||
|
}
|
||||||
|
|
@ -570,6 +570,20 @@ table.data tr.clickable:hover td { background: var(--body-bg); }
|
||||||
.board-col-head { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }
|
.board-col-head { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }
|
||||||
.board-col-count { margin-left: auto; font-weight: 500; }
|
.board-col-count { margin-left: auto; font-weight: 500; }
|
||||||
|
|
||||||
|
/* ── Global search bar (top of page-content, above every routed view) ── */
|
||||||
|
.search-bar {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-bottom: 1px solid var(--card-border);
|
||||||
|
color: var(--text-mid);
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
}
|
||||||
|
.search-bar input {
|
||||||
|
flex: 1; border: none; outline: none; background: none;
|
||||||
|
font-size: 13.5px; font-family: var(--font); color: var(--text-dark);
|
||||||
|
}
|
||||||
|
|
||||||
/* Agenda groups (Today view) */
|
/* Agenda groups (Today view) */
|
||||||
.agenda-group-title { font-size: 13px; font-weight: 700; color: var(--text-dark); margin: 16px 0 8px; display: flex; align-items: center; gap: 8px; }
|
.agenda-group-title { font-size: 13px; font-weight: 700; color: var(--text-dark); margin: 16px 0 8px; display: flex; align-items: center; gap: 8px; }
|
||||||
.agenda-group-title.overdue { color: var(--danger); }
|
.agenda-group-title.overdue { color: var(--danger); }
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,23 @@ import type { Task } from '../types'
|
||||||
import { fetchTasks, updateTask } from '../api'
|
import { fetchTasks, updateTask } from '../api'
|
||||||
import { useTaskDrawer } from '../components/TaskDrawer'
|
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||||
import { useStatuses } from '../context/StatusesContext'
|
import { useStatuses } from '../context/StatusesContext'
|
||||||
import { PriorityBadge, dueInfo, CategoryFilter } from '../components/shared'
|
import { useSearch } from '../context/SearchContext'
|
||||||
|
import { PriorityBadge, dueInfo, CategoryFilter, ProjectFilter } from '../components/shared'
|
||||||
|
|
||||||
export default function BoardView() {
|
export default function BoardView() {
|
||||||
const { openTask, changeVersion } = useTaskDrawer()
|
const { openTask, changeVersion } = useTaskDrawer()
|
||||||
const { statuses } = useStatuses()
|
const { statuses } = useStatuses()
|
||||||
|
const { debouncedQuery } = useSearch()
|
||||||
const [tasks, setTasks] = useState<Task[]>([])
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [over, setOver] = useState<string | null>(null)
|
const [over, setOver] = useState<string | null>(null)
|
||||||
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
|
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
|
||||||
|
const [projectFilter, setProjectFilter] = useState<number[]>([])
|
||||||
const dragId = useRef<number | null>(null)
|
const dragId = useRef<number | null>(null)
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
fetchTasks({}).then(setTasks).catch(e => setError(e.message))
|
fetchTasks({ search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
|
||||||
}, [])
|
}, [debouncedQuery])
|
||||||
useEffect(() => { load() }, [load, changeVersion])
|
useEffect(() => { load() }, [load, changeVersion])
|
||||||
|
|
||||||
async function drop(status: string) {
|
async function drop(status: string) {
|
||||||
|
|
@ -32,9 +35,9 @@ export default function BoardView() {
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibleTasks = categoryFilter.length === 0
|
const visibleTasks = tasks
|
||||||
? tasks
|
.filter(t => categoryFilter.length === 0 || (t.category_id !== null && categoryFilter.includes(t.category_id)))
|
||||||
: tasks.filter(t => t.category_id !== null && categoryFilter.includes(t.category_id))
|
.filter(t => projectFilter.length === 0 || (t.project_id !== null && projectFilter.includes(t.project_id)))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page" style={{ maxWidth: 1200 }}>
|
<div className="page" style={{ maxWidth: 1200 }}>
|
||||||
|
|
@ -42,6 +45,7 @@ export default function BoardView() {
|
||||||
<h1><Columns3 size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Board</h1>
|
<h1><Columns3 size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Board</h1>
|
||||||
</div>
|
</div>
|
||||||
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
|
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
|
||||||
|
<ProjectFilter selected={projectFilter} onChange={setProjectFilter} />
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
<div className="board">
|
<div className="board">
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Send } from 'lucide-react'
|
||||||
import type { Task } from '../types'
|
import type { Task } from '../types'
|
||||||
import { fetchTasks, reallocateTask } from '../api'
|
import { fetchTasks, reallocateTask } from '../api'
|
||||||
import { useAuth } from '../components/AuthGate'
|
import { useAuth } from '../components/AuthGate'
|
||||||
|
import { useSearch } from '../context/SearchContext'
|
||||||
import { can } from '../types'
|
import { can } from '../types'
|
||||||
import { dueInfo, PriorityBadge } from '../components/shared'
|
import { dueInfo, PriorityBadge } from '../components/shared'
|
||||||
|
|
||||||
|
|
@ -12,14 +13,15 @@ import { dueInfo, PriorityBadge } from '../components/shared'
|
||||||
// only needs enough per-row info to decide where a task should go.
|
// only needs enough per-row info to decide where a task should go.
|
||||||
export default function DispatchView() {
|
export default function DispatchView() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
const { debouncedQuery } = useSearch()
|
||||||
const [tasks, setTasks] = useState<Task[]>([])
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [movingId, setMovingId] = useState<number | null>(null)
|
const [movingId, setMovingId] = useState<number | null>(null)
|
||||||
const [target, setTarget] = useState('')
|
const [target, setTarget] = useState('')
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
fetchTasks({ view: 'all' }).then(setTasks).catch(e => setError(e.message))
|
fetchTasks({ view: 'all', search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
|
||||||
}, [])
|
}, [debouncedQuery])
|
||||||
useEffect(() => { load() }, [load])
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
if (!can(user, 'reallocate')) {
|
if (!can(user, 'reallocate')) {
|
||||||
|
|
|
||||||
70
frontend/src/pages/FilteredListView.tsx
Normal file
70
frontend/src/pages/FilteredListView.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { Share2, UserCheck } 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 TaskRow from '../components/TaskRow'
|
||||||
|
import { CategoryFilter, ProjectFilter } from '../components/shared'
|
||||||
|
|
||||||
|
const ICONS = { shared: Share2, assigned: UserCheck }
|
||||||
|
|
||||||
|
export default function FilteredListView({ view, title }: { view: 'shared' | 'assigned'; title: string }) {
|
||||||
|
const { openTask, changeVersion } = useTaskDrawer()
|
||||||
|
const { firstDoneValue, firstNotDoneValue } = useStatuses()
|
||||||
|
const { debouncedQuery } = useSearch()
|
||||||
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
|
||||||
|
const [projectFilter, setProjectFilter] = useState<number[]>([])
|
||||||
|
const Icon = ICONS[view]
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
fetchTasks({ view, search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
|
||||||
|
}, [view, debouncedQuery])
|
||||||
|
useEffect(() => { load() }, [load, changeVersion])
|
||||||
|
|
||||||
|
async function toggle(t: Task) {
|
||||||
|
const next = t.is_done ? firstNotDoneValue() : firstDoneValue()
|
||||||
|
if (!next) return
|
||||||
|
await updateTask(t.id, { status: next }).catch(() => {})
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
const visible = tasks
|
||||||
|
.filter(t => categoryFilter.length === 0 || (t.category_id !== null && categoryFilter.includes(t.category_id)))
|
||||||
|
.filter(t => projectFilter.length === 0 || (t.project_id !== null && projectFilter.includes(t.project_id)))
|
||||||
|
const open = visible.filter(t => !t.is_done)
|
||||||
|
const done = visible.filter(t => t.is_done)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<div className="page-header">
|
||||||
|
<h1><Icon size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />{title}</h1>
|
||||||
|
</div>
|
||||||
|
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
|
||||||
|
<ProjectFilter selected={projectFilter} onChange={setProjectFilter} />
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
|
{open.length === 0 && done.length === 0 && (
|
||||||
|
<div className="empty-state">
|
||||||
|
{view === 'shared' ? "Nobody's tagged you on anything." : "Nothing's been handed to you."}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open.map(t => (
|
||||||
|
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{done.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="section-title">Completed ({done.length})</div>
|
||||||
|
{done.map(t => (
|
||||||
|
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,16 +5,18 @@ import type { Task, List } from '../types'
|
||||||
import { fetchTasks, createTask, updateTask, reorderTasks } from '../api'
|
import { fetchTasks, createTask, updateTask, reorderTasks } from '../api'
|
||||||
import { useLists } from '../context/ListsContext'
|
import { useLists } from '../context/ListsContext'
|
||||||
import { useStatuses } from '../context/StatusesContext'
|
import { useStatuses } from '../context/StatusesContext'
|
||||||
|
import { useSearch } from '../context/SearchContext'
|
||||||
import { useTaskDrawer } from '../components/TaskDrawer'
|
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||||
import TaskRow from '../components/TaskRow'
|
import TaskRow from '../components/TaskRow'
|
||||||
import ListEditor from '../components/ListEditor'
|
import ListEditor from '../components/ListEditor'
|
||||||
import { CategoryFilter } from '../components/shared'
|
import { CategoryFilter, ProjectFilter } from '../components/shared'
|
||||||
|
|
||||||
export default function ListView() {
|
export default function ListView() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const listId = Number(id)
|
const listId = Number(id)
|
||||||
const { lists } = useLists()
|
const { lists } = useLists()
|
||||||
const { firstDoneValue, firstNotDoneValue } = useStatuses()
|
const { firstDoneValue, firstNotDoneValue } = useStatuses()
|
||||||
|
const { debouncedQuery } = useSearch()
|
||||||
const { openTask, changeVersion } = useTaskDrawer()
|
const { openTask, changeVersion } = useTaskDrawer()
|
||||||
const list = lists.find(l => l.id === listId)
|
const list = lists.find(l => l.id === listId)
|
||||||
|
|
||||||
|
|
@ -23,12 +25,13 @@ export default function ListView() {
|
||||||
const [newTitle, setNewTitle] = useState('')
|
const [newTitle, setNewTitle] = useState('')
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
|
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
|
||||||
|
const [projectFilter, setProjectFilter] = useState<number[]>([])
|
||||||
const dragId = useRef<number | null>(null)
|
const dragId = useRef<number | null>(null)
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
if (!listId) return
|
if (!listId) return
|
||||||
fetchTasks({ list_id: listId }).then(setTasks).catch(e => setError(e.message))
|
fetchTasks({ list_id: listId, search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
|
||||||
}, [listId])
|
}, [listId, debouncedQuery])
|
||||||
useEffect(() => { load() }, [load, changeVersion])
|
useEffect(() => { load() }, [load, changeVersion])
|
||||||
|
|
||||||
async function add() {
|
async function add() {
|
||||||
|
|
@ -59,9 +62,9 @@ export default function ListView() {
|
||||||
reorderTasks(ids).catch(() => load())
|
reorderTasks(ids).catch(() => load())
|
||||||
}
|
}
|
||||||
|
|
||||||
const visible = categoryFilter.length === 0
|
const visible = tasks
|
||||||
? tasks
|
.filter(t => categoryFilter.length === 0 || (t.category_id !== null && categoryFilter.includes(t.category_id)))
|
||||||
: tasks.filter(t => t.category_id !== null && categoryFilter.includes(t.category_id))
|
.filter(t => projectFilter.length === 0 || (t.project_id !== null && projectFilter.includes(t.project_id)))
|
||||||
const open = visible.filter(t => !t.is_done)
|
const open = visible.filter(t => !t.is_done)
|
||||||
const done = visible.filter(t => t.is_done)
|
const done = visible.filter(t => t.is_done)
|
||||||
|
|
||||||
|
|
@ -79,6 +82,7 @@ export default function ListView() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
|
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
|
||||||
|
<ProjectFilter selected={projectFilter} onChange={setProjectFilter} />
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
<div className="task-add">
|
<div className="task-add">
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,24 @@ import type { Task } from '../types'
|
||||||
import { fetchTasks, updateTask } from '../api'
|
import { fetchTasks, updateTask } from '../api'
|
||||||
import { useTaskDrawer } from '../components/TaskDrawer'
|
import { useTaskDrawer } from '../components/TaskDrawer'
|
||||||
import { useStatuses } from '../context/StatusesContext'
|
import { useStatuses } from '../context/StatusesContext'
|
||||||
|
import { useSearch } from '../context/SearchContext'
|
||||||
import TaskRow from '../components/TaskRow'
|
import TaskRow from '../components/TaskRow'
|
||||||
|
import { CategoryFilter, ProjectFilter } from '../components/shared'
|
||||||
|
|
||||||
function startOfDay(d: Date) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x }
|
function startOfDay(d: Date) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x }
|
||||||
|
|
||||||
export default function TodayView() {
|
export default function TodayView() {
|
||||||
const { openTask, changeVersion } = useTaskDrawer()
|
const { openTask, changeVersion } = useTaskDrawer()
|
||||||
const { firstDoneValue, firstNotDoneValue } = useStatuses()
|
const { firstDoneValue, firstNotDoneValue } = useStatuses()
|
||||||
|
const { debouncedQuery } = useSearch()
|
||||||
const [tasks, setTasks] = useState<Task[]>([])
|
const [tasks, setTasks] = useState<Task[]>([])
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
|
||||||
|
const [projectFilter, setProjectFilter] = useState<number[]>([])
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
fetchTasks({ view: 'today' }).then(setTasks).catch(e => setError(e.message))
|
fetchTasks({ view: 'today', search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
|
||||||
}, [])
|
}, [debouncedQuery])
|
||||||
useEffect(() => { load() }, [load, changeVersion])
|
useEffect(() => { load() }, [load, changeVersion])
|
||||||
|
|
||||||
async function toggle(t: Task) {
|
async function toggle(t: Task) {
|
||||||
|
|
@ -26,13 +31,17 @@ export default function TodayView() {
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visible = tasks
|
||||||
|
.filter(t => categoryFilter.length === 0 || (t.category_id !== null && categoryFilter.includes(t.category_id)))
|
||||||
|
.filter(t => projectFilter.length === 0 || (t.project_id !== null && projectFilter.includes(t.project_id)))
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const todayStart = startOfDay(now)
|
const todayStart = startOfDay(now)
|
||||||
const tomorrowStart = new Date(todayStart); tomorrowStart.setDate(tomorrowStart.getDate() + 1)
|
const tomorrowStart = new Date(todayStart); tomorrowStart.setDate(tomorrowStart.getDate() + 1)
|
||||||
|
|
||||||
const overdue = tasks.filter(t => t.due_at && new Date(t.due_at) < todayStart)
|
const overdue = visible.filter(t => t.due_at && new Date(t.due_at) < todayStart)
|
||||||
const today = tasks.filter(t => t.due_at && new Date(t.due_at) >= todayStart && new Date(t.due_at) < tomorrowStart)
|
const today = visible.filter(t => t.due_at && new Date(t.due_at) >= todayStart && new Date(t.due_at) < tomorrowStart)
|
||||||
const upcoming = tasks.filter(t => t.due_at && 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 }> = [
|
const groups: Array<{ key: string; title: string; items: Task[]; overdue?: boolean }> = [
|
||||||
{ key: 'overdue', title: 'Overdue', items: overdue, overdue: true },
|
{ key: 'overdue', title: 'Overdue', items: overdue, overdue: true },
|
||||||
|
|
@ -45,9 +54,11 @@ export default function TodayView() {
|
||||||
<div className="page-header">
|
<div className="page-header">
|
||||||
<h1><CalendarDays size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Today</h1>
|
<h1><CalendarDays size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Today</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
|
||||||
|
<ProjectFilter selected={projectFilter} onChange={setProjectFilter} />
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
|
||||||
{tasks.length === 0 && (
|
{visible.length === 0 && (
|
||||||
<div className="empty-state">Nothing due in the next 7 days. Enjoy the calm. ☕</div>
|
<div className="empty-state">Nothing due in the next 7 days. Enjoy the calm. ☕</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue