various enhancements for new tabs and bug fixes
This commit is contained in:
@@ -161,6 +161,7 @@ class BaseRepository:
|
|||||||
"category", "instructions", "expected_output", "estimated_days",
|
"category", "instructions", "expected_output", "estimated_days",
|
||||||
"contact_id", "started_at",
|
"contact_id", "started_at",
|
||||||
"weekly_hours", "effective_from",
|
"weekly_hours", "effective_from",
|
||||||
|
"task_id", "meeting_id",
|
||||||
}
|
}
|
||||||
clean_data = {}
|
clean_data = {}
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
|
|||||||
337
lifeos-claude-code-prompts_R1_1.md
Normal file
337
lifeos-claude-code-prompts_R1_1.md
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
# Life OS - Claude Code Prompts for DEV Enhancements
|
||||||
|
|
||||||
|
**Target Environment:** DEV only (lifeos-dev.invixiom.com)
|
||||||
|
**Codebase Location:** `/opt/lifeos/dev/`
|
||||||
|
**Hot Reload:** Changes to files in `/opt/lifeos/dev/` are picked up automatically by uvicorn `--reload`. No container rebuild needed.
|
||||||
|
**Database:** `lifeos_dev` on container `lifeos-db` (PostgreSQL 16)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PROMPT 1: Task Detail Page - Add Tabs
|
||||||
|
|
||||||
|
**Context:** The task detail page at `/tasks/{task_id}` currently shows task metadata but does NOT use the tabbed layout. The project detail page at `/projects/{project_id}` (template: `project_detail.html`) IS the reference implementation for tabbed detail views. It uses a `?tab=` query parameter to switch between tabs, with tab content rendered server-side via Jinja2 conditionals.
|
||||||
|
|
||||||
|
**Codebase facts:**
|
||||||
|
- Stack: Python 3.12, FastAPI, SQLAlchemy async, Jinja2 templates, vanilla JS/CSS
|
||||||
|
- Template to modify: `templates/task_detail.html`
|
||||||
|
- Router to modify: `routers/tasks.py` (the `task_detail` GET handler at `/tasks/{task_id}`)
|
||||||
|
- Reference template: `templates/project_detail.html` - copy the tab-strip HTML structure and `?tab=` query param pattern exactly
|
||||||
|
- CSS: `static/style.css` already has `.tab-strip` and `.tab-content` classes from the project detail implementation
|
||||||
|
- BaseRepository pattern: `core/base_repository.py` provides generic CRUD. All queries use `is_deleted = false` filtering automatically.
|
||||||
|
|
||||||
|
**Existing DB tables/junctions (already in schema, may not have app code):**
|
||||||
|
- `notes` - has `domain_id`, `project_id` columns. Notes don't have a `task_id` FK directly. You'll need to either add a `task_id` FK column to notes OR use a polymorphic junction (e.g., `note_tasks` or use `file_mappings` pattern with `context_type='task'`). Prefer adding a nullable `task_id UUID REFERENCES tasks(id)` column to the `notes` table for simplicity.
|
||||||
|
- `weblinks` - has `domain_id`, `project_id`. Same situation as notes - add nullable `task_id UUID REFERENCES tasks(id)`.
|
||||||
|
- `file_mappings` - polymorphic junction: `file_id, context_type, context_id`. Already supports `context_type='task'`. Files tab should query `file_mappings WHERE context_type='task' AND context_id={task_id}` joined to `files`.
|
||||||
|
- `lists` - has `domain_id`, `project_id`. Add nullable `task_id UUID REFERENCES tasks(id)`.
|
||||||
|
- `decisions` - has `domain_id`, `project_id`. Add nullable `task_id UUID REFERENCES tasks(id)`.
|
||||||
|
- `processes` / `process_runs` - These tables exist in the R1 schema but process CRUD is NOT yet built. The Processes tab should show: (a) a way to apply a process template to this task (create a process_run with context), and (b) list existing process_runs linked to this task. For now, since process CRUD doesn't exist yet, create the tab structure with an empty state placeholder that says "Process management coming soon" and a TODO comment in the code.
|
||||||
|
- `contact_tasks` - junction table already exists with columns: `contact_id, task_id, role, created_at`. This is the Contacts + Roles tab data source.
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
|
||||||
|
1. **Migration SQL** - Create a migration file at `/opt/lifeos/migrations/` that adds:
|
||||||
|
- `ALTER TABLE notes ADD COLUMN task_id UUID REFERENCES tasks(id);`
|
||||||
|
- `ALTER TABLE weblinks ADD COLUMN task_id UUID REFERENCES tasks(id);`
|
||||||
|
- `ALTER TABLE lists ADD COLUMN task_id UUID REFERENCES tasks(id);`
|
||||||
|
- `ALTER TABLE decisions ADD COLUMN task_id UUID REFERENCES tasks(id);`
|
||||||
|
- Create indexes on these new FK columns.
|
||||||
|
- Apply to `lifeos_dev` database via: `docker exec -i lifeos-db psql -U postgres -d lifeos_dev < /opt/lifeos/migrations/NNNN_task_detail_tabs.sql`
|
||||||
|
|
||||||
|
2. **Router** (`routers/tasks.py`) - Modify the task detail GET handler:
|
||||||
|
- Accept `tab` query parameter (default: "overview")
|
||||||
|
- Tabs: overview, notes, weblinks, files, lists, decisions, processes, contacts
|
||||||
|
- For each tab, query the relevant data and pass to template context
|
||||||
|
- For contacts tab: query `contact_tasks` joined to `contacts` WHERE `task_id = {id}`
|
||||||
|
- For files tab: query `file_mappings` WHERE `context_type='task' AND context_id={id}` joined to `files`
|
||||||
|
- For notes/weblinks/lists/decisions: query WHERE `task_id = {id} AND is_deleted = false`
|
||||||
|
|
||||||
|
3. **Template** (`templates/task_detail.html`) - Restructure to match `project_detail.html` tab pattern:
|
||||||
|
- Keep existing task header (title, metadata, action buttons including timer)
|
||||||
|
- Add tab-strip below header with all tab names
|
||||||
|
- Active tab highlighted via CSS class
|
||||||
|
- Tab links use `?tab=tabname` pattern
|
||||||
|
- Each tab section wrapped in Jinja2 `{% if tab == 'notes' %}...{% endif %}` blocks
|
||||||
|
- Overview tab contains what's currently shown (description, subtasks, metadata)
|
||||||
|
- Each entity tab shows a list of related items with an "+ Add" button
|
||||||
|
- Contacts tab shows contact name + role with ability to add/remove
|
||||||
|
|
||||||
|
4. **Contact + Role management on tasks:**
|
||||||
|
- Add endpoint `POST /tasks/{task_id}/contacts/add` accepting `contact_id` and `role` form fields. Insert into `contact_tasks`.
|
||||||
|
- Add endpoint `POST /tasks/{task_id}/contacts/{contact_id}/remove` to delete from `contact_tasks`.
|
||||||
|
- The contacts tab should show a form with a contact dropdown (all non-deleted contacts) and a role text input, plus a list of currently linked contacts with remove buttons.
|
||||||
|
- Role should be a free-text field (not a fixed set yet - we'll define role sets later).
|
||||||
|
|
||||||
|
5. **Update note/weblink/list/decision create forms** to accept optional `task_id` and pre-fill it when creating from within a task detail page. The "+ Add" buttons on each tab should link to the respective create form with `?task_id={task_id}` pre-filled.
|
||||||
|
|
||||||
|
**Do NOT:**
|
||||||
|
- Touch production database or files
|
||||||
|
- Build full process CRUD (just placeholder tab)
|
||||||
|
- Change the existing task list page (`tasks.html`)
|
||||||
|
- Add any npm/frontend build tooling
|
||||||
|
|
||||||
|
**Test:** After implementation, verify at `https://lifeos-dev.invixiom.com/tasks/{any_task_id}` that all tabs render, switching works via URL param, and the contacts add/remove flow works.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PROMPT 2: Project Detail Page - Add Missing Tabs
|
||||||
|
|
||||||
|
**Context:** The project detail page at `/projects/{project_id}` already has a working tabbed layout with tabs for Tasks, Notes, Links (weblinks). It needs additional tabs for: Files, Lists, Processes, Contacts + Roles.
|
||||||
|
|
||||||
|
**Codebase facts:**
|
||||||
|
- Template: `templates/project_detail.html` (already has tab-strip pattern)
|
||||||
|
- Router: `routers/projects.py` (the detail GET handler already accepts `?tab=` param)
|
||||||
|
- Existing tabs: tasks, notes, links/weblinks
|
||||||
|
- `file_mappings` supports `context_type='project'` already
|
||||||
|
- `contact_projects` junction table exists: `contact_id, project_id, role, created_at`
|
||||||
|
- `lists` table has `project_id` FK already
|
||||||
|
- Process CRUD not yet built
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
|
||||||
|
1. **Router** (`routers/projects.py`) - Extend the detail handler:
|
||||||
|
- Add tab cases for: files, lists, processes, contacts
|
||||||
|
- Files: query `file_mappings WHERE context_type='project' AND context_id={project_id}` joined to `files`
|
||||||
|
- Lists: query `lists WHERE project_id={id} AND is_deleted=false`
|
||||||
|
- Processes: placeholder (empty state)
|
||||||
|
- Contacts: query `contact_projects` joined to `contacts` WHERE `project_id={id}`
|
||||||
|
|
||||||
|
2. **Template** (`templates/project_detail.html`) - Add new tabs to existing tab-strip:
|
||||||
|
- Files tab: list of attached files with upload link (`/files/upload?context_type=project&context_id={project_id}`)
|
||||||
|
- Lists tab: list of project lists with link to create (`/lists/create?project_id={project_id}`)
|
||||||
|
- Processes tab: empty state placeholder "Process management coming soon"
|
||||||
|
- Contacts tab: same pattern as Task contacts - dropdown + role input + list with remove
|
||||||
|
|
||||||
|
3. **Contact + Role management on projects:**
|
||||||
|
- `POST /projects/{project_id}/contacts/add` - insert into `contact_projects`
|
||||||
|
- `POST /projects/{project_id}/contacts/{contact_id}/remove` - delete from `contact_projects`
|
||||||
|
- Same UI pattern as task contacts tab
|
||||||
|
|
||||||
|
**Do NOT:**
|
||||||
|
- Modify existing working tabs (tasks, notes, links)
|
||||||
|
- Build process CRUD
|
||||||
|
|
||||||
|
**Test:** Verify at `https://lifeos-dev.invixiom.com/projects/{any_project_id}` that new tabs appear and function.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PROMPT 3: Meeting Detail Page - Add Tabs
|
||||||
|
|
||||||
|
**Context:** The meeting detail page at `/meetings/{meeting_id}` exists but may not have the full tabbed layout. It needs tabs for: Notes, Weblinks, Files, Lists, Processes, Contacts + Roles. Meetings already have an action items / tasks relationship via the `meeting_tasks` junction table.
|
||||||
|
|
||||||
|
**Codebase facts:**
|
||||||
|
- Template: `templates/meeting_detail.html`
|
||||||
|
- Router: `routers/meetings.py`
|
||||||
|
- `contact_meetings` junction exists: `contact_id, meeting_id, role, created_at` (role values: organizer|attendee|optional)
|
||||||
|
- `meeting_tasks` junction exists: `meeting_id, task_id, source` (source: discussed|action_item)
|
||||||
|
- `meetings` table has `notes_body` column for inline meeting notes
|
||||||
|
- Notes, weblinks, lists don't have a `meeting_id` FK column
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
|
||||||
|
1. **Migration SQL** - Add to same or new migration file:
|
||||||
|
- `ALTER TABLE notes ADD COLUMN meeting_id UUID REFERENCES meetings(id);`
|
||||||
|
- `ALTER TABLE weblinks ADD COLUMN meeting_id UUID REFERENCES meetings(id);`
|
||||||
|
- `ALTER TABLE lists ADD COLUMN meeting_id UUID REFERENCES meetings(id);`
|
||||||
|
- Indexes on new columns.
|
||||||
|
- Apply to `lifeos_dev`.
|
||||||
|
|
||||||
|
2. **Router** (`routers/meetings.py`) - Add/extend detail handler with `?tab=` param:
|
||||||
|
- Tabs: overview (existing content: agenda, notes_body, action items), notes, weblinks, files, lists, processes, contacts
|
||||||
|
- Query patterns same as tasks/projects for each tab type
|
||||||
|
- Files via `file_mappings WHERE context_type='meeting'`
|
||||||
|
- Contacts via `contact_meetings` joined to `contacts`
|
||||||
|
|
||||||
|
3. **Template** (`templates/meeting_detail.html`) - Restructure to tab layout matching project_detail.html pattern:
|
||||||
|
- Overview tab: existing meeting content (agenda, meeting notes, action items list)
|
||||||
|
- All other tabs: same list + add pattern as task/project detail
|
||||||
|
|
||||||
|
4. **Contact + Role management on meetings:**
|
||||||
|
- `POST /meetings/{meeting_id}/contacts/add` - insert into `contact_meetings`, role defaults to "attendee"
|
||||||
|
- `POST /meetings/{meeting_id}/contacts/{contact_id}/remove` - delete from `contact_meetings`
|
||||||
|
- Role dropdown with predefined values: organizer, attendee, optional
|
||||||
|
|
||||||
|
**Test:** Verify at `https://lifeos-dev.invixiom.com/meetings/{any_meeting_id}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PROMPT 4: Lists Page - Dynamic Domain/Project Filter + Contact Roles
|
||||||
|
|
||||||
|
**Context:** The lists page at `/lists/` has domain and project dropdown filters. Currently the project dropdown shows ALL projects regardless of which domain is selected. It should filter dynamically.
|
||||||
|
|
||||||
|
**Codebase facts:**
|
||||||
|
- Template: `templates/lists.html`
|
||||||
|
- Router: `routers/lists.py` (or wherever list CRUD lives)
|
||||||
|
- The filter uses HTML `<select>` elements that auto-submit on change (pattern from `app.js`)
|
||||||
|
- `projects` table has `domain_id` FK (nullable - some projects may have domain_id = NULL via unassigned area)
|
||||||
|
- `areas` table has `domain_id` FK
|
||||||
|
- `contact_lists` junction exists: `contact_id, list_id, role, created_at`
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
|
||||||
|
1. **Dynamic project filtering by domain** - Two approaches, pick the simpler one:
|
||||||
|
- **Option A (recommended - vanilla JS):** Add a small JS block to `lists.html` (or `app.js`) that intercepts the domain dropdown `change` event. When domain changes, fetch projects for that domain via a new lightweight API endpoint `GET /api/projects?domain_id={id}` that returns JSON `[{id, name}]`. Rebuild the project `<select>` options dynamically. Include projects where `domain_id` matches OR where `domain_id IS NULL` (unassigned). If no domain selected, show all projects.
|
||||||
|
- **Option B (server-side only):** On domain change, the form auto-submits (existing behavior). The router filters the project dropdown options server-side based on the selected domain_id and passes filtered projects to the template. This is simpler but causes a page reload.
|
||||||
|
- Go with Option A for better UX. Create the API endpoint in the lists router or a shared API router.
|
||||||
|
|
||||||
|
2. **Contact + Role management on lists:**
|
||||||
|
- On the list detail page (`list_detail.html`), add a Contacts section (or tab if it has tabs).
|
||||||
|
- `POST /lists/{list_id}/contacts/add` - insert into `contact_lists`
|
||||||
|
- `POST /lists/{list_id}/contacts/{contact_id}/remove` - delete from `contact_lists`
|
||||||
|
- Same UI pattern: contact dropdown + role text input + list with remove buttons.
|
||||||
|
|
||||||
|
**Test:** Go to `https://lifeos-dev.invixiom.com/lists/`, select a domain, verify the project dropdown updates to only show projects in that domain plus unassigned ones. Test adding/removing contacts on a list detail page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PROMPT 5: Eisenhower Page - Dynamic Height + Filters
|
||||||
|
|
||||||
|
**Context:** The Eisenhower matrix page at `/eisenhower/` displays tasks in a 2x2 grid (Important+Urgent, Important+Not Urgent, Not Important+Urgent, Not Important+Not Urgent). Currently the quadrant cards have a fixed minimum height which wastes space or clips content.
|
||||||
|
|
||||||
|
**Codebase facts:**
|
||||||
|
- Template: `templates/eisenhower.html` (if it exists) or it may be part of another template
|
||||||
|
- Router: `routers/eisenhower.py` or may be in `routers/tasks.py`
|
||||||
|
- CSS in `static/style.css`
|
||||||
|
- Current quadrant logic: priority 1-2 = Important, 3-4 = Not Important. Due date <= 7 days = Urgent, > 7 days or null = Not Urgent.
|
||||||
|
- Tasks are filtered: only `status IN ('open', 'in_progress', 'blocked')` and `is_deleted = false`
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
|
||||||
|
1. **Dynamic height for quadrant cards:**
|
||||||
|
- Remove any `min-height` or fixed `height` CSS on the Eisenhower quadrant containers.
|
||||||
|
- Use CSS that allows quadrants to grow with content: `min-height: 200px` (reasonable minimum) but no `max-height` restriction. Or use CSS Grid with `grid-template-rows: auto auto` so rows size to content.
|
||||||
|
- Each card within a quadrant should be compact (task title, priority dot, due date - single line or two lines max).
|
||||||
|
- If a quadrant has many tasks, it grows. If empty, it shows a small empty state.
|
||||||
|
|
||||||
|
2. **Add filters:**
|
||||||
|
- Add a filter bar above the 2x2 grid with dropdowns for:
|
||||||
|
- **Domain** - filter tasks by domain_id
|
||||||
|
- **Project** - filter tasks by project_id (dynamic based on domain selection, same pattern as Prompt 4)
|
||||||
|
- **Status** - filter by status (open, in_progress, blocked)
|
||||||
|
- **Context** - filter by context (GTD execution context from context_types table)
|
||||||
|
- Filters use query parameters: `/eisenhower/?domain_id=X&project_id=Y&status=Z&context=W`
|
||||||
|
- Router applies these filters to the task query before distributing into quadrants.
|
||||||
|
- Use the same auto-submit-on-change pattern used elsewhere in the app.
|
||||||
|
|
||||||
|
**Test:** Verify at `https://lifeos-dev.invixiom.com/eisenhower/` that quadrants resize with content and filters narrow the displayed tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PROMPT 6: Focus Page - Add Domain/Area/Project Filters
|
||||||
|
|
||||||
|
**Context:** The focus page at `/focus/` shows the daily focus list (tasks selected for today). Currently there are no filters to narrow which available tasks are shown for adding to focus.
|
||||||
|
|
||||||
|
**Codebase facts:**
|
||||||
|
- Template: `templates/focus.html`
|
||||||
|
- Router: `routers/focus.py`
|
||||||
|
- The page has two sections: (1) Today's focus items (tasks already added), (2) Available tasks to add
|
||||||
|
- `daily_focus` table: `id, task_id, focus_date, completed, sort_order, is_deleted, created_at`
|
||||||
|
- Tasks have `domain_id`, `project_id` FKs. Tasks also have `area_id` FK.
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
|
||||||
|
1. **Filter bar for available tasks section:**
|
||||||
|
- Add dropdowns above the "Available Tasks" section for: Domain, Area (filtered by selected domain), Project (filtered by selected domain/area)
|
||||||
|
- Same dynamic filtering pattern as Prompts 4 and 5
|
||||||
|
- Filters apply only to the "available tasks to add" list, NOT to the already-focused items
|
||||||
|
- Query params: `/focus/?domain_id=X&area_id=Y&project_id=Z&focus_date=YYYY-MM-DD`
|
||||||
|
|
||||||
|
2. **Router changes:**
|
||||||
|
- Accept domain_id, area_id, project_id query params
|
||||||
|
- Apply as WHERE clauses when querying available (unfocused) tasks
|
||||||
|
- Pass filter values + dropdown options to template context
|
||||||
|
|
||||||
|
**Test:** Verify at `https://lifeos-dev.invixiom.com/focus/` that filter dropdowns appear and narrow the available tasks list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PROMPT 7: Change History (Recently Changed Items)
|
||||||
|
|
||||||
|
**Context:** We need a "Change History" view so the user can see recently modified items across the entire system. Every table in Life OS has `created_at` and `updated_at` TIMESTAMPTZ columns (except `time_entries` which is missing `updated_at`). This gives us a simple approach: query `updated_at` across all entity tables and show a reverse-chronological feed.
|
||||||
|
|
||||||
|
**Codebase facts:**
|
||||||
|
- All main entity tables have `updated_at`: domains, areas, projects, tasks, notes, contacts, meetings, decisions, lists, weblinks, appointments, links, files, processes, capture
|
||||||
|
- `time_entries` does NOT have `updated_at` - exclude from change history
|
||||||
|
- BaseRepository handles all CRUD and sets `updated_at = now()` on every update
|
||||||
|
- Sidebar navigation is built in `core/sidebar.py`
|
||||||
|
|
||||||
|
**What to build:**
|
||||||
|
|
||||||
|
1. **New router:** Create `routers/history.py` with prefix `/history`
|
||||||
|
- `GET /history/` - renders the change history page
|
||||||
|
- Query: For each entity table, `SELECT id, 'entity_type' as type, title/name as label, updated_at, created_at FROM {table} WHERE is_deleted = false ORDER BY updated_at DESC LIMIT 20`
|
||||||
|
- Union all results, sort by `updated_at DESC`, take top 50 (or paginate)
|
||||||
|
- For each item, determine if it was "created" (updated_at = created_at or within 1 second) or "modified"
|
||||||
|
- Pass to template as a list of `{type, id, label, updated_at, action}` dicts
|
||||||
|
|
||||||
|
2. **Template:** Create `templates/history.html`
|
||||||
|
- Page title: "Change History" or "Recent Changes"
|
||||||
|
- Filter by: entity type dropdown, date range
|
||||||
|
- Each row shows: timestamp, action icon (created/modified), entity type badge, item name as clickable link to detail page, relative time ("2 minutes ago", "yesterday")
|
||||||
|
- Use the standard list-row styling
|
||||||
|
|
||||||
|
3. **Sidebar link:** Add "History" to the sidebar navigation in `core/sidebar.py` (or `base.html` directly), in the utility section near Search/Capture/Admin.
|
||||||
|
|
||||||
|
4. **Register router** in `main.py`: `app.include_router(history.router)`
|
||||||
|
|
||||||
|
**Architecture note:** This is NOT a full audit log with field-level diffs. It's a simple "what changed recently" view derived from `updated_at` timestamps. A full audit log (with before/after values) would require triggers or middleware - defer that to a later phase.
|
||||||
|
|
||||||
|
**Test:** Verify at `https://lifeos-dev.invixiom.com/history/` that recently created/modified items appear in reverse chronological order with working links.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PROMPT 8: Fix Search - Partial Word Matching
|
||||||
|
|
||||||
|
**Context:** The global search currently uses PostgreSQL `tsvector/tsquery` full-text search. This is great for natural language search but does NOT support partial word matching. Searching for "Sys" does not return items containing "System" because tsquery requires complete lexemes.
|
||||||
|
|
||||||
|
**Codebase facts:**
|
||||||
|
- Search router: `routers/search.py`
|
||||||
|
- API endpoint: `GET /search/api?q=X&entity_type=Y&limit=Z`
|
||||||
|
- Page endpoint: `GET /search/?q=X`
|
||||||
|
- Every searchable table has a `search_vector TSVECTOR` column with GIN index
|
||||||
|
- Search triggers maintain the tsvector automatically
|
||||||
|
- Current query pattern likely uses `plainto_tsquery('english', query)` or `to_tsquery`
|
||||||
|
|
||||||
|
**The fix:**
|
||||||
|
|
||||||
|
The search needs to support partial prefix matching. PostgreSQL supports this via `:*` suffix on tsquery terms.
|
||||||
|
|
||||||
|
1. **Modify the search query building** in `routers/search.py`:
|
||||||
|
- Instead of `plainto_tsquery('english', query)`, build a prefix query
|
||||||
|
- For a search term like "Sys", construct: `to_tsquery('english', 'Sys:*')`
|
||||||
|
- For multi-word queries like "Sys Admin", construct: `to_tsquery('english', 'Sys:* & Admin:*')` (AND all terms with prefix matching)
|
||||||
|
- Implementation: split the query string on whitespace, strip non-alphanumeric chars from each term, append `:*` to each, join with ` & `
|
||||||
|
- Handle edge cases: empty query, single character (skip search for < 2 chars), special characters
|
||||||
|
|
||||||
|
2. **Also add an ILIKE fallback** for when tsvector doesn't match (tsvector strips stop words and stems, so very short or unusual terms might not match):
|
||||||
|
- After the tsvector query, if results are sparse (< 3 results), do a supplemental `WHERE title ILIKE '%{query}%' OR name ILIKE '%{query}%'` query
|
||||||
|
- Deduplicate results (tsvector results first, then ILIKE additions)
|
||||||
|
- This ensures "Sys" matches "System" even if the stemmer does something unexpected
|
||||||
|
|
||||||
|
3. **Update the search query for EACH entity** that's searched. The pattern should be consistent - build a helper function like:
|
||||||
|
```python
|
||||||
|
def build_search_condition(query: str):
|
||||||
|
terms = query.strip().split()
|
||||||
|
if not terms:
|
||||||
|
return None
|
||||||
|
tsquery_str = ' & '.join(f"{term}:*" for term in terms if len(term) >= 2)
|
||||||
|
return tsquery_str
|
||||||
|
```
|
||||||
|
|
||||||
|
**Do NOT:**
|
||||||
|
- Change the search_vector triggers or GIN indexes (they're fine)
|
||||||
|
- Add any new database tables
|
||||||
|
- Change the search UI/template (just the backend query logic)
|
||||||
|
|
||||||
|
**Test:** Go to `https://lifeos-dev.invixiom.com/search/?q=Sys` and verify it returns items containing "System", "Systematic", "Sys" etc. Test with "Inv" to match "Invoice", "Investment" etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## General Notes for All Prompts
|
||||||
|
|
||||||
|
- **Git:** After each prompt's changes are verified working, run: `cd /opt/lifeos/dev && git add . && git commit -m "descriptive message" && git push origin main`
|
||||||
|
- **Database migrations:** Always apply to `lifeos_dev` only. Command pattern: `docker exec -i lifeos-db psql -U postgres -d lifeos_dev < migration_file.sql`
|
||||||
|
- **Restart if needed:** `docker restart lifeos-dev` (though hot reload should handle most changes)
|
||||||
|
- **Logs:** `docker logs lifeos-dev --tail 30` to debug errors
|
||||||
|
- **Contact role pattern:** For now, roles are free-text on tasks/projects/lists. On meetings, roles are constrained to: organizer, attendee, optional. This will be standardized later with a defined role set.
|
||||||
|
- **Empty states:** Every tab/section that can be empty should show a clean empty state message with a CTA button to add the first item.
|
||||||
2
main.py
2
main.py
@@ -43,6 +43,7 @@ from routers import (
|
|||||||
calendar as calendar_router,
|
calendar as calendar_router,
|
||||||
time_budgets as time_budgets_router,
|
time_budgets as time_budgets_router,
|
||||||
eisenhower as eisenhower_router,
|
eisenhower as eisenhower_router,
|
||||||
|
history as history_router,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -201,3 +202,4 @@ app.include_router(processes_router.router)
|
|||||||
app.include_router(calendar_router.router)
|
app.include_router(calendar_router.router)
|
||||||
app.include_router(time_budgets_router.router)
|
app.include_router(time_budgets_router.router)
|
||||||
app.include_router(eisenhower_router.router)
|
app.include_router(eisenhower_router.router)
|
||||||
|
app.include_router(history_router.router)
|
||||||
|
|||||||
2068
project-docs/lifeos-system-design-document.md
Normal file
2068
project-docs/lifeos-system-design-document.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -56,6 +56,7 @@ async def list_decisions(
|
|||||||
async def create_form(
|
async def create_form(
|
||||||
request: Request,
|
request: Request,
|
||||||
meeting_id: Optional[str] = None,
|
meeting_id: Optional[str] = None,
|
||||||
|
task_id: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
sidebar = await get_sidebar_data(db)
|
sidebar = await get_sidebar_data(db)
|
||||||
@@ -72,6 +73,7 @@ async def create_form(
|
|||||||
"page_title": "New Decision", "active_nav": "decisions",
|
"page_title": "New Decision", "active_nav": "decisions",
|
||||||
"item": None,
|
"item": None,
|
||||||
"prefill_meeting_id": meeting_id or "",
|
"prefill_meeting_id": meeting_id or "",
|
||||||
|
"prefill_task_id": task_id or "",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -84,6 +86,7 @@ async def create_decision(
|
|||||||
impact: str = Form("medium"),
|
impact: str = Form("medium"),
|
||||||
decided_at: Optional[str] = Form(None),
|
decided_at: Optional[str] = Form(None),
|
||||||
meeting_id: Optional[str] = Form(None),
|
meeting_id: Optional[str] = Form(None),
|
||||||
|
task_id: Optional[str] = Form(None),
|
||||||
tags: Optional[str] = Form(None),
|
tags: Optional[str] = Form(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -96,10 +99,14 @@ async def create_decision(
|
|||||||
data["decided_at"] = decided_at
|
data["decided_at"] = decided_at
|
||||||
if meeting_id and meeting_id.strip():
|
if meeting_id and meeting_id.strip():
|
||||||
data["meeting_id"] = meeting_id
|
data["meeting_id"] = meeting_id
|
||||||
|
if task_id and task_id.strip():
|
||||||
|
data["task_id"] = task_id
|
||||||
if tags and tags.strip():
|
if tags and tags.strip():
|
||||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||||
|
|
||||||
decision = await repo.create(data)
|
decision = await repo.create(data)
|
||||||
|
if task_id and task_id.strip():
|
||||||
|
return RedirectResponse(url=f"/tasks/{task_id}?tab=decisions", status_code=303)
|
||||||
return RedirectResponse(url=f"/decisions/{decision['id']}", status_code=303)
|
return RedirectResponse(url=f"/decisions/{decision['id']}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ from fastapi import APIRouter, Request, Depends
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from core.database import get_db
|
from core.database import get_db
|
||||||
|
from core.base_repository import BaseRepository
|
||||||
from core.sidebar import get_sidebar_data
|
from core.sidebar import get_sidebar_data
|
||||||
|
|
||||||
router = APIRouter(prefix="/eisenhower", tags=["eisenhower"])
|
router = APIRouter(prefix="/eisenhower", tags=["eisenhower"])
|
||||||
@@ -15,11 +17,36 @@ templates = Jinja2Templates(directory="templates")
|
|||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def eisenhower_matrix(
|
async def eisenhower_matrix(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
domain_id: Optional[str] = None,
|
||||||
|
project_id: Optional[str] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
context: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
sidebar = await get_sidebar_data(db)
|
sidebar = await get_sidebar_data(db)
|
||||||
|
|
||||||
result = await db.execute(text("""
|
where_clauses = [
|
||||||
|
"t.is_deleted = false",
|
||||||
|
"t.status IN ('open', 'in_progress', 'blocked')",
|
||||||
|
]
|
||||||
|
params = {}
|
||||||
|
|
||||||
|
if domain_id:
|
||||||
|
where_clauses.append("t.domain_id = :domain_id")
|
||||||
|
params["domain_id"] = domain_id
|
||||||
|
if project_id:
|
||||||
|
where_clauses.append("t.project_id = :project_id")
|
||||||
|
params["project_id"] = project_id
|
||||||
|
if status:
|
||||||
|
where_clauses.append("t.status = :status")
|
||||||
|
params["status"] = status
|
||||||
|
if context:
|
||||||
|
where_clauses.append("t.context = :context")
|
||||||
|
params["context"] = context
|
||||||
|
|
||||||
|
where_sql = " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
result = await db.execute(text(f"""
|
||||||
SELECT t.id, t.title, t.priority, t.status, t.due_date,
|
SELECT t.id, t.title, t.priority, t.status, t.due_date,
|
||||||
t.context, t.estimated_minutes,
|
t.context, t.estimated_minutes,
|
||||||
p.name as project_name,
|
p.name as project_name,
|
||||||
@@ -27,10 +54,9 @@ async def eisenhower_matrix(
|
|||||||
FROM tasks t
|
FROM tasks t
|
||||||
LEFT JOIN projects p ON t.project_id = p.id
|
LEFT JOIN projects p ON t.project_id = p.id
|
||||||
LEFT JOIN domains d ON t.domain_id = d.id
|
LEFT JOIN domains d ON t.domain_id = d.id
|
||||||
WHERE t.is_deleted = false
|
WHERE {where_sql}
|
||||||
AND t.status IN ('open', 'in_progress', 'blocked')
|
|
||||||
ORDER BY t.priority, t.due_date NULLS LAST, t.title
|
ORDER BY t.priority, t.due_date NULLS LAST, t.title
|
||||||
"""))
|
"""), params)
|
||||||
tasks = [dict(r._mapping) for r in result]
|
tasks = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
# Classify into quadrants
|
# Classify into quadrants
|
||||||
@@ -39,10 +65,10 @@ async def eisenhower_matrix(
|
|||||||
urgent_cutoff = today + timedelta(days=7)
|
urgent_cutoff = today + timedelta(days=7)
|
||||||
|
|
||||||
quadrants = {
|
quadrants = {
|
||||||
"do_first": [], # Urgent + Important
|
"do_first": [],
|
||||||
"schedule": [], # Not Urgent + Important
|
"schedule": [],
|
||||||
"delegate": [], # Urgent + Not Important
|
"delegate": [],
|
||||||
"eliminate": [], # Not Urgent + Not Important
|
"eliminate": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
for t in tasks:
|
for t in tasks:
|
||||||
@@ -64,6 +90,17 @@ async def eisenhower_matrix(
|
|||||||
counts = {k: len(v) for k, v in quadrants.items()}
|
counts = {k: len(v) for k, v in quadrants.items()}
|
||||||
total = sum(counts.values())
|
total = sum(counts.values())
|
||||||
|
|
||||||
|
# Filter options
|
||||||
|
domains_repo = BaseRepository("domains", db)
|
||||||
|
domains = await domains_repo.list()
|
||||||
|
projects_repo = BaseRepository("projects", db)
|
||||||
|
projects = await projects_repo.list()
|
||||||
|
|
||||||
|
result = await db.execute(text(
|
||||||
|
"SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order"
|
||||||
|
))
|
||||||
|
context_types = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
return templates.TemplateResponse("eisenhower.html", {
|
return templates.TemplateResponse("eisenhower.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"sidebar": sidebar,
|
"sidebar": sidebar,
|
||||||
@@ -71,6 +108,13 @@ async def eisenhower_matrix(
|
|||||||
"counts": counts,
|
"counts": counts,
|
||||||
"total": total,
|
"total": total,
|
||||||
"today": today,
|
"today": today,
|
||||||
|
"domains": domains,
|
||||||
|
"projects": projects,
|
||||||
|
"context_types": context_types,
|
||||||
|
"current_domain_id": domain_id or "",
|
||||||
|
"current_project_id": project_id or "",
|
||||||
|
"current_status": status or "",
|
||||||
|
"current_context": context or "",
|
||||||
"page_title": "Eisenhower Matrix",
|
"page_title": "Eisenhower Matrix",
|
||||||
"active_nav": "eisenhower",
|
"active_nav": "eisenhower",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,7 +17,14 @@ templates = Jinja2Templates(directory="templates")
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def focus_view(request: Request, focus_date: Optional[str] = None, db: AsyncSession = Depends(get_db)):
|
async def focus_view(
|
||||||
|
request: Request,
|
||||||
|
focus_date: Optional[str] = None,
|
||||||
|
domain_id: Optional[str] = None,
|
||||||
|
area_id: Optional[str] = None,
|
||||||
|
project_id: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
sidebar = await get_sidebar_data(db)
|
sidebar = await get_sidebar_data(db)
|
||||||
target_date = date.fromisoformat(focus_date) if focus_date else date.today()
|
target_date = date.fromisoformat(focus_date) if focus_date else date.today()
|
||||||
|
|
||||||
@@ -36,30 +43,57 @@ async def focus_view(request: Request, focus_date: Optional[str] = None, db: Asy
|
|||||||
items = [dict(r._mapping) for r in result]
|
items = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
# Available tasks to add (open, not already in today's focus)
|
# Available tasks to add (open, not already in today's focus)
|
||||||
result = await db.execute(text("""
|
avail_where = [
|
||||||
|
"t.is_deleted = false",
|
||||||
|
"t.status NOT IN ('done', 'cancelled')",
|
||||||
|
"t.id NOT IN (SELECT task_id FROM daily_focus WHERE focus_date = :target_date AND is_deleted = false)",
|
||||||
|
]
|
||||||
|
avail_params = {"target_date": target_date}
|
||||||
|
|
||||||
|
if domain_id:
|
||||||
|
avail_where.append("t.domain_id = :domain_id")
|
||||||
|
avail_params["domain_id"] = domain_id
|
||||||
|
if area_id:
|
||||||
|
avail_where.append("t.area_id = :area_id")
|
||||||
|
avail_params["area_id"] = area_id
|
||||||
|
if project_id:
|
||||||
|
avail_where.append("t.project_id = :project_id")
|
||||||
|
avail_params["project_id"] = project_id
|
||||||
|
|
||||||
|
avail_sql = " AND ".join(avail_where)
|
||||||
|
|
||||||
|
result = await db.execute(text(f"""
|
||||||
SELECT t.id, t.title, t.priority, t.due_date,
|
SELECT t.id, t.title, t.priority, t.due_date,
|
||||||
p.name as project_name, d.name as domain_name
|
p.name as project_name, d.name as domain_name
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
LEFT JOIN projects p ON t.project_id = p.id
|
LEFT JOIN projects p ON t.project_id = p.id
|
||||||
LEFT JOIN domains d ON t.domain_id = d.id
|
LEFT JOIN domains d ON t.domain_id = d.id
|
||||||
WHERE t.is_deleted = false AND t.status NOT IN ('done', 'cancelled')
|
WHERE {avail_sql}
|
||||||
AND t.id NOT IN (
|
|
||||||
SELECT task_id FROM daily_focus
|
|
||||||
WHERE focus_date = :target_date AND is_deleted = false
|
|
||||||
)
|
|
||||||
ORDER BY t.priority ASC, t.due_date ASC NULLS LAST
|
ORDER BY t.priority ASC, t.due_date ASC NULLS LAST
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
"""), {"target_date": target_date})
|
"""), avail_params)
|
||||||
available_tasks = [dict(r._mapping) for r in result]
|
available_tasks = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
# Estimated total minutes
|
# Estimated total minutes
|
||||||
total_est = sum(i.get("estimated_minutes") or 0 for i in items)
|
total_est = sum(i.get("estimated_minutes") or 0 for i in items)
|
||||||
|
|
||||||
|
# Filter options
|
||||||
|
domains_repo = BaseRepository("domains", db)
|
||||||
|
domains = await domains_repo.list()
|
||||||
|
areas_repo = BaseRepository("areas", db)
|
||||||
|
areas = await areas_repo.list()
|
||||||
|
projects_repo = BaseRepository("projects", db)
|
||||||
|
projects = await projects_repo.list()
|
||||||
|
|
||||||
return templates.TemplateResponse("focus.html", {
|
return templates.TemplateResponse("focus.html", {
|
||||||
"request": request, "sidebar": sidebar,
|
"request": request, "sidebar": sidebar,
|
||||||
"items": items, "available_tasks": available_tasks,
|
"items": items, "available_tasks": available_tasks,
|
||||||
"focus_date": target_date,
|
"focus_date": target_date,
|
||||||
"total_estimated": total_est,
|
"total_estimated": total_est,
|
||||||
|
"domains": domains, "areas": areas, "projects": projects,
|
||||||
|
"current_domain_id": domain_id or "",
|
||||||
|
"current_area_id": area_id or "",
|
||||||
|
"current_project_id": project_id or "",
|
||||||
"page_title": "Daily Focus", "active_nav": "focus",
|
"page_title": "Daily Focus", "active_nav": "focus",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
91
routers/history.py
Normal file
91
routers/history.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""Change History: reverse-chronological feed of recently modified items."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import text
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from core.database import get_db
|
||||||
|
from core.sidebar import get_sidebar_data
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/history", tags=["history"])
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
# Entity configs: (table, label_column, type_label, url_prefix)
|
||||||
|
HISTORY_ENTITIES = [
|
||||||
|
("domains", "name", "Domain", "/domains"),
|
||||||
|
("areas", "name", "Area", "/areas"),
|
||||||
|
("projects", "name", "Project", "/projects"),
|
||||||
|
("tasks", "title", "Task", "/tasks"),
|
||||||
|
("notes", "title", "Note", "/notes"),
|
||||||
|
("contacts", "first_name", "Contact", "/contacts"),
|
||||||
|
("meetings", "title", "Meeting", "/meetings"),
|
||||||
|
("decisions", "title", "Decision", "/decisions"),
|
||||||
|
("lists", "name", "List", "/lists"),
|
||||||
|
("weblinks", "label", "Weblink", "/weblinks"),
|
||||||
|
("appointments", "title", "Appointment", "/appointments"),
|
||||||
|
("links", "label", "Link", "/links"),
|
||||||
|
("files", "original_filename", "File", "/files"),
|
||||||
|
("capture", "content", "Capture", "/capture"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def history_view(
|
||||||
|
request: Request,
|
||||||
|
entity_type: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
sidebar = await get_sidebar_data(db)
|
||||||
|
|
||||||
|
all_items = []
|
||||||
|
|
||||||
|
for table, label_col, type_label, url_prefix in HISTORY_ENTITIES:
|
||||||
|
if entity_type and entity_type != table:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await db.execute(text(f"""
|
||||||
|
SELECT id, {label_col} as label, updated_at, created_at
|
||||||
|
FROM {table}
|
||||||
|
WHERE is_deleted = false
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
"""))
|
||||||
|
for r in result:
|
||||||
|
row = dict(r._mapping)
|
||||||
|
# Determine action
|
||||||
|
action = "created"
|
||||||
|
if row["updated_at"] and row["created_at"]:
|
||||||
|
diff = abs((row["updated_at"] - row["created_at"]).total_seconds())
|
||||||
|
if diff > 1:
|
||||||
|
action = "modified"
|
||||||
|
|
||||||
|
all_items.append({
|
||||||
|
"type": table,
|
||||||
|
"type_label": type_label,
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"label": str(row["label"] or "Untitled")[:80],
|
||||||
|
"url": f"{url_prefix}/{row['id']}",
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
"action": action,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sort by updated_at descending, take top 50
|
||||||
|
all_items.sort(key=lambda x: x["updated_at"] or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
|
||||||
|
all_items = all_items[:50]
|
||||||
|
|
||||||
|
# Build entity type options for filter
|
||||||
|
type_options = [{"value": t[0], "label": t[2]} for t in HISTORY_ENTITIES]
|
||||||
|
|
||||||
|
return templates.TemplateResponse("history.html", {
|
||||||
|
"request": request, "sidebar": sidebar,
|
||||||
|
"items": all_items,
|
||||||
|
"type_options": type_options,
|
||||||
|
"current_type": entity_type or "",
|
||||||
|
"page_title": "Change History", "active_nav": "history",
|
||||||
|
})
|
||||||
@@ -70,6 +70,8 @@ async def create_form(
|
|||||||
request: Request,
|
request: Request,
|
||||||
domain_id: Optional[str] = None,
|
domain_id: Optional[str] = None,
|
||||||
project_id: Optional[str] = None,
|
project_id: Optional[str] = None,
|
||||||
|
task_id: Optional[str] = None,
|
||||||
|
meeting_id: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
sidebar = await get_sidebar_data(db)
|
sidebar = await get_sidebar_data(db)
|
||||||
@@ -87,6 +89,8 @@ async def create_form(
|
|||||||
"item": None,
|
"item": None,
|
||||||
"prefill_domain_id": domain_id or "",
|
"prefill_domain_id": domain_id or "",
|
||||||
"prefill_project_id": project_id or "",
|
"prefill_project_id": project_id or "",
|
||||||
|
"prefill_task_id": task_id or "",
|
||||||
|
"prefill_meeting_id": meeting_id or "",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -97,6 +101,8 @@ async def create_list(
|
|||||||
domain_id: str = Form(...),
|
domain_id: str = Form(...),
|
||||||
area_id: Optional[str] = Form(None),
|
area_id: Optional[str] = Form(None),
|
||||||
project_id: Optional[str] = Form(None),
|
project_id: Optional[str] = Form(None),
|
||||||
|
task_id: Optional[str] = Form(None),
|
||||||
|
meeting_id: Optional[str] = Form(None),
|
||||||
list_type: str = Form("checklist"),
|
list_type: str = Form("checklist"),
|
||||||
description: Optional[str] = Form(None),
|
description: Optional[str] = Form(None),
|
||||||
tags: Optional[str] = Form(None),
|
tags: Optional[str] = Form(None),
|
||||||
@@ -112,10 +118,18 @@ async def create_list(
|
|||||||
data["area_id"] = area_id
|
data["area_id"] = area_id
|
||||||
if project_id and project_id.strip():
|
if project_id and project_id.strip():
|
||||||
data["project_id"] = project_id
|
data["project_id"] = project_id
|
||||||
|
if task_id and task_id.strip():
|
||||||
|
data["task_id"] = task_id
|
||||||
|
if meeting_id and meeting_id.strip():
|
||||||
|
data["meeting_id"] = meeting_id
|
||||||
if tags and tags.strip():
|
if tags and tags.strip():
|
||||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||||
|
|
||||||
new_list = await repo.create(data)
|
new_list = await repo.create(data)
|
||||||
|
if task_id and task_id.strip():
|
||||||
|
return RedirectResponse(url=f"/tasks/{task_id}?tab=lists", status_code=303)
|
||||||
|
if meeting_id and meeting_id.strip():
|
||||||
|
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=lists", status_code=303)
|
||||||
return RedirectResponse(url=f"/lists/{new_list['id']}", status_code=303)
|
return RedirectResponse(url=f"/lists/{new_list['id']}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@@ -156,10 +170,28 @@ async def list_detail(list_id: str, request: Request, db: AsyncSession = Depends
|
|||||||
if pid:
|
if pid:
|
||||||
child_map.setdefault(str(pid), []).append(i)
|
child_map.setdefault(str(pid), []).append(i)
|
||||||
|
|
||||||
|
# Contacts linked to this list
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT c.*, cl.role, cl.created_at as linked_at
|
||||||
|
FROM contacts c
|
||||||
|
JOIN contact_lists cl ON cl.contact_id = c.id
|
||||||
|
WHERE cl.list_id = :lid AND c.is_deleted = false
|
||||||
|
ORDER BY c.first_name
|
||||||
|
"""), {"lid": list_id})
|
||||||
|
contacts = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# All contacts for add dropdown
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT id, first_name, last_name FROM contacts
|
||||||
|
WHERE is_deleted = false ORDER BY first_name
|
||||||
|
"""))
|
||||||
|
all_contacts = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
return templates.TemplateResponse("list_detail.html", {
|
return templates.TemplateResponse("list_detail.html", {
|
||||||
"request": request, "sidebar": sidebar, "item": item,
|
"request": request, "sidebar": sidebar, "item": item,
|
||||||
"domain": domain, "project": project,
|
"domain": domain, "project": project,
|
||||||
"list_items": top_items, "child_map": child_map,
|
"list_items": top_items, "child_map": child_map,
|
||||||
|
"contacts": contacts, "all_contacts": all_contacts,
|
||||||
"page_title": item["name"], "active_nav": "lists",
|
"page_title": item["name"], "active_nav": "lists",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -273,3 +305,30 @@ async def edit_item(
|
|||||||
repo = BaseRepository("list_items", db)
|
repo = BaseRepository("list_items", db)
|
||||||
await repo.update(item_id, {"content": content})
|
await repo.update(item_id, {"content": content})
|
||||||
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
|
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Contact linking ----
|
||||||
|
|
||||||
|
@router.post("/{list_id}/contacts/add")
|
||||||
|
async def add_contact(
|
||||||
|
list_id: str,
|
||||||
|
contact_id: str = Form(...),
|
||||||
|
role: Optional[str] = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(text("""
|
||||||
|
INSERT INTO contact_lists (contact_id, list_id, role)
|
||||||
|
VALUES (:cid, :lid, :role) ON CONFLICT DO NOTHING
|
||||||
|
"""), {"cid": contact_id, "lid": list_id, "role": role if role and role.strip() else None})
|
||||||
|
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{list_id}/contacts/{contact_id}/remove")
|
||||||
|
async def remove_contact(
|
||||||
|
list_id: str, contact_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(text(
|
||||||
|
"DELETE FROM contact_lists WHERE contact_id = :cid AND list_id = :lid"
|
||||||
|
), {"cid": contact_id, "lid": list_id})
|
||||||
|
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
|
||||||
|
|||||||
@@ -107,14 +107,26 @@ async def create_meeting(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{meeting_id}")
|
@router.get("/{meeting_id}")
|
||||||
async def meeting_detail(meeting_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
async def meeting_detail(
|
||||||
|
meeting_id: str, request: Request,
|
||||||
|
tab: str = "overview",
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
repo = BaseRepository("meetings", db)
|
repo = BaseRepository("meetings", db)
|
||||||
sidebar = await get_sidebar_data(db)
|
sidebar = await get_sidebar_data(db)
|
||||||
item = await repo.get(meeting_id)
|
item = await repo.get(meeting_id)
|
||||||
if not item:
|
if not item:
|
||||||
return RedirectResponse(url="/meetings", status_code=303)
|
return RedirectResponse(url="/meetings", status_code=303)
|
||||||
|
|
||||||
# Action items (tasks linked to this meeting)
|
# Overview data (always needed for overview tab)
|
||||||
|
action_items = []
|
||||||
|
decisions = []
|
||||||
|
domains = []
|
||||||
|
tab_data = []
|
||||||
|
all_contacts = []
|
||||||
|
|
||||||
|
if tab == "overview":
|
||||||
|
# Action items
|
||||||
result = await db.execute(text("""
|
result = await db.execute(text("""
|
||||||
SELECT t.*, mt.source,
|
SELECT t.*, mt.source,
|
||||||
d.name as domain_name, d.color as domain_color,
|
d.name as domain_name, d.color as domain_color,
|
||||||
@@ -128,14 +140,6 @@ async def meeting_detail(meeting_id: str, request: Request, db: AsyncSession = D
|
|||||||
"""), {"mid": meeting_id})
|
"""), {"mid": meeting_id})
|
||||||
action_items = [dict(r._mapping) for r in result]
|
action_items = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
# Notes linked to this meeting
|
|
||||||
result = await db.execute(text("""
|
|
||||||
SELECT * FROM notes
|
|
||||||
WHERE meeting_id = :mid AND is_deleted = false
|
|
||||||
ORDER BY created_at
|
|
||||||
"""), {"mid": meeting_id})
|
|
||||||
meeting_notes = [dict(r._mapping) for r in result]
|
|
||||||
|
|
||||||
# Decisions from this meeting
|
# Decisions from this meeting
|
||||||
result = await db.execute(text("""
|
result = await db.execute(text("""
|
||||||
SELECT * FROM decisions
|
SELECT * FROM decisions
|
||||||
@@ -144,24 +148,77 @@ async def meeting_detail(meeting_id: str, request: Request, db: AsyncSession = D
|
|||||||
"""), {"mid": meeting_id})
|
"""), {"mid": meeting_id})
|
||||||
decisions = [dict(r._mapping) for r in result]
|
decisions = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
# Attendees
|
|
||||||
result = await db.execute(text("""
|
|
||||||
SELECT c.*, cm.role FROM contact_meetings cm
|
|
||||||
JOIN contacts c ON cm.contact_id = c.id
|
|
||||||
WHERE cm.meeting_id = :mid AND c.is_deleted = false
|
|
||||||
ORDER BY c.first_name
|
|
||||||
"""), {"mid": meeting_id})
|
|
||||||
attendees = [dict(r._mapping) for r in result]
|
|
||||||
|
|
||||||
# Domains for action item creation
|
# Domains for action item creation
|
||||||
domains_repo = BaseRepository("domains", db)
|
domains_repo = BaseRepository("domains", db)
|
||||||
domains = await domains_repo.list()
|
domains = await domains_repo.list()
|
||||||
|
|
||||||
|
elif tab == "notes":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT * FROM notes
|
||||||
|
WHERE meeting_id = :mid AND is_deleted = false
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
"""), {"mid": meeting_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
elif tab == "weblinks":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT * FROM weblinks
|
||||||
|
WHERE meeting_id = :mid AND is_deleted = false
|
||||||
|
ORDER BY sort_order, label
|
||||||
|
"""), {"mid": meeting_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
elif tab == "files":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT f.* FROM files f
|
||||||
|
JOIN file_mappings fm ON fm.file_id = f.id
|
||||||
|
WHERE fm.context_type = 'meeting' AND fm.context_id = :mid AND f.is_deleted = false
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
"""), {"mid": meeting_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
elif tab == "lists":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT l.*,
|
||||||
|
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count
|
||||||
|
FROM lists l
|
||||||
|
WHERE l.meeting_id = :mid AND l.is_deleted = false
|
||||||
|
ORDER BY l.sort_order, l.created_at DESC
|
||||||
|
"""), {"mid": meeting_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
elif tab == "contacts":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT c.*, cm.role, cm.created_at as linked_at
|
||||||
|
FROM contacts c
|
||||||
|
JOIN contact_meetings cm ON cm.contact_id = c.id
|
||||||
|
WHERE cm.meeting_id = :mid AND c.is_deleted = false
|
||||||
|
ORDER BY c.first_name
|
||||||
|
"""), {"mid": meeting_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT id, first_name, last_name FROM contacts
|
||||||
|
WHERE is_deleted = false ORDER BY first_name
|
||||||
|
"""))
|
||||||
|
all_contacts = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# Tab counts
|
||||||
|
counts = {}
|
||||||
|
for count_tab, count_sql in [
|
||||||
|
("notes", "SELECT count(*) FROM notes WHERE meeting_id = :mid AND is_deleted = false"),
|
||||||
|
("weblinks", "SELECT count(*) FROM weblinks WHERE meeting_id = :mid AND is_deleted = false"),
|
||||||
|
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'meeting' AND fm.context_id = :mid AND f.is_deleted = false"),
|
||||||
|
("lists", "SELECT count(*) FROM lists WHERE meeting_id = :mid AND is_deleted = false"),
|
||||||
|
("contacts", "SELECT count(*) FROM contacts c JOIN contact_meetings cm ON cm.contact_id = c.id WHERE cm.meeting_id = :mid AND c.is_deleted = false"),
|
||||||
|
]:
|
||||||
|
result = await db.execute(text(count_sql), {"mid": meeting_id})
|
||||||
|
counts[count_tab] = result.scalar() or 0
|
||||||
|
|
||||||
return templates.TemplateResponse("meeting_detail.html", {
|
return templates.TemplateResponse("meeting_detail.html", {
|
||||||
"request": request, "sidebar": sidebar, "item": item,
|
"request": request, "sidebar": sidebar, "item": item,
|
||||||
"action_items": action_items, "meeting_notes": meeting_notes,
|
"action_items": action_items, "decisions": decisions,
|
||||||
"decisions": decisions, "attendees": attendees,
|
"domains": domains, "tab": tab, "tab_data": tab_data,
|
||||||
"domains": domains,
|
"all_contacts": all_contacts, "counts": counts,
|
||||||
"page_title": item["title"], "active_nav": "meetings",
|
"page_title": item["title"], "active_nav": "meetings",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -266,3 +323,30 @@ async def create_action_item(
|
|||||||
"""), {"mid": meeting_id, "tid": task["id"]})
|
"""), {"mid": meeting_id, "tid": task["id"]})
|
||||||
|
|
||||||
return RedirectResponse(url=f"/meetings/{meeting_id}", status_code=303)
|
return RedirectResponse(url=f"/meetings/{meeting_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Contact linking ----
|
||||||
|
|
||||||
|
@router.post("/{meeting_id}/contacts/add")
|
||||||
|
async def add_contact(
|
||||||
|
meeting_id: str,
|
||||||
|
contact_id: str = Form(...),
|
||||||
|
role: str = Form("attendee"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(text("""
|
||||||
|
INSERT INTO contact_meetings (contact_id, meeting_id, role)
|
||||||
|
VALUES (:cid, :mid, :role) ON CONFLICT DO NOTHING
|
||||||
|
"""), {"cid": contact_id, "mid": meeting_id, "role": role if role and role.strip() else "attendee"})
|
||||||
|
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{meeting_id}/contacts/{contact_id}/remove")
|
||||||
|
async def remove_contact(
|
||||||
|
meeting_id: str, contact_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(text(
|
||||||
|
"DELETE FROM contact_meetings WHERE contact_id = :cid AND meeting_id = :mid"
|
||||||
|
), {"cid": contact_id, "mid": meeting_id})
|
||||||
|
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303)
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ async def create_form(
|
|||||||
request: Request,
|
request: Request,
|
||||||
domain_id: Optional[str] = None,
|
domain_id: Optional[str] = None,
|
||||||
project_id: Optional[str] = None,
|
project_id: Optional[str] = None,
|
||||||
|
task_id: Optional[str] = None,
|
||||||
|
meeting_id: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
sidebar = await get_sidebar_data(db)
|
sidebar = await get_sidebar_data(db)
|
||||||
@@ -75,6 +77,8 @@ async def create_form(
|
|||||||
"item": None,
|
"item": None,
|
||||||
"prefill_domain_id": domain_id or "",
|
"prefill_domain_id": domain_id or "",
|
||||||
"prefill_project_id": project_id or "",
|
"prefill_project_id": project_id or "",
|
||||||
|
"prefill_task_id": task_id or "",
|
||||||
|
"prefill_meeting_id": meeting_id or "",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -84,6 +88,8 @@ async def create_note(
|
|||||||
title: str = Form(...),
|
title: str = Form(...),
|
||||||
domain_id: str = Form(...),
|
domain_id: str = Form(...),
|
||||||
project_id: Optional[str] = Form(None),
|
project_id: Optional[str] = Form(None),
|
||||||
|
task_id: Optional[str] = Form(None),
|
||||||
|
meeting_id: Optional[str] = Form(None),
|
||||||
body: Optional[str] = Form(None),
|
body: Optional[str] = Form(None),
|
||||||
content_format: str = Form("rich"),
|
content_format: str = Form("rich"),
|
||||||
tags: Optional[str] = Form(None),
|
tags: Optional[str] = Form(None),
|
||||||
@@ -96,9 +102,17 @@ async def create_note(
|
|||||||
}
|
}
|
||||||
if project_id and project_id.strip():
|
if project_id and project_id.strip():
|
||||||
data["project_id"] = project_id
|
data["project_id"] = project_id
|
||||||
|
if task_id and task_id.strip():
|
||||||
|
data["task_id"] = task_id
|
||||||
|
if meeting_id and meeting_id.strip():
|
||||||
|
data["meeting_id"] = meeting_id
|
||||||
if tags and tags.strip():
|
if tags and tags.strip():
|
||||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||||
note = await repo.create(data)
|
note = await repo.create(data)
|
||||||
|
if task_id and task_id.strip():
|
||||||
|
return RedirectResponse(url=f"/tasks/{task_id}?tab=notes", status_code=303)
|
||||||
|
if meeting_id and meeting_id.strip():
|
||||||
|
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=notes", status_code=303)
|
||||||
return RedirectResponse(url=f"/notes/{note['id']}", status_code=303)
|
return RedirectResponse(url=f"/notes/{note['id']}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, Request, Form, Depends
|
from fastapi import APIRouter, Request, Form, Depends
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse, JSONResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -15,6 +15,27 @@ router = APIRouter(prefix="/projects", tags=["projects"])
|
|||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/by-domain")
|
||||||
|
async def api_projects_by_domain(
|
||||||
|
domain_id: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""JSON API: return projects filtered by domain_id for dynamic dropdowns."""
|
||||||
|
if domain_id:
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT id, name FROM projects
|
||||||
|
WHERE is_deleted = false AND (domain_id = :did OR domain_id IS NULL)
|
||||||
|
ORDER BY name
|
||||||
|
"""), {"did": domain_id})
|
||||||
|
else:
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT id, name FROM projects
|
||||||
|
WHERE is_deleted = false ORDER BY name
|
||||||
|
"""))
|
||||||
|
projects = [{"id": str(r.id), "name": r.name} for r in result]
|
||||||
|
return JSONResponse(content=projects)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def list_projects(
|
async def list_projects(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -151,7 +172,7 @@ async def project_detail(
|
|||||||
row = result.first()
|
row = result.first()
|
||||||
area = dict(row._mapping) if row else None
|
area = dict(row._mapping) if row else None
|
||||||
|
|
||||||
# Tasks for this project
|
# Tasks for this project (always needed for progress bar)
|
||||||
result = await db.execute(text("""
|
result = await db.execute(text("""
|
||||||
SELECT t.*, d.name as domain_name, d.color as domain_color
|
SELECT t.*, d.name as domain_name, d.color as domain_color
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
@@ -161,29 +182,92 @@ async def project_detail(
|
|||||||
"""), {"pid": project_id})
|
"""), {"pid": project_id})
|
||||||
tasks = [dict(r._mapping) for r in result]
|
tasks = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
# Notes
|
# Progress
|
||||||
|
total = len(tasks)
|
||||||
|
done = len([t for t in tasks if t["status"] == "done"])
|
||||||
|
progress = round((done / total * 100) if total > 0 else 0)
|
||||||
|
|
||||||
|
# Tab-specific data
|
||||||
|
notes = []
|
||||||
|
links = []
|
||||||
|
tab_data = []
|
||||||
|
all_contacts = []
|
||||||
|
|
||||||
|
if tab == "notes":
|
||||||
result = await db.execute(text("""
|
result = await db.execute(text("""
|
||||||
SELECT * FROM notes WHERE project_id = :pid AND is_deleted = false
|
SELECT * FROM notes WHERE project_id = :pid AND is_deleted = false
|
||||||
ORDER BY sort_order, created_at DESC
|
ORDER BY sort_order, created_at DESC
|
||||||
"""), {"pid": project_id})
|
"""), {"pid": project_id})
|
||||||
notes = [dict(r._mapping) for r in result]
|
notes = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
# Links
|
elif tab == "links":
|
||||||
result = await db.execute(text("""
|
result = await db.execute(text("""
|
||||||
SELECT * FROM links WHERE project_id = :pid AND is_deleted = false
|
SELECT * FROM links WHERE project_id = :pid AND is_deleted = false
|
||||||
ORDER BY sort_order, created_at
|
ORDER BY sort_order, created_at
|
||||||
"""), {"pid": project_id})
|
"""), {"pid": project_id})
|
||||||
links = [dict(r._mapping) for r in result]
|
links = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
# Progress
|
elif tab == "files":
|
||||||
total = len(tasks)
|
result = await db.execute(text("""
|
||||||
done = len([t for t in tasks if t["status"] == "done"])
|
SELECT f.* FROM files f
|
||||||
progress = round((done / total * 100) if total > 0 else 0)
|
JOIN file_mappings fm ON fm.file_id = f.id
|
||||||
|
WHERE fm.context_type = 'project' AND fm.context_id = :pid AND f.is_deleted = false
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
"""), {"pid": project_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
elif tab == "lists":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT l.*,
|
||||||
|
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count
|
||||||
|
FROM lists l
|
||||||
|
WHERE l.project_id = :pid AND l.is_deleted = false
|
||||||
|
ORDER BY l.sort_order, l.created_at DESC
|
||||||
|
"""), {"pid": project_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
elif tab == "decisions":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT d.* FROM decisions d
|
||||||
|
JOIN decision_projects dp ON dp.decision_id = d.id
|
||||||
|
WHERE dp.project_id = :pid AND d.is_deleted = false
|
||||||
|
ORDER BY d.created_at DESC
|
||||||
|
"""), {"pid": project_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
elif tab == "contacts":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT c.*, cp.role, cp.created_at as linked_at
|
||||||
|
FROM contacts c
|
||||||
|
JOIN contact_projects cp ON cp.contact_id = c.id
|
||||||
|
WHERE cp.project_id = :pid AND c.is_deleted = false
|
||||||
|
ORDER BY c.first_name
|
||||||
|
"""), {"pid": project_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT id, first_name, last_name FROM contacts
|
||||||
|
WHERE is_deleted = false ORDER BY first_name
|
||||||
|
"""))
|
||||||
|
all_contacts = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# Tab counts
|
||||||
|
counts = {}
|
||||||
|
for count_tab, count_sql in [
|
||||||
|
("notes", "SELECT count(*) FROM notes WHERE project_id = :pid AND is_deleted = false"),
|
||||||
|
("links", "SELECT count(*) FROM links WHERE project_id = :pid AND is_deleted = false"),
|
||||||
|
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'project' AND fm.context_id = :pid AND f.is_deleted = false"),
|
||||||
|
("lists", "SELECT count(*) FROM lists WHERE project_id = :pid AND is_deleted = false"),
|
||||||
|
("decisions", "SELECT count(*) FROM decisions d JOIN decision_projects dp ON dp.decision_id = d.id WHERE dp.project_id = :pid AND d.is_deleted = false"),
|
||||||
|
("contacts", "SELECT count(*) FROM contacts c JOIN contact_projects cp ON cp.contact_id = c.id WHERE cp.project_id = :pid AND c.is_deleted = false"),
|
||||||
|
]:
|
||||||
|
result = await db.execute(text(count_sql), {"pid": project_id})
|
||||||
|
counts[count_tab] = result.scalar() or 0
|
||||||
|
|
||||||
return templates.TemplateResponse("project_detail.html", {
|
return templates.TemplateResponse("project_detail.html", {
|
||||||
"request": request, "sidebar": sidebar, "item": item,
|
"request": request, "sidebar": sidebar, "item": item,
|
||||||
"domain": domain, "area": area,
|
"domain": domain, "area": area,
|
||||||
"tasks": tasks, "notes": notes, "links": links,
|
"tasks": tasks, "notes": notes, "links": links,
|
||||||
|
"tab_data": tab_data, "all_contacts": all_contacts, "counts": counts,
|
||||||
"progress": progress, "task_count": total, "done_count": done,
|
"progress": progress, "task_count": total, "done_count": done,
|
||||||
"tab": tab,
|
"tab": tab,
|
||||||
"page_title": item["name"], "active_nav": "projects",
|
"page_title": item["name"], "active_nav": "projects",
|
||||||
@@ -246,3 +330,30 @@ async def delete_project(project_id: str, db: AsyncSession = Depends(get_db)):
|
|||||||
repo = BaseRepository("projects", db)
|
repo = BaseRepository("projects", db)
|
||||||
await repo.soft_delete(project_id)
|
await repo.soft_delete(project_id)
|
||||||
return RedirectResponse(url="/projects", status_code=303)
|
return RedirectResponse(url="/projects", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Contact linking ----
|
||||||
|
|
||||||
|
@router.post("/{project_id}/contacts/add")
|
||||||
|
async def add_contact(
|
||||||
|
project_id: str,
|
||||||
|
contact_id: str = Form(...),
|
||||||
|
role: Optional[str] = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(text("""
|
||||||
|
INSERT INTO contact_projects (contact_id, project_id, role)
|
||||||
|
VALUES (:cid, :pid, :role) ON CONFLICT DO NOTHING
|
||||||
|
"""), {"cid": contact_id, "pid": project_id, "role": role if role and role.strip() else None})
|
||||||
|
return RedirectResponse(url=f"/projects/{project_id}?tab=contacts", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/contacts/{contact_id}/remove")
|
||||||
|
async def remove_contact(
|
||||||
|
project_id: str, contact_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(text(
|
||||||
|
"DELETE FROM contact_projects WHERE contact_id = :cid AND project_id = :pid"
|
||||||
|
), {"cid": contact_id, "pid": project_id})
|
||||||
|
return RedirectResponse(url=f"/projects/{project_id}?tab=contacts", status_code=303)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Global search: Cmd/K modal, tsvector full-text search across all entities."""
|
"""Global search: Cmd/K modal, tsvector full-text search across all entities."""
|
||||||
|
|
||||||
|
import re
|
||||||
from fastapi import APIRouter, Request, Depends, Query
|
from fastapi import APIRouter, Request, Depends, Query
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@@ -14,174 +15,162 @@ router = APIRouter(prefix="/search", tags=["search"])
|
|||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
# Entity search configs: (table, title_col, subtitle_query, url_pattern, icon)
|
def build_prefix_tsquery(query: str) -> Optional[str]:
|
||||||
|
"""Build a prefix-matching tsquery string from user input.
|
||||||
|
'Sys Admin' -> 'Sys:* & Admin:*'
|
||||||
|
Returns None if no valid terms.
|
||||||
|
"""
|
||||||
|
terms = query.strip().split()
|
||||||
|
# Keep only alphanumeric terms >= 2 chars
|
||||||
|
clean = [re.sub(r'[^\w]', '', t) for t in terms]
|
||||||
|
clean = [t for t in clean if len(t) >= 2]
|
||||||
|
if not clean:
|
||||||
|
return None
|
||||||
|
return " & ".join(f"{t}:*" for t in clean)
|
||||||
|
|
||||||
|
|
||||||
|
# Entity search configs
|
||||||
|
# Each has: type, label, table, name_col, joins, extra_cols, url, icon
|
||||||
SEARCH_ENTITIES = [
|
SEARCH_ENTITIES = [
|
||||||
{
|
{
|
||||||
"type": "tasks",
|
"type": "tasks", "label": "Tasks", "table": "tasks", "alias": "t",
|
||||||
"label": "Tasks",
|
"name_col": "t.title", "status_col": "t.status",
|
||||||
"query": """
|
"joins": "LEFT JOIN domains d ON t.domain_id = d.id LEFT JOIN projects p ON t.project_id = p.id",
|
||||||
SELECT t.id, t.title as name, t.status,
|
"domain_col": "d.name", "project_col": "p.name",
|
||||||
d.name as domain_name, p.name as project_name,
|
"url": "/tasks/{id}", "icon": "task",
|
||||||
ts_rank(t.search_vector, websearch_to_tsquery('english', :q)) as rank
|
|
||||||
FROM tasks t
|
|
||||||
LEFT JOIN domains d ON t.domain_id = d.id
|
|
||||||
LEFT JOIN projects p ON t.project_id = p.id
|
|
||||||
WHERE t.is_deleted = false AND t.search_vector @@ websearch_to_tsquery('english', :q)
|
|
||||||
ORDER BY rank DESC LIMIT :lim
|
|
||||||
""",
|
|
||||||
"url": "/tasks/{id}",
|
|
||||||
"icon": "task",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "projects",
|
"type": "projects", "label": "Projects", "table": "projects", "alias": "p",
|
||||||
"label": "Projects",
|
"name_col": "p.name", "status_col": "p.status",
|
||||||
"query": """
|
"joins": "LEFT JOIN domains d ON p.domain_id = d.id",
|
||||||
SELECT p.id, p.name, p.status,
|
"domain_col": "d.name", "project_col": "NULL",
|
||||||
d.name as domain_name, NULL as project_name,
|
"url": "/projects/{id}", "icon": "project",
|
||||||
ts_rank(p.search_vector, websearch_to_tsquery('english', :q)) as rank
|
|
||||||
FROM projects p
|
|
||||||
LEFT JOIN domains d ON p.domain_id = d.id
|
|
||||||
WHERE p.is_deleted = false AND p.search_vector @@ websearch_to_tsquery('english', :q)
|
|
||||||
ORDER BY rank DESC LIMIT :lim
|
|
||||||
""",
|
|
||||||
"url": "/projects/{id}",
|
|
||||||
"icon": "project",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "notes",
|
"type": "notes", "label": "Notes", "table": "notes", "alias": "n",
|
||||||
"label": "Notes",
|
"name_col": "n.title", "status_col": "NULL",
|
||||||
"query": """
|
"joins": "LEFT JOIN domains d ON n.domain_id = d.id LEFT JOIN projects p ON n.project_id = p.id",
|
||||||
SELECT n.id, n.title as name, NULL as status,
|
"domain_col": "d.name", "project_col": "p.name",
|
||||||
d.name as domain_name, p.name as project_name,
|
"url": "/notes/{id}", "icon": "note",
|
||||||
ts_rank(n.search_vector, websearch_to_tsquery('english', :q)) as rank
|
|
||||||
FROM notes n
|
|
||||||
LEFT JOIN domains d ON n.domain_id = d.id
|
|
||||||
LEFT JOIN projects p ON n.project_id = p.id
|
|
||||||
WHERE n.is_deleted = false AND n.search_vector @@ websearch_to_tsquery('english', :q)
|
|
||||||
ORDER BY rank DESC LIMIT :lim
|
|
||||||
""",
|
|
||||||
"url": "/notes/{id}",
|
|
||||||
"icon": "note",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "contacts",
|
"type": "contacts", "label": "Contacts", "table": "contacts", "alias": "c",
|
||||||
"label": "Contacts",
|
"name_col": "(c.first_name || ' ' || coalesce(c.last_name, ''))", "status_col": "NULL",
|
||||||
"query": """
|
"joins": "",
|
||||||
SELECT c.id, (c.first_name || ' ' || coalesce(c.last_name, '')) as name,
|
"domain_col": "c.company", "project_col": "NULL",
|
||||||
NULL as status, c.company as domain_name, NULL as project_name,
|
"url": "/contacts/{id}", "icon": "contact",
|
||||||
ts_rank(c.search_vector, websearch_to_tsquery('english', :q)) as rank
|
|
||||||
FROM contacts c
|
|
||||||
WHERE c.is_deleted = false AND c.search_vector @@ websearch_to_tsquery('english', :q)
|
|
||||||
ORDER BY rank DESC LIMIT :lim
|
|
||||||
""",
|
|
||||||
"url": "/contacts/{id}",
|
|
||||||
"icon": "contact",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "links",
|
"type": "links", "label": "Links", "table": "links", "alias": "l",
|
||||||
"label": "Links",
|
"name_col": "l.label", "status_col": "NULL",
|
||||||
"query": """
|
"joins": "LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id",
|
||||||
SELECT l.id, l.label as name, NULL as status,
|
"domain_col": "d.name", "project_col": "p.name",
|
||||||
d.name as domain_name, p.name as project_name,
|
"url": "/links", "icon": "link",
|
||||||
ts_rank(l.search_vector, websearch_to_tsquery('english', :q)) as rank
|
|
||||||
FROM links l
|
|
||||||
LEFT JOIN domains d ON l.domain_id = d.id
|
|
||||||
LEFT JOIN projects p ON l.project_id = p.id
|
|
||||||
WHERE l.is_deleted = false AND l.search_vector @@ websearch_to_tsquery('english', :q)
|
|
||||||
ORDER BY rank DESC LIMIT :lim
|
|
||||||
""",
|
|
||||||
"url": "/links",
|
|
||||||
"icon": "link",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "lists",
|
"type": "lists", "label": "Lists", "table": "lists", "alias": "l",
|
||||||
"label": "Lists",
|
"name_col": "l.name", "status_col": "NULL",
|
||||||
"query": """
|
"joins": "LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id",
|
||||||
SELECT l.id, l.name, NULL as status,
|
"domain_col": "d.name", "project_col": "p.name",
|
||||||
d.name as domain_name, p.name as project_name,
|
"url": "/lists/{id}", "icon": "list",
|
||||||
ts_rank(l.search_vector, websearch_to_tsquery('english', :q)) as rank
|
|
||||||
FROM lists l
|
|
||||||
LEFT JOIN domains d ON l.domain_id = d.id
|
|
||||||
LEFT JOIN projects p ON l.project_id = p.id
|
|
||||||
WHERE l.is_deleted = false AND l.search_vector @@ websearch_to_tsquery('english', :q)
|
|
||||||
ORDER BY rank DESC LIMIT :lim
|
|
||||||
""",
|
|
||||||
"url": "/lists/{id}",
|
|
||||||
"icon": "list",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "meetings",
|
"type": "meetings", "label": "Meetings", "table": "meetings", "alias": "m",
|
||||||
"label": "Meetings",
|
"name_col": "m.title", "status_col": "m.status",
|
||||||
"query": """
|
"joins": "",
|
||||||
SELECT m.id, m.title as name, m.status,
|
"domain_col": "NULL", "project_col": "NULL",
|
||||||
NULL as domain_name, NULL as project_name,
|
"url": "/meetings/{id}", "icon": "meeting",
|
||||||
ts_rank(m.search_vector, websearch_to_tsquery('english', :q)) as rank
|
|
||||||
FROM meetings m
|
|
||||||
WHERE m.is_deleted = false AND m.search_vector @@ websearch_to_tsquery('english', :q)
|
|
||||||
ORDER BY rank DESC LIMIT :lim
|
|
||||||
""",
|
|
||||||
"url": "/meetings/{id}",
|
|
||||||
"icon": "meeting",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "decisions",
|
"type": "decisions", "label": "Decisions", "table": "decisions", "alias": "d",
|
||||||
"label": "Decisions",
|
"name_col": "d.title", "status_col": "d.status",
|
||||||
"query": """
|
"joins": "",
|
||||||
SELECT d.id, d.title as name, d.status,
|
"domain_col": "NULL", "project_col": "NULL",
|
||||||
NULL as domain_name, NULL as project_name,
|
"url": "/decisions/{id}", "icon": "decision",
|
||||||
ts_rank(d.search_vector, websearch_to_tsquery('english', :q)) as rank
|
|
||||||
FROM decisions d
|
|
||||||
WHERE d.is_deleted = false AND d.search_vector @@ websearch_to_tsquery('english', :q)
|
|
||||||
ORDER BY rank DESC LIMIT :lim
|
|
||||||
""",
|
|
||||||
"url": "/decisions/{id}",
|
|
||||||
"icon": "decision",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "weblinks",
|
"type": "weblinks", "label": "Weblinks", "table": "weblinks", "alias": "w",
|
||||||
"label": "Weblinks",
|
"name_col": "w.label", "status_col": "NULL",
|
||||||
"query": """
|
"joins": "",
|
||||||
SELECT w.id, w.label as name, NULL as status,
|
"domain_col": "NULL", "project_col": "NULL",
|
||||||
NULL as domain_name, NULL as project_name,
|
"url": "/weblinks", "icon": "weblink",
|
||||||
ts_rank(w.search_vector, websearch_to_tsquery('english', :q)) as rank
|
|
||||||
FROM weblinks w
|
|
||||||
WHERE w.is_deleted = false AND w.search_vector @@ websearch_to_tsquery('english', :q)
|
|
||||||
ORDER BY rank DESC LIMIT :lim
|
|
||||||
""",
|
|
||||||
"url": "/weblinks",
|
|
||||||
"icon": "weblink",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "processes",
|
"type": "processes", "label": "Processes", "table": "processes", "alias": "p",
|
||||||
"label": "Processes",
|
"name_col": "p.name", "status_col": "p.status",
|
||||||
"query": """
|
"joins": "",
|
||||||
SELECT p.id, p.name, p.status,
|
"domain_col": "p.category", "project_col": "NULL",
|
||||||
p.category as domain_name, NULL as project_name,
|
"url": "/processes/{id}", "icon": "process",
|
||||||
ts_rank(p.search_vector, websearch_to_tsquery('english', :q)) as rank
|
|
||||||
FROM processes p
|
|
||||||
WHERE p.is_deleted = false AND p.search_vector @@ websearch_to_tsquery('english', :q)
|
|
||||||
ORDER BY rank DESC LIMIT :lim
|
|
||||||
""",
|
|
||||||
"url": "/processes/{id}",
|
|
||||||
"icon": "process",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "appointments",
|
"type": "appointments", "label": "Appointments", "table": "appointments", "alias": "a",
|
||||||
"label": "Appointments",
|
"name_col": "a.title", "status_col": "NULL",
|
||||||
"query": """
|
"joins": "",
|
||||||
SELECT a.id, a.title as name, NULL as status,
|
"domain_col": "a.location", "project_col": "NULL",
|
||||||
a.location as domain_name, NULL as project_name,
|
"url": "/appointments/{id}", "icon": "appointment",
|
||||||
ts_rank(a.search_vector, websearch_to_tsquery('english', :q)) as rank
|
|
||||||
FROM appointments a
|
|
||||||
WHERE a.is_deleted = false AND a.search_vector @@ websearch_to_tsquery('english', :q)
|
|
||||||
ORDER BY rank DESC LIMIT :lim
|
|
||||||
""",
|
|
||||||
"url": "/appointments/{id}",
|
|
||||||
"icon": "appointment",
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_entity(entity: dict, q: str, tsquery_str: str, limit: int, db: AsyncSession) -> list[dict]:
|
||||||
|
"""Search a single entity using prefix tsquery, with ILIKE fallback."""
|
||||||
|
a = entity["alias"]
|
||||||
|
results = []
|
||||||
|
seen_ids = set()
|
||||||
|
|
||||||
|
# 1. tsvector prefix search
|
||||||
|
if tsquery_str:
|
||||||
|
sql = f"""
|
||||||
|
SELECT {a}.id, {entity['name_col']} as name, {entity['status_col']} as status,
|
||||||
|
{entity['domain_col']} as domain_name, {entity['project_col']} as project_name,
|
||||||
|
ts_rank({a}.search_vector, to_tsquery('english', :tsq)) as rank
|
||||||
|
FROM {entity['table']} {a}
|
||||||
|
{entity['joins']}
|
||||||
|
WHERE {a}.is_deleted = false AND {a}.search_vector @@ to_tsquery('english', :tsq)
|
||||||
|
ORDER BY rank DESC LIMIT :lim
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await db.execute(text(sql), {"tsq": tsquery_str, "lim": limit})
|
||||||
|
for r in result:
|
||||||
|
row = dict(r._mapping)
|
||||||
|
results.append(row)
|
||||||
|
seen_ids.add(str(row["id"]))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. ILIKE fallback if < 3 tsvector results
|
||||||
|
if len(results) < 3:
|
||||||
|
ilike_param = f"%{q.strip()}%"
|
||||||
|
remaining = limit - len(results)
|
||||||
|
if remaining > 0:
|
||||||
|
# Build exclusion for already-found IDs
|
||||||
|
exclude_sql = ""
|
||||||
|
params = {"ilike_q": ilike_param, "lim2": remaining}
|
||||||
|
if seen_ids:
|
||||||
|
id_placeholders = ", ".join(f":ex_{i}" for i in range(len(seen_ids)))
|
||||||
|
exclude_sql = f"AND {a}.id NOT IN ({id_placeholders})"
|
||||||
|
for i, sid in enumerate(seen_ids):
|
||||||
|
params[f"ex_{i}"] = sid
|
||||||
|
|
||||||
|
sql2 = f"""
|
||||||
|
SELECT {a}.id, {entity['name_col']} as name, {entity['status_col']} as status,
|
||||||
|
{entity['domain_col']} as domain_name, {entity['project_col']} as project_name,
|
||||||
|
0.0 as rank
|
||||||
|
FROM {entity['table']} {a}
|
||||||
|
{entity['joins']}
|
||||||
|
WHERE {a}.is_deleted = false AND {entity['name_col']} ILIKE :ilike_q {exclude_sql}
|
||||||
|
ORDER BY {entity['name_col']} LIMIT :lim2
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await db.execute(text(sql2), params)
|
||||||
|
for r in result:
|
||||||
|
results.append(dict(r._mapping))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api")
|
@router.get("/api")
|
||||||
async def search_api(
|
async def search_api(
|
||||||
q: str = Query("", min_length=1),
|
q: str = Query("", min_length=1),
|
||||||
@@ -190,18 +179,18 @@ async def search_api(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""JSON search endpoint for the Cmd/K modal."""
|
"""JSON search endpoint for the Cmd/K modal."""
|
||||||
if not q or not q.strip():
|
if not q or not q.strip() or len(q.strip()) < 2:
|
||||||
return JSONResponse({"results": [], "query": q})
|
return JSONResponse({"results": [], "query": q})
|
||||||
|
|
||||||
|
tsquery_str = build_prefix_tsquery(q)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
entities = SEARCH_ENTITIES
|
entities = SEARCH_ENTITIES
|
||||||
if entity_type:
|
if entity_type:
|
||||||
entities = [e for e in entities if e["type"] == entity_type]
|
entities = [e for e in entities if e["type"] == entity_type]
|
||||||
|
|
||||||
for entity in entities:
|
for entity in entities:
|
||||||
try:
|
rows = await _search_entity(entity, q, tsquery_str, limit, db)
|
||||||
result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": limit})
|
|
||||||
rows = [dict(r._mapping) for r in result]
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
results.append({
|
results.append({
|
||||||
"type": entity["type"],
|
"type": entity["type"],
|
||||||
@@ -214,9 +203,6 @@ async def search_api(
|
|||||||
"rank": float(row.get("rank", 0)),
|
"rank": float(row.get("rank", 0)),
|
||||||
"icon": entity["icon"],
|
"icon": entity["icon"],
|
||||||
})
|
})
|
||||||
except Exception:
|
|
||||||
# Table might not have search_vector yet, skip silently
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Sort all results by rank descending
|
# Sort all results by rank descending
|
||||||
results.sort(key=lambda r: r["rank"], reverse=True)
|
results.sort(key=lambda r: r["rank"], reverse=True)
|
||||||
@@ -234,11 +220,11 @@ async def search_page(
|
|||||||
sidebar = await get_sidebar_data(db)
|
sidebar = await get_sidebar_data(db)
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
if q and q.strip():
|
if q and q.strip() and len(q.strip()) >= 2:
|
||||||
|
tsquery_str = build_prefix_tsquery(q)
|
||||||
|
|
||||||
for entity in SEARCH_ENTITIES:
|
for entity in SEARCH_ENTITIES:
|
||||||
try:
|
rows = await _search_entity(entity, q, tsquery_str, 10, db)
|
||||||
result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": 10})
|
|
||||||
rows = [dict(r._mapping) for r in result]
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
results.append({
|
results.append({
|
||||||
"type": entity["type"],
|
"type": entity["type"],
|
||||||
@@ -250,8 +236,6 @@ async def search_page(
|
|||||||
"url": entity["url"].format(id=row["id"]),
|
"url": entity["url"].format(id=row["id"]),
|
||||||
"icon": entity["icon"],
|
"icon": entity["icon"],
|
||||||
})
|
})
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return templates.TemplateResponse("search.html", {
|
return templates.TemplateResponse("search.html", {
|
||||||
"request": request, "sidebar": sidebar,
|
"request": request, "sidebar": sidebar,
|
||||||
|
|||||||
119
routers/tasks.py
119
routers/tasks.py
@@ -186,7 +186,11 @@ async def create_task(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{task_id}")
|
@router.get("/{task_id}")
|
||||||
async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
async def task_detail(
|
||||||
|
task_id: str, request: Request,
|
||||||
|
tab: str = "overview",
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
repo = BaseRepository("tasks", db)
|
repo = BaseRepository("tasks", db)
|
||||||
sidebar = await get_sidebar_data(db)
|
sidebar = await get_sidebar_data(db)
|
||||||
item = await repo.get(task_id)
|
item = await repo.get(task_id)
|
||||||
@@ -212,7 +216,9 @@ async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends
|
|||||||
row = result.first()
|
row = result.first()
|
||||||
parent = dict(row._mapping) if row else None
|
parent = dict(row._mapping) if row else None
|
||||||
|
|
||||||
# Subtasks
|
# Subtasks (always needed for overview tab)
|
||||||
|
subtasks = []
|
||||||
|
if tab == "overview":
|
||||||
result = await db.execute(text("""
|
result = await db.execute(text("""
|
||||||
SELECT * FROM tasks WHERE parent_id = :tid AND is_deleted = false
|
SELECT * FROM tasks WHERE parent_id = :tid AND is_deleted = false
|
||||||
ORDER BY sort_order, created_at
|
ORDER BY sort_order, created_at
|
||||||
@@ -221,10 +227,90 @@ async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends
|
|||||||
|
|
||||||
running_task_id = await get_running_task_id(db)
|
running_task_id = await get_running_task_id(db)
|
||||||
|
|
||||||
|
# Tab-specific data
|
||||||
|
tab_data = []
|
||||||
|
all_contacts = []
|
||||||
|
|
||||||
|
if tab == "notes":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT * FROM notes WHERE task_id = :tid AND is_deleted = false
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
"""), {"tid": task_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
elif tab == "weblinks":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT * FROM weblinks WHERE task_id = :tid AND is_deleted = false
|
||||||
|
ORDER BY sort_order, label
|
||||||
|
"""), {"tid": task_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
elif tab == "files":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT f.* FROM files f
|
||||||
|
JOIN file_mappings fm ON fm.file_id = f.id
|
||||||
|
WHERE fm.context_type = 'task' AND fm.context_id = :tid AND f.is_deleted = false
|
||||||
|
ORDER BY f.created_at DESC
|
||||||
|
"""), {"tid": task_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
elif tab == "lists":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT l.*,
|
||||||
|
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count
|
||||||
|
FROM lists l
|
||||||
|
WHERE l.task_id = :tid AND l.is_deleted = false
|
||||||
|
ORDER BY l.sort_order, l.created_at DESC
|
||||||
|
"""), {"tid": task_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
elif tab == "decisions":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT * FROM decisions WHERE task_id = :tid AND is_deleted = false
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"""), {"tid": task_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
elif tab == "contacts":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT c.*, ct.role, ct.created_at as linked_at
|
||||||
|
FROM contacts c
|
||||||
|
JOIN contact_tasks ct ON ct.contact_id = c.id
|
||||||
|
WHERE ct.task_id = :tid AND c.is_deleted = false
|
||||||
|
ORDER BY c.first_name
|
||||||
|
"""), {"tid": task_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
# All contacts for add dropdown
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT id, first_name, last_name FROM contacts
|
||||||
|
WHERE is_deleted = false ORDER BY first_name
|
||||||
|
"""))
|
||||||
|
all_contacts = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# Tab counts for badges
|
||||||
|
counts = {}
|
||||||
|
for count_tab, count_sql in [
|
||||||
|
("notes", "SELECT count(*) FROM notes WHERE task_id = :tid AND is_deleted = false"),
|
||||||
|
("weblinks", "SELECT count(*) FROM weblinks WHERE task_id = :tid AND is_deleted = false"),
|
||||||
|
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'task' AND fm.context_id = :tid AND f.is_deleted = false"),
|
||||||
|
("lists", "SELECT count(*) FROM lists WHERE task_id = :tid AND is_deleted = false"),
|
||||||
|
("decisions", "SELECT count(*) FROM decisions WHERE task_id = :tid AND is_deleted = false"),
|
||||||
|
("contacts", "SELECT count(*) FROM contacts c JOIN contact_tasks ct ON ct.contact_id = c.id WHERE ct.task_id = :tid AND c.is_deleted = false"),
|
||||||
|
]:
|
||||||
|
result = await db.execute(text(count_sql), {"tid": task_id})
|
||||||
|
counts[count_tab] = result.scalar() or 0
|
||||||
|
|
||||||
|
# Subtask count for overview badge
|
||||||
|
result = await db.execute(text(
|
||||||
|
"SELECT count(*) FROM tasks WHERE parent_id = :tid AND is_deleted = false"
|
||||||
|
), {"tid": task_id})
|
||||||
|
counts["overview"] = result.scalar() or 0
|
||||||
|
|
||||||
return templates.TemplateResponse("task_detail.html", {
|
return templates.TemplateResponse("task_detail.html", {
|
||||||
"request": request, "sidebar": sidebar, "item": item,
|
"request": request, "sidebar": sidebar, "item": item,
|
||||||
"domain": domain, "project": project, "parent": parent,
|
"domain": domain, "project": project, "parent": parent,
|
||||||
"subtasks": subtasks,
|
"subtasks": subtasks, "tab": tab, "tab_data": tab_data,
|
||||||
|
"all_contacts": all_contacts, "counts": counts,
|
||||||
"running_task_id": running_task_id,
|
"running_task_id": running_task_id,
|
||||||
"page_title": item["title"], "active_nav": "tasks",
|
"page_title": item["title"], "active_nav": "tasks",
|
||||||
})
|
})
|
||||||
@@ -368,3 +454,30 @@ async def quick_add(
|
|||||||
await repo.create(data)
|
await repo.create(data)
|
||||||
referer = request.headers.get("referer", "/tasks")
|
referer = request.headers.get("referer", "/tasks")
|
||||||
return RedirectResponse(url=referer, status_code=303)
|
return RedirectResponse(url=referer, status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Contact linking ----
|
||||||
|
|
||||||
|
@router.post("/{task_id}/contacts/add")
|
||||||
|
async def add_contact(
|
||||||
|
task_id: str,
|
||||||
|
contact_id: str = Form(...),
|
||||||
|
role: Optional[str] = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(text("""
|
||||||
|
INSERT INTO contact_tasks (contact_id, task_id, role)
|
||||||
|
VALUES (:cid, :tid, :role) ON CONFLICT DO NOTHING
|
||||||
|
"""), {"cid": contact_id, "tid": task_id, "role": role if role and role.strip() else None})
|
||||||
|
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{task_id}/contacts/{contact_id}/remove")
|
||||||
|
async def remove_contact(
|
||||||
|
task_id: str, contact_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(text(
|
||||||
|
"DELETE FROM contact_tasks WHERE contact_id = :cid AND task_id = :tid"
|
||||||
|
), {"cid": contact_id, "tid": task_id})
|
||||||
|
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ async def list_weblinks(
|
|||||||
async def create_form(
|
async def create_form(
|
||||||
request: Request,
|
request: Request,
|
||||||
folder_id: Optional[str] = None,
|
folder_id: Optional[str] = None,
|
||||||
|
task_id: Optional[str] = None,
|
||||||
|
meeting_id: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
sidebar = await get_sidebar_data(db)
|
sidebar = await get_sidebar_data(db)
|
||||||
@@ -92,6 +94,8 @@ async def create_form(
|
|||||||
"page_title": "New Weblink", "active_nav": "weblinks",
|
"page_title": "New Weblink", "active_nav": "weblinks",
|
||||||
"item": None,
|
"item": None,
|
||||||
"prefill_folder_id": folder_id or "",
|
"prefill_folder_id": folder_id or "",
|
||||||
|
"prefill_task_id": task_id or "",
|
||||||
|
"prefill_meeting_id": meeting_id or "",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -102,11 +106,17 @@ async def create_weblink(
|
|||||||
url: str = Form(...),
|
url: str = Form(...),
|
||||||
description: Optional[str] = Form(None),
|
description: Optional[str] = Form(None),
|
||||||
folder_id: Optional[str] = Form(None),
|
folder_id: Optional[str] = Form(None),
|
||||||
|
task_id: Optional[str] = Form(None),
|
||||||
|
meeting_id: Optional[str] = Form(None),
|
||||||
tags: Optional[str] = Form(None),
|
tags: Optional[str] = Form(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
repo = BaseRepository("weblinks", db)
|
repo = BaseRepository("weblinks", db)
|
||||||
data = {"label": label, "url": url, "description": description}
|
data = {"label": label, "url": url, "description": description}
|
||||||
|
if task_id and task_id.strip():
|
||||||
|
data["task_id"] = task_id
|
||||||
|
if meeting_id and meeting_id.strip():
|
||||||
|
data["meeting_id"] = meeting_id
|
||||||
if tags and tags.strip():
|
if tags and tags.strip():
|
||||||
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||||
|
|
||||||
@@ -119,6 +129,10 @@ async def create_weblink(
|
|||||||
VALUES (:fid, :wid) ON CONFLICT DO NOTHING
|
VALUES (:fid, :wid) ON CONFLICT DO NOTHING
|
||||||
"""), {"fid": folder_id, "wid": weblink["id"]})
|
"""), {"fid": folder_id, "wid": weblink["id"]})
|
||||||
|
|
||||||
|
if task_id and task_id.strip():
|
||||||
|
return RedirectResponse(url=f"/tasks/{task_id}?tab=weblinks", status_code=303)
|
||||||
|
if meeting_id and meeting_id.strip():
|
||||||
|
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=weblinks", status_code=303)
|
||||||
redirect_url = f"/weblinks?folder_id={folder_id}" if folder_id and folder_id.strip() else "/weblinks"
|
redirect_url = f"/weblinks?folder_id={folder_id}" if folder_id and folder_id.strip() else "/weblinks"
|
||||||
return RedirectResponse(url=redirect_url, status_code=303)
|
return RedirectResponse(url=redirect_url, status_code=303)
|
||||||
|
|
||||||
|
|||||||
@@ -1451,9 +1451,8 @@ a:hover { color: var(--accent-hover); }
|
|||||||
.eisenhower-grid {
|
.eisenhower-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 32px 1fr 1fr;
|
grid-template-columns: 32px 1fr 1fr;
|
||||||
grid-template-rows: 1fr 1fr auto;
|
grid-template-rows: auto auto auto;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 60vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.eisenhower-y-label {
|
.eisenhower-y-label {
|
||||||
@@ -1488,9 +1487,9 @@ a:hover { color: var(--accent-hover); }
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
min-height: 200px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.eisenhower-q1 { border-top: 3px solid var(--red); }
|
.eisenhower-q1 { border-top: 3px solid var(--red); }
|
||||||
|
|||||||
@@ -131,6 +131,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-section" style="margin-top: auto; padding-bottom: 12px;">
|
<div class="nav-section" style="margin-top: auto; padding-bottom: 12px;">
|
||||||
|
<a href="/history" class="nav-item {{ 'active' if active_nav == 'history' }}">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||||
|
History
|
||||||
|
</a>
|
||||||
<a href="/admin/trash" class="nav-item {{ 'active' if active_nav == 'trash' }}">
|
<a href="/admin/trash" class="nav-item {{ 'active' if active_nav == 'trash' }}">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
Trash
|
Trash
|
||||||
|
|||||||
@@ -77,9 +77,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if prefill_task_id is defined and prefill_task_id %}<input type="hidden" name="task_id" value="{{ prefill_task_id }}">{% endif %}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Record Decision' }}</button>
|
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Record Decision' }}</button>
|
||||||
<a href="{{ '/decisions/' ~ item.id if item else '/decisions' }}" class="btn btn-secondary">Cancel</a>
|
<a href="{{ '/tasks/' ~ prefill_task_id ~ '?tab=decisions' if prefill_task_id is defined and prefill_task_id else ('/decisions/' ~ item.id if item else '/decisions') }}" class="btn btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,34 @@
|
|||||||
<span class="text-muted">{{ total }} open tasks classified by priority & urgency</span>
|
<span class="text-muted">{{ total }} open tasks classified by priority & urgency</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<form class="filters-bar" method="get" action="/eisenhower" id="eis-filters">
|
||||||
|
<select name="domain_id" class="filter-select" id="eis-domain" onchange="this.form.submit()">
|
||||||
|
<option value="">All Domains</option>
|
||||||
|
{% for d in domains %}
|
||||||
|
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select name="project_id" class="filter-select" id="eis-project" onchange="this.form.submit()">
|
||||||
|
<option value="">All Projects</option>
|
||||||
|
{% for p in projects %}
|
||||||
|
<option value="{{ p.id }}" {{ 'selected' if current_project_id == p.id|string }}>{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select name="status" class="filter-select" onchange="this.form.submit()">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="open" {{ 'selected' if current_status == 'open' }}>Open</option>
|
||||||
|
<option value="in_progress" {{ 'selected' if current_status == 'in_progress' }}>In Progress</option>
|
||||||
|
<option value="blocked" {{ 'selected' if current_status == 'blocked' }}>Blocked</option>
|
||||||
|
</select>
|
||||||
|
<select name="context" class="filter-select" onchange="this.form.submit()">
|
||||||
|
<option value="">All Contexts</option>
|
||||||
|
{% for ct in context_types %}
|
||||||
|
<option value="{{ ct.value }}" {{ 'selected' if current_context == ct.value }}>{{ ct.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="eisenhower-grid">
|
<div class="eisenhower-grid">
|
||||||
<!-- Axis labels -->
|
<!-- Axis labels -->
|
||||||
<div class="eisenhower-y-label">
|
<div class="eisenhower-y-label">
|
||||||
@@ -126,4 +154,31 @@
|
|||||||
<div class="eisenhower-x-label">Not Urgent</div>
|
<div class="eisenhower-x-label">Not Urgent</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var domainSel = document.getElementById('eis-domain');
|
||||||
|
var projectSel = document.getElementById('eis-project');
|
||||||
|
var currentProjectId = '{{ current_project_id }}';
|
||||||
|
var form = document.getElementById('eis-filters');
|
||||||
|
|
||||||
|
domainSel.addEventListener('change', function() {
|
||||||
|
var did = domainSel.value;
|
||||||
|
if (!did) { form.submit(); return; }
|
||||||
|
fetch('/projects/api/by-domain?domain_id=' + encodeURIComponent(did))
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(projects) {
|
||||||
|
projectSel.innerHTML = '<option value="">All Projects</option>';
|
||||||
|
projects.forEach(function(p) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = p.id;
|
||||||
|
opt.textContent = p.name;
|
||||||
|
if (p.id === currentProjectId) opt.selected = true;
|
||||||
|
projectSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
form.submit();
|
||||||
|
})
|
||||||
|
.catch(function() { form.submit(); });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -37,9 +37,31 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Add task to focus -->
|
<!-- Add task to focus -->
|
||||||
{% if available_tasks %}
|
|
||||||
<div class="card mt-4">
|
<div class="card mt-4">
|
||||||
<div class="card-header"><h2 class="card-title">Add to Focus</h2></div>
|
<div class="card-header"><h2 class="card-title">Add to Focus</h2></div>
|
||||||
|
|
||||||
|
<form class="filters-bar" method="get" action="/focus" id="focus-filters" style="padding: 8px 12px; border-bottom: 1px solid var(--border);">
|
||||||
|
<input type="hidden" name="focus_date" value="{{ focus_date }}">
|
||||||
|
<select name="domain_id" class="filter-select" id="focus-domain" onchange="this.form.submit()">
|
||||||
|
<option value="">All Domains</option>
|
||||||
|
{% for d in domains %}
|
||||||
|
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select name="area_id" class="filter-select" onchange="this.form.submit()">
|
||||||
|
<option value="">All Areas</option>
|
||||||
|
{% for a in areas %}
|
||||||
|
<option value="{{ a.id }}" {{ 'selected' if current_area_id == a.id|string }}>{{ a.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select name="project_id" class="filter-select" id="focus-project" onchange="this.form.submit()">
|
||||||
|
<option value="">All Projects</option>
|
||||||
|
{% for p in projects %}
|
||||||
|
<option value="{{ p.id }}" {{ 'selected' if current_project_id == p.id|string }}>{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
|
||||||
{% for t in available_tasks[:15] %}
|
{% for t in available_tasks[:15] %}
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
<span class="priority-dot priority-{{ t.priority }}"></span>
|
<span class="priority-dot priority-{{ t.priority }}"></span>
|
||||||
@@ -52,7 +74,36 @@
|
|||||||
<button class="btn btn-ghost btn-xs" style="color:var(--green)">+ Focus</button>
|
<button class="btn btn-ghost btn-xs" style="color:var(--green)">+ Focus</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="padding: 16px; color: var(--muted); font-size: 0.85rem;">No available tasks matching filters</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var domainSel = document.getElementById('focus-domain');
|
||||||
|
var projectSel = document.getElementById('focus-project');
|
||||||
|
var currentProjectId = '{{ current_project_id }}';
|
||||||
|
var form = document.getElementById('focus-filters');
|
||||||
|
|
||||||
|
domainSel.addEventListener('change', function() {
|
||||||
|
var did = domainSel.value;
|
||||||
|
if (!did) { form.submit(); return; }
|
||||||
|
fetch('/projects/api/by-domain?domain_id=' + encodeURIComponent(did))
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(projects) {
|
||||||
|
projectSel.innerHTML = '<option value="">All Projects</option>';
|
||||||
|
projects.forEach(function(p) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = p.id;
|
||||||
|
opt.textContent = p.name;
|
||||||
|
if (p.id === currentProjectId) opt.selected = true;
|
||||||
|
projectSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
form.submit();
|
||||||
|
})
|
||||||
|
.catch(function() { form.submit(); });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
32
templates/history.html
Normal file
32
templates/history.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Change History<span class="page-count">{{ items|length }}</span></h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="filters-bar" method="get" action="/history">
|
||||||
|
<select name="entity_type" class="filter-select" onchange="this.form.submit()">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
{% for t in type_options %}
|
||||||
|
<option value="{{ t.value }}" {{ 'selected' if current_type == t.value }}>{{ t.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if items %}
|
||||||
|
<div class="card mt-3">
|
||||||
|
{% for item in items %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-tag" style="min-width: 70px; text-align: center;">{{ item.type_label }}</span>
|
||||||
|
<span class="row-title"><a href="{{ item.url }}">{{ item.label }}</a></span>
|
||||||
|
<span class="status-badge {{ 'status-active' if item.action == 'created' else 'status-open' }}">{{ item.action }}</span>
|
||||||
|
<span class="row-meta">{{ item.updated_at.strftime('%Y-%m-%d %H:%M') if item.updated_at else '' }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state mt-3">
|
||||||
|
<div class="empty-state-text">No recent changes</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -95,4 +95,38 @@
|
|||||||
<div class="empty-state-text">No items yet. Add one above.</div>
|
<div class="empty-state-text">No items yet. Add one above.</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Contacts -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Contacts<span class="page-count">{{ contacts|length }}</span></h3>
|
||||||
|
</div>
|
||||||
|
<form action="/lists/{{ item.id }}/contacts/add" method="post" class="flex gap-2 items-end" style="padding: 12px; border-bottom: 1px solid var(--border);">
|
||||||
|
<div class="form-group" style="flex:1; margin:0;">
|
||||||
|
<select name="contact_id" class="form-select" required>
|
||||||
|
<option value="">Select contact...</option>
|
||||||
|
{% for c in all_contacts %}
|
||||||
|
<option value="{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:1; margin:0;">
|
||||||
|
<input type="text" name="role" class="form-input" placeholder="Role (optional)">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||||
|
</form>
|
||||||
|
{% for c in contacts %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/contacts/{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</a></span>
|
||||||
|
{% if c.role %}<span class="row-tag">{{ c.role }}</span>{% endif %}
|
||||||
|
<div class="row-actions">
|
||||||
|
<form action="/lists/{{ item.id }}/contacts/{{ c.id }}/remove" method="post" style="display:inline">
|
||||||
|
<button class="btn btn-ghost btn-xs" title="Remove">Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div style="padding: 12px; color: var(--muted); font-size: 0.85rem;">No contacts linked</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -73,9 +73,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if prefill_task_id is defined and prefill_task_id %}<input type="hidden" name="task_id" value="{{ prefill_task_id }}">{% endif %}
|
||||||
|
{% if prefill_meeting_id is defined and prefill_meeting_id %}<input type="hidden" name="meeting_id" value="{{ prefill_meeting_id }}">{% endif %}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Create List' }}</button>
|
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Create List' }}</button>
|
||||||
<a href="{{ '/lists/' ~ item.id if item else '/lists' }}" class="btn btn-secondary">Cancel</a>
|
<a href="{{ '/tasks/' ~ prefill_task_id ~ '?tab=lists' if prefill_task_id is defined and prefill_task_id else ('/lists/' ~ item.id if item else '/lists') }}" class="btn btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<form class="filters-bar" method="get" action="/lists">
|
<form class="filters-bar" method="get" action="/lists" id="list-filters">
|
||||||
<select name="domain_id" class="filter-select" onchange="this.form.submit()">
|
<select name="domain_id" class="filter-select" id="domain-filter" onchange="this.form.submit()">
|
||||||
<option value="">All Domains</option>
|
<option value="">All Domains</option>
|
||||||
{% for d in domains %}
|
{% for d in domains %}
|
||||||
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
|
<option value="{{ d.id }}" {{ 'selected' if current_domain_id == d.id|string }}>{{ d.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<select name="project_id" class="filter-select" onchange="this.form.submit()">
|
<select name="project_id" class="filter-select" id="project-filter" onchange="this.form.submit()">
|
||||||
<option value="">All Projects</option>
|
<option value="">All Projects</option>
|
||||||
{% for p in projects %}
|
{% for p in projects %}
|
||||||
<option value="{{ p.id }}" {{ 'selected' if current_project_id == p.id|string }}>{{ p.name }}</option>
|
<option value="{{ p.id }}" {{ 'selected' if current_project_id == p.id|string }}>{{ p.name }}</option>
|
||||||
@@ -57,4 +57,38 @@
|
|||||||
<a href="/lists/create" class="btn btn-primary">Create First List</a>
|
<a href="/lists/create" class="btn btn-primary">Create First List</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var domainSel = document.getElementById('domain-filter');
|
||||||
|
var projectSel = document.getElementById('project-filter');
|
||||||
|
var currentProjectId = '{{ current_project_id }}';
|
||||||
|
|
||||||
|
domainSel.addEventListener('change', function() {
|
||||||
|
var did = domainSel.value;
|
||||||
|
if (!did) {
|
||||||
|
// No domain filter - submit immediately, server returns all projects
|
||||||
|
document.getElementById('list-filters').submit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fetch projects for this domain
|
||||||
|
fetch('/projects/api/by-domain?domain_id=' + encodeURIComponent(did))
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(projects) {
|
||||||
|
projectSel.innerHTML = '<option value="">All Projects</option>';
|
||||||
|
projects.forEach(function(p) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = p.id;
|
||||||
|
opt.textContent = p.name;
|
||||||
|
if (p.id === currentProjectId) opt.selected = true;
|
||||||
|
projectSel.appendChild(opt);
|
||||||
|
});
|
||||||
|
document.getElementById('list-filters').submit();
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
document.getElementById('list-filters').submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -28,9 +28,21 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tab-strip">
|
||||||
|
<a href="/meetings/{{ item.id }}?tab=overview" class="tab-item {{ 'active' if tab == 'overview' }}">Overview</a>
|
||||||
|
<a href="/meetings/{{ item.id }}?tab=notes" class="tab-item {{ 'active' if tab == 'notes' }}">Notes{% if counts.notes %} ({{ counts.notes }}){% endif %}</a>
|
||||||
|
<a href="/meetings/{{ item.id }}?tab=weblinks" class="tab-item {{ 'active' if tab == 'weblinks' }}">Weblinks{% if counts.weblinks %} ({{ counts.weblinks }}){% endif %}</a>
|
||||||
|
<a href="/meetings/{{ item.id }}?tab=files" class="tab-item {{ 'active' if tab == 'files' }}">Files{% if counts.files %} ({{ counts.files }}){% endif %}</a>
|
||||||
|
<a href="/meetings/{{ item.id }}?tab=lists" class="tab-item {{ 'active' if tab == 'lists' }}">Lists{% if counts.lists %} ({{ counts.lists }}){% endif %}</a>
|
||||||
|
<a href="/meetings/{{ item.id }}?tab=processes" class="tab-item {{ 'active' if tab == 'processes' }}">Processes</a>
|
||||||
|
<a href="/meetings/{{ item.id }}?tab=contacts" class="tab-item {{ 'active' if tab == 'contacts' }}">Contacts{% if counts.contacts %} ({{ counts.contacts }}){% endif %}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if tab == 'overview' %}
|
||||||
<!-- Agenda -->
|
<!-- Agenda -->
|
||||||
{% if item.agenda %}
|
{% if item.agenda %}
|
||||||
<div class="card mt-3">
|
<div class="card mb-4">
|
||||||
<div class="card-header"><h3 class="card-title">Agenda</h3></div>
|
<div class="card-header"><h3 class="card-title">Agenda</h3></div>
|
||||||
<div class="detail-body" style="padding: 12px 16px;">{{ item.agenda }}</div>
|
<div class="detail-body" style="padding: 12px 16px;">{{ item.agenda }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +50,7 @@
|
|||||||
|
|
||||||
<!-- Meeting Notes -->
|
<!-- Meeting Notes -->
|
||||||
{% if item.notes_body %}
|
{% if item.notes_body %}
|
||||||
<div class="card mt-3">
|
<div class="card mb-4">
|
||||||
<div class="card-header"><h3 class="card-title">Notes</h3></div>
|
<div class="card-header"><h3 class="card-title">Notes</h3></div>
|
||||||
<div class="detail-body" style="padding: 12px 16px;">{{ item.notes_body }}</div>
|
<div class="detail-body" style="padding: 12px 16px;">{{ item.notes_body }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -46,19 +58,17 @@
|
|||||||
|
|
||||||
<!-- Transcript -->
|
<!-- Transcript -->
|
||||||
{% if item.transcript %}
|
{% if item.transcript %}
|
||||||
<div class="card mt-3">
|
<div class="card mb-4">
|
||||||
<div class="card-header"><h3 class="card-title">Transcript</h3></div>
|
<div class="card-header"><h3 class="card-title">Transcript</h3></div>
|
||||||
<div class="detail-body" style="padding: 12px 16px; font-family: var(--font-mono); font-size: 0.82rem; white-space: pre-wrap;">{{ item.transcript }}</div>
|
<div class="detail-body" style="padding: 12px 16px; font-family: var(--font-mono); font-size: 0.82rem; white-space: pre-wrap;">{{ item.transcript }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Action Items -->
|
<!-- Action Items -->
|
||||||
<div class="card mt-3">
|
<div class="card mb-4">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">Action Items<span class="page-count">{{ action_items|length }}</span></h3>
|
<h3 class="card-title">Action Items<span class="page-count">{{ action_items|length }}</span></h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick add action item -->
|
|
||||||
<form class="quick-add" action="/meetings/{{ item.id }}/action-item" method="post" style="border-bottom: 1px solid var(--border);">
|
<form class="quick-add" action="/meetings/{{ item.id }}/action-item" method="post" style="border-bottom: 1px solid var(--border);">
|
||||||
<input type="text" name="title" placeholder="Add action item..." required>
|
<input type="text" name="title" placeholder="Add action item..." required>
|
||||||
<select name="domain_id" class="filter-select" required style="width: auto;">
|
<select name="domain_id" class="filter-select" required style="width: auto;">
|
||||||
@@ -68,7 +78,6 @@
|
|||||||
</select>
|
</select>
|
||||||
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% for task in action_items %}
|
{% for task in action_items %}
|
||||||
<div class="list-row {{ 'completed' if task.status == 'done' }}">
|
<div class="list-row {{ 'completed' if task.status == 'done' }}">
|
||||||
<div class="row-check">
|
<div class="row-check">
|
||||||
@@ -83,16 +92,14 @@
|
|||||||
{% if task.project_name %}<span class="row-tag">{{ task.project_name }}</span>{% endif %}
|
{% if task.project_name %}<span class="row-tag">{{ task.project_name }}</span>{% endif %}
|
||||||
<span class="status-badge status-{{ task.status }}">{{ task.status|replace('_', ' ') }}</span>
|
<span class="status-badge status-{{ task.status }}">{{ task.status|replace('_', ' ') }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% else %}
|
||||||
|
|
||||||
{% if not action_items %}
|
|
||||||
<div style="padding: 16px; color: var(--muted); font-size: 0.85rem;">No action items yet</div>
|
<div style="padding: 16px; color: var(--muted); font-size: 0.85rem;">No action items yet</div>
|
||||||
{% endif %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Decisions -->
|
<!-- Decisions -->
|
||||||
{% if decisions %}
|
{% if decisions %}
|
||||||
<div class="card mt-3">
|
<div class="card">
|
||||||
<div class="card-header"><h3 class="card-title">Decisions<span class="page-count">{{ decisions|length }}</span></h3></div>
|
<div class="card-header"><h3 class="card-title">Decisions<span class="page-count">{{ decisions|length }}</span></h3></div>
|
||||||
{% for dec in decisions %}
|
{% for dec in decisions %}
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
@@ -104,16 +111,89 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Attendees -->
|
{% elif tab == 'notes' %}
|
||||||
{% if attendees %}
|
<a href="/notes/create?meeting_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Note</a>
|
||||||
<div class="card mt-3">
|
{% for n in tab_data %}
|
||||||
<div class="card-header"><h3 class="card-title">Attendees<span class="page-count">{{ attendees|length }}</span></h3></div>
|
<div class="list-row">
|
||||||
{% for att in attendees %}
|
<span class="row-title"><a href="/notes/{{ n.id }}">{{ n.title }}</a></span>
|
||||||
<div class="list-row">
|
<span class="row-meta">{{ n.updated_at.strftime('%Y-%m-%d') if n.updated_at else '' }}</span>
|
||||||
<span class="row-title"><a href="/contacts/{{ att.id }}">{{ att.first_name }} {{ att.last_name or '' }}</a></span>
|
|
||||||
{% if att.role %}<span class="row-tag">{{ att.role }}</span>{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">No notes linked to this meeting</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% elif tab == 'weblinks' %}
|
||||||
|
<a href="/weblinks/create?meeting_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Weblink</a>
|
||||||
|
{% for w in tab_data %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="{{ w.url }}" target="_blank">{{ w.label }}</a></span>
|
||||||
|
<span class="row-meta">{{ w.url[:50] }}{% if w.url|length > 50 %}...{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">No weblinks linked to this meeting</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% elif tab == 'files' %}
|
||||||
|
<a href="/files/upload?context_type=meeting&context_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ Upload File</a>
|
||||||
|
{% for f in tab_data %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/files/{{ f.id }}">{{ f.original_filename }}</a></span>
|
||||||
|
<span class="row-meta">{{ f.created_at.strftime('%Y-%m-%d') if f.created_at else '' }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">No files attached to this meeting</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% elif tab == 'lists' %}
|
||||||
|
<a href="/lists/create?meeting_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New List</a>
|
||||||
|
{% for l in tab_data %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/lists/{{ l.id }}">{{ l.name }}</a></span>
|
||||||
|
<span class="row-meta">{{ l.item_count }} items</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">No lists linked to this meeting</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% elif tab == 'processes' %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">Process management coming soon</div></div>
|
||||||
|
|
||||||
|
{% elif tab == 'contacts' %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<form action="/meetings/{{ item.id }}/contacts/add" method="post" class="flex gap-2 items-end" style="padding: 12px;">
|
||||||
|
<div class="form-group" style="flex:1; margin:0;">
|
||||||
|
<label class="form-label">Contact</label>
|
||||||
|
<select name="contact_id" class="form-select" required>
|
||||||
|
<option value="">Select contact...</option>
|
||||||
|
{% for c in all_contacts %}
|
||||||
|
<option value="{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:1; margin:0;">
|
||||||
|
<label class="form-label">Role</label>
|
||||||
|
<select name="role" class="form-select">
|
||||||
|
<option value="attendee">Attendee</option>
|
||||||
|
<option value="organizer">Organizer</option>
|
||||||
|
<option value="optional">Optional</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% for c in tab_data %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/contacts/{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</a></span>
|
||||||
|
{% if c.role %}<span class="row-tag">{{ c.role }}</span>{% endif %}
|
||||||
|
<span class="row-meta">{{ c.linked_at.strftime('%Y-%m-%d') if c.linked_at else '' }}</span>
|
||||||
|
<div class="row-actions">
|
||||||
|
<form action="/meetings/{{ item.id }}/contacts/{{ c.id }}/remove" method="post" style="display:inline">
|
||||||
|
<button class="btn btn-ghost btn-xs" title="Remove">Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">No contacts linked to this meeting</div></div>
|
||||||
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
<div class="form-group full-width"><label class="form-label">Content</label><textarea name="body" class="form-textarea" rows="15" style="font-family:var(--font-mono);font-size:0.88rem">{{ item.body if item and item.body else '' }}</textarea></div>
|
<div class="form-group full-width"><label class="form-label">Content</label><textarea name="body" class="form-textarea" rows="15" style="font-family:var(--font-mono);font-size:0.88rem">{{ item.body if item and item.body else '' }}</textarea></div>
|
||||||
<div class="form-group full-width"><label class="form-label">Tags</label><input type="text" name="tags" class="form-input" value="{{ item.tags|join(', ') if item and item.tags else '' }}"></div>
|
<div class="form-group full-width"><label class="form-label">Tags</label><input type="text" name="tags" class="form-input" value="{{ item.tags|join(', ') if item and item.tags else '' }}"></div>
|
||||||
<input type="hidden" name="content_format" value="rich">
|
<input type="hidden" name="content_format" value="rich">
|
||||||
|
{% if prefill_task_id is defined and prefill_task_id %}<input type="hidden" name="task_id" value="{{ prefill_task_id }}">{% endif %}
|
||||||
|
{% if prefill_meeting_id is defined and prefill_meeting_id %}<input type="hidden" name="meeting_id" value="{{ prefill_meeting_id }}">{% endif %}
|
||||||
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="{{ '/notes/' ~ item.id if item else '/notes' }}" class="btn btn-secondary">Cancel</a></div>
|
<div class="form-actions"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="{{ '/notes/' ~ item.id if item else '/notes' }}" class="btn btn-secondary">Cancel</a></div>
|
||||||
</div>
|
</div>
|
||||||
</form></div>
|
</form></div>
|
||||||
|
|||||||
@@ -34,8 +34,13 @@
|
|||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="tab-strip">
|
<div class="tab-strip">
|
||||||
<a href="/projects/{{ item.id }}?tab=tasks" class="tab-item {{ 'active' if tab == 'tasks' }}">Tasks ({{ tasks|length }})</a>
|
<a href="/projects/{{ item.id }}?tab=tasks" class="tab-item {{ 'active' if tab == 'tasks' }}">Tasks ({{ tasks|length }})</a>
|
||||||
<a href="/projects/{{ item.id }}?tab=notes" class="tab-item {{ 'active' if tab == 'notes' }}">Notes ({{ notes|length }})</a>
|
<a href="/projects/{{ item.id }}?tab=notes" class="tab-item {{ 'active' if tab == 'notes' }}">Notes{% if counts.notes %} ({{ counts.notes }}){% endif %}</a>
|
||||||
<a href="/projects/{{ item.id }}?tab=links" class="tab-item {{ 'active' if tab == 'links' }}">Links ({{ links|length }})</a>
|
<a href="/projects/{{ item.id }}?tab=links" class="tab-item {{ 'active' if tab == 'links' }}">Links{% if counts.links %} ({{ counts.links }}){% endif %}</a>
|
||||||
|
<a href="/projects/{{ item.id }}?tab=files" class="tab-item {{ 'active' if tab == 'files' }}">Files{% if counts.files %} ({{ counts.files }}){% endif %}</a>
|
||||||
|
<a href="/projects/{{ item.id }}?tab=lists" class="tab-item {{ 'active' if tab == 'lists' }}">Lists{% if counts.lists %} ({{ counts.lists }}){% endif %}</a>
|
||||||
|
<a href="/projects/{{ item.id }}?tab=decisions" class="tab-item {{ 'active' if tab == 'decisions' }}">Decisions{% if counts.decisions %} ({{ counts.decisions }}){% endif %}</a>
|
||||||
|
<a href="/projects/{{ item.id }}?tab=processes" class="tab-item {{ 'active' if tab == 'processes' }}">Processes</a>
|
||||||
|
<a href="/projects/{{ item.id }}?tab=contacts" class="tab-item {{ 'active' if tab == 'contacts' }}">Contacts{% if counts.contacts %} ({{ counts.contacts }}){% endif %}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if tab == 'tasks' %}
|
{% if tab == 'tasks' %}
|
||||||
@@ -91,5 +96,75 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state"><div class="empty-state-text">No links yet</div></div>
|
<div class="empty-state"><div class="empty-state-text">No links yet</div></div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
{% elif tab == 'files' %}
|
||||||
|
<a href="/files/upload?context_type=project&context_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ Upload File</a>
|
||||||
|
{% for f in tab_data %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/files/{{ f.id }}">{{ f.original_filename }}</a></span>
|
||||||
|
<span class="row-meta">{{ f.created_at.strftime('%Y-%m-%d') if f.created_at else '' }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">No files attached to this project</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% elif tab == 'lists' %}
|
||||||
|
<a href="/lists/create?domain_id={{ item.domain_id }}&project_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New List</a>
|
||||||
|
{% for l in tab_data %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/lists/{{ l.id }}">{{ l.name }}</a></span>
|
||||||
|
<span class="row-meta">{{ l.item_count }} items</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">No lists linked to this project</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% elif tab == 'decisions' %}
|
||||||
|
<a href="/decisions/create?project_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Decision</a>
|
||||||
|
{% for d in tab_data %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/decisions/{{ d.id }}">{{ d.title }}</a></span>
|
||||||
|
<span class="status-badge status-{{ d.status }}">{{ d.status }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">No decisions linked to this project</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% elif tab == 'processes' %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">Process management coming soon</div></div>
|
||||||
|
|
||||||
|
{% elif tab == 'contacts' %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<form action="/projects/{{ item.id }}/contacts/add" method="post" class="flex gap-2 items-end" style="padding: 12px;">
|
||||||
|
<div class="form-group" style="flex:1; margin:0;">
|
||||||
|
<label class="form-label">Contact</label>
|
||||||
|
<select name="contact_id" class="form-select" required>
|
||||||
|
<option value="">Select contact...</option>
|
||||||
|
{% for c in all_contacts %}
|
||||||
|
<option value="{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:1; margin:0;">
|
||||||
|
<label class="form-label">Role</label>
|
||||||
|
<input type="text" name="role" class="form-input" placeholder="e.g. lead, contributor...">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% for c in tab_data %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/contacts/{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</a></span>
|
||||||
|
{% if c.role %}<span class="row-tag">{{ c.role }}</span>{% endif %}
|
||||||
|
<span class="row-meta">{{ c.linked_at.strftime('%Y-%m-%d') if c.linked_at else '' }}</span>
|
||||||
|
<div class="row-actions">
|
||||||
|
<form action="/projects/{{ item.id }}/contacts/{{ c.id }}/remove" method="post" style="display:inline">
|
||||||
|
<button class="btn btn-ghost btn-xs" title="Remove">Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">No contacts linked to this project</div></div>
|
||||||
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -54,6 +54,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div class="tab-strip">
|
||||||
|
<a href="/tasks/{{ item.id }}?tab=overview" class="tab-item {{ 'active' if tab == 'overview' }}">Overview{% if counts.overview %} ({{ counts.overview }}){% endif %}</a>
|
||||||
|
<a href="/tasks/{{ item.id }}?tab=notes" class="tab-item {{ 'active' if tab == 'notes' }}">Notes{% if counts.notes %} ({{ counts.notes }}){% endif %}</a>
|
||||||
|
<a href="/tasks/{{ item.id }}?tab=weblinks" class="tab-item {{ 'active' if tab == 'weblinks' }}">Weblinks{% if counts.weblinks %} ({{ counts.weblinks }}){% endif %}</a>
|
||||||
|
<a href="/tasks/{{ item.id }}?tab=files" class="tab-item {{ 'active' if tab == 'files' }}">Files{% if counts.files %} ({{ counts.files }}){% endif %}</a>
|
||||||
|
<a href="/tasks/{{ item.id }}?tab=lists" class="tab-item {{ 'active' if tab == 'lists' }}">Lists{% if counts.lists %} ({{ counts.lists }}){% endif %}</a>
|
||||||
|
<a href="/tasks/{{ item.id }}?tab=decisions" class="tab-item {{ 'active' if tab == 'decisions' }}">Decisions{% if counts.decisions %} ({{ counts.decisions }}){% endif %}</a>
|
||||||
|
<a href="/tasks/{{ item.id }}?tab=processes" class="tab-item {{ 'active' if tab == 'processes' }}">Processes</a>
|
||||||
|
<a href="/tasks/{{ item.id }}?tab=contacts" class="tab-item {{ 'active' if tab == 'contacts' }}">Contacts{% if counts.contacts %} ({{ counts.contacts }}){% endif %}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if tab == 'overview' %}
|
||||||
{% if parent %}
|
{% if parent %}
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-title text-sm">Parent Task</div>
|
<div class="card-title text-sm">Parent Task</div>
|
||||||
@@ -61,7 +74,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Subtasks -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2 class="card-title">Subtasks<span class="page-count">{{ subtasks|length }}</span></h2>
|
<h2 class="card-title">Subtasks<span class="page-count">{{ subtasks|length }}</span></h2>
|
||||||
@@ -84,6 +96,99 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% elif tab == 'notes' %}
|
||||||
|
<a href="/notes/create?task_id={{ item.id }}&domain_id={{ item.domain_id }}" class="btn btn-ghost btn-sm mb-3">+ New Note</a>
|
||||||
|
{% for n in tab_data %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/notes/{{ n.id }}">{{ n.title }}</a></span>
|
||||||
|
<span class="row-meta">{{ n.updated_at.strftime('%Y-%m-%d') if n.updated_at else '' }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">No notes linked to this task</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% elif tab == 'weblinks' %}
|
||||||
|
<a href="/weblinks/create?task_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Weblink</a>
|
||||||
|
{% for w in tab_data %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="{{ w.url }}" target="_blank">{{ w.label }}</a></span>
|
||||||
|
<span class="row-meta">{{ w.url[:50] }}{% if w.url|length > 50 %}...{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">No weblinks linked to this task</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% elif tab == 'files' %}
|
||||||
|
<a href="/files/upload?context_type=task&context_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ Upload File</a>
|
||||||
|
{% for f in tab_data %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/files/{{ f.id }}">{{ f.original_filename }}</a></span>
|
||||||
|
<span class="row-meta">{{ f.created_at.strftime('%Y-%m-%d') if f.created_at else '' }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">No files attached to this task</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% elif tab == 'lists' %}
|
||||||
|
<a href="/lists/create?task_id={{ item.id }}&domain_id={{ item.domain_id }}" class="btn btn-ghost btn-sm mb-3">+ New List</a>
|
||||||
|
{% for l in tab_data %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/lists/{{ l.id }}">{{ l.name }}</a></span>
|
||||||
|
<span class="row-meta">{{ l.item_count }} items</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">No lists linked to this task</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% elif tab == 'decisions' %}
|
||||||
|
<a href="/decisions/create?task_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Decision</a>
|
||||||
|
{% for d in tab_data %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/decisions/{{ d.id }}">{{ d.title }}</a></span>
|
||||||
|
<span class="status-badge status-{{ d.status }}">{{ d.status }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">No decisions linked to this task</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% elif tab == 'processes' %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">Process management coming soon</div></div>
|
||||||
|
|
||||||
|
{% elif tab == 'contacts' %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<form action="/tasks/{{ item.id }}/contacts/add" method="post" class="flex gap-2 items-end" style="padding: 12px;">
|
||||||
|
<div class="form-group" style="flex:1; margin:0;">
|
||||||
|
<label class="form-label">Contact</label>
|
||||||
|
<select name="contact_id" class="form-select" required>
|
||||||
|
<option value="">Select contact...</option>
|
||||||
|
{% for c in all_contacts %}
|
||||||
|
<option value="{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:1; margin:0;">
|
||||||
|
<label class="form-label">Role</label>
|
||||||
|
<input type="text" name="role" class="form-input" placeholder="e.g. reviewer, assignee...">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% for c in tab_data %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/contacts/{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</a></span>
|
||||||
|
{% if c.role %}<span class="row-tag">{{ c.role }}</span>{% endif %}
|
||||||
|
<span class="row-meta">{{ c.linked_at.strftime('%Y-%m-%d') if c.linked_at else '' }}</span>
|
||||||
|
<div class="row-actions">
|
||||||
|
<form action="/tasks/{{ item.id }}/contacts/{{ c.id }}/remove" method="post" style="display:inline">
|
||||||
|
<button class="btn btn-ghost btn-xs" title="Remove">Remove</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">No contacts linked to this task</div></div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="text-xs text-muted mt-4">
|
<div class="text-xs text-muted mt-4">
|
||||||
Created {{ item.created_at.strftime('%Y-%m-%d %H:%M') if item.created_at else '' }}
|
Created {{ item.created_at.strftime('%Y-%m-%d %H:%M') if item.created_at else '' }}
|
||||||
{% if item.completed_at %} | Completed {{ item.completed_at.strftime('%Y-%m-%d %H:%M') }}{% endif %}
|
{% if item.completed_at %} | Completed {{ item.completed_at.strftime('%Y-%m-%d %H:%M') }}{% endif %}
|
||||||
|
|||||||
@@ -41,9 +41,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if prefill_task_id is defined and prefill_task_id %}<input type="hidden" name="task_id" value="{{ prefill_task_id }}">{% endif %}
|
||||||
|
{% if prefill_meeting_id is defined and prefill_meeting_id %}<input type="hidden" name="meeting_id" value="{{ prefill_meeting_id }}">{% endif %}
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Add Weblink' }}</button>
|
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Add Weblink' }}</button>
|
||||||
<a href="/weblinks" class="btn btn-secondary">Cancel</a>
|
<a href="{{ '/tasks/' ~ prefill_task_id ~ '?tab=weblinks' if prefill_task_id is defined and prefill_task_id else '/weblinks' }}" class="btn btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -201,6 +201,24 @@ def all_seeds(sync_conn):
|
|||||||
ON CONFLICT (id) DO NOTHING
|
ON CONFLICT (id) DO NOTHING
|
||||||
""", (d["focus"], d["task"]))
|
""", (d["focus"], d["task"]))
|
||||||
|
|
||||||
|
# Junction table seeds for contact tabs
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO contact_tasks (contact_id, task_id, role)
|
||||||
|
VALUES (%s, %s, 'assignee') ON CONFLICT DO NOTHING
|
||||||
|
""", (d["contact"], d["task"]))
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO contact_projects (contact_id, project_id, role)
|
||||||
|
VALUES (%s, %s, 'stakeholder') ON CONFLICT DO NOTHING
|
||||||
|
""", (d["contact"], d["project"]))
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO contact_meetings (contact_id, meeting_id, role)
|
||||||
|
VALUES (%s, %s, 'attendee') ON CONFLICT DO NOTHING
|
||||||
|
""", (d["contact"], d["meeting"]))
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO contact_lists (contact_id, list_id, role)
|
||||||
|
VALUES (%s, %s, 'contributor') ON CONFLICT DO NOTHING
|
||||||
|
""", (d["contact"], d["list"]))
|
||||||
|
|
||||||
# Process
|
# Process
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO processes (id, name, process_type, status, category, is_deleted, created_at, updated_at)
|
INSERT INTO processes (id, name, process_type, status, category, is_deleted, created_at, updated_at)
|
||||||
@@ -253,6 +271,11 @@ def all_seeds(sync_conn):
|
|||||||
|
|
||||||
# Cleanup: delete all seed data (reverse dependency order)
|
# Cleanup: delete all seed data (reverse dependency order)
|
||||||
try:
|
try:
|
||||||
|
# Junction tables first
|
||||||
|
cur.execute("DELETE FROM contact_tasks WHERE task_id = %s", (d["task"],))
|
||||||
|
cur.execute("DELETE FROM contact_projects WHERE project_id = %s", (d["project"],))
|
||||||
|
cur.execute("DELETE FROM contact_meetings WHERE meeting_id = %s", (d["meeting"],))
|
||||||
|
cur.execute("DELETE FROM contact_lists WHERE list_id = %s", (d["list"],))
|
||||||
cur.execute("DELETE FROM files WHERE id = %s", (d["file"],))
|
cur.execute("DELETE FROM files WHERE id = %s", (d["file"],))
|
||||||
if os.path.exists(dummy_file_path):
|
if os.path.exists(dummy_file_path):
|
||||||
os.remove(dummy_file_path)
|
os.remove(dummy_file_path)
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ PREFIX_TO_SEED = {
|
|||||||
"/time-budgets": "time_budget",
|
"/time-budgets": "time_budget",
|
||||||
"/files": "file",
|
"/files": "file",
|
||||||
"/admin/trash": None,
|
"/admin/trash": None,
|
||||||
|
"/history": None,
|
||||||
|
"/eisenhower": None,
|
||||||
|
"/calendar": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
def resolve_path(path_template, seeds):
|
def resolve_path(path_template, seeds):
|
||||||
|
|||||||
@@ -1893,3 +1893,743 @@ async def _create_list_item(db: AsyncSession, list_id: str, content: str) -> str
|
|||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return _id
|
return _id
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Task Detail Tabs
|
||||||
|
# ===========================================================================
|
||||||
|
class TestTaskDetailTabs:
|
||||||
|
"""Test tabbed task detail page - all tabs load, correct content per tab."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_overview_tab_default(self, client: AsyncClient, seed_task: dict):
|
||||||
|
"""Default tab is overview."""
|
||||||
|
r = await client.get(f"/tasks/{seed_task['id']}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
# The active tab has class "tab-item active"
|
||||||
|
assert 'tab-item active' in r.text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_tabs_return_200(self, client: AsyncClient, seed_task: dict):
|
||||||
|
"""Every tab on task detail returns 200."""
|
||||||
|
for tab in ("overview", "notes", "weblinks", "files", "lists", "decisions", "processes", "contacts"):
|
||||||
|
r = await client.get(f"/tasks/{seed_task['id']}?tab={tab}")
|
||||||
|
assert r.status_code == 200, f"Tab '{tab}' returned {r.status_code}"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_contacts_tab_shows_seed_contact(
|
||||||
|
self, client: AsyncClient, seed_task: dict, seed_contact: dict,
|
||||||
|
):
|
||||||
|
"""Contacts tab shows the linked contact from seed data."""
|
||||||
|
r = await client.get(f"/tasks/{seed_task['id']}?tab=contacts")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert seed_contact["first_name"] in r.text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_notes_tab_shows_linked_note(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession,
|
||||||
|
seed_task: dict, seed_domain: dict,
|
||||||
|
):
|
||||||
|
"""A note linked to a task appears on the notes tab."""
|
||||||
|
tag = _uid()
|
||||||
|
note_id = str(uuid.uuid4())
|
||||||
|
await db_session.execute(
|
||||||
|
text("INSERT INTO notes (id, title, domain_id, task_id, body, content_format, is_deleted, created_at, updated_at) "
|
||||||
|
"VALUES (:id, :title, :did, :tid, 'body', 'markdown', false, now(), now())"),
|
||||||
|
{"id": note_id, "title": f"TaskNote-{tag}", "did": seed_domain["id"], "tid": seed_task["id"]},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
r = await client.get(f"/tasks/{seed_task['id']}?tab=notes")
|
||||||
|
assert f"TaskNote-{tag}" in r.text
|
||||||
|
|
||||||
|
await db_session.execute(text("DELETE FROM notes WHERE id = :id"), {"id": note_id})
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_tab_defaults_to_overview(self, client: AsyncClient, seed_task: dict):
|
||||||
|
"""Invalid tab param still loads the page (defaults to overview)."""
|
||||||
|
r = await client.get(f"/tasks/{seed_task['id']}?tab=nonexistent")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Task Contact Management
|
||||||
|
# ===========================================================================
|
||||||
|
class TestTaskContactManagement:
|
||||||
|
"""Test adding and removing contacts on task detail."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_contact_to_task(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession,
|
||||||
|
seed_domain: dict, seed_project: dict, seed_contact: dict,
|
||||||
|
):
|
||||||
|
"""Adding a contact to a task creates a junction entry."""
|
||||||
|
tid = await _create_task(db_session, seed_domain["id"], seed_project["id"], f"ContactTask-{_uid()}")
|
||||||
|
|
||||||
|
r = await client.post(f"/tasks/{tid}/contacts/add", data={
|
||||||
|
"contact_id": seed_contact["id"],
|
||||||
|
"role": "reviewer",
|
||||||
|
}, follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT role FROM contact_tasks WHERE task_id = :tid AND contact_id = :cid"),
|
||||||
|
{"tid": tid, "cid": seed_contact["id"]},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None, "Contact-task junction not created"
|
||||||
|
assert row.role == "reviewer"
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
await db_session.execute(text("DELETE FROM contact_tasks WHERE task_id = :tid"), {"tid": tid})
|
||||||
|
await db_session.commit()
|
||||||
|
await _delete_tasks(db_session, [tid])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_contact_from_task(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession,
|
||||||
|
seed_domain: dict, seed_project: dict, seed_contact: dict,
|
||||||
|
):
|
||||||
|
"""Removing a contact from a task deletes the junction entry."""
|
||||||
|
tid = await _create_task(db_session, seed_domain["id"], seed_project["id"], f"RmContact-{_uid()}")
|
||||||
|
await db_session.execute(
|
||||||
|
text("INSERT INTO contact_tasks (contact_id, task_id, role) VALUES (:cid, :tid, 'test') ON CONFLICT DO NOTHING"),
|
||||||
|
{"cid": seed_contact["id"], "tid": tid},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
r = await client.post(f"/tasks/{tid}/contacts/{seed_contact['id']}/remove", follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT count(*) FROM contact_tasks WHERE task_id = :tid AND contact_id = :cid"),
|
||||||
|
{"tid": tid, "cid": seed_contact["id"]},
|
||||||
|
)
|
||||||
|
assert result.scalar() == 0
|
||||||
|
|
||||||
|
await _delete_tasks(db_session, [tid])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_duplicate_contact_no_error(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession,
|
||||||
|
seed_task: dict, seed_contact: dict,
|
||||||
|
):
|
||||||
|
"""Adding same contact twice doesn't crash (ON CONFLICT DO NOTHING)."""
|
||||||
|
r = await client.post(f"/tasks/{seed_task['id']}/contacts/add", data={
|
||||||
|
"contact_id": seed_contact["id"],
|
||||||
|
}, follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
r = await client.post(f"/tasks/{seed_task['id']}/contacts/add", data={
|
||||||
|
"contact_id": seed_contact["id"],
|
||||||
|
}, follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Project Detail Tabs
|
||||||
|
# ===========================================================================
|
||||||
|
class TestProjectDetailTabs:
|
||||||
|
"""Test tabbed project detail page."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_project_tabs_return_200(self, client: AsyncClient, seed_project: dict):
|
||||||
|
"""Every tab on project detail returns 200."""
|
||||||
|
for tab in ("tasks", "notes", "links", "files", "lists", "decisions", "processes", "contacts"):
|
||||||
|
r = await client.get(f"/projects/{seed_project['id']}?tab={tab}")
|
||||||
|
assert r.status_code == 200, f"Project tab '{tab}' returned {r.status_code}"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_project_contacts_tab_shows_seed_contact(
|
||||||
|
self, client: AsyncClient, seed_project: dict, seed_contact: dict,
|
||||||
|
):
|
||||||
|
"""Contacts tab shows the linked contact."""
|
||||||
|
r = await client.get(f"/projects/{seed_project['id']}?tab=contacts")
|
||||||
|
assert seed_contact["first_name"] in r.text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_project_tasks_tab_shows_seed_task(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession,
|
||||||
|
seed_project: dict, seed_task: dict,
|
||||||
|
):
|
||||||
|
"""Tasks tab shows the seed task linked to this project."""
|
||||||
|
# Ensure seed project and task are not soft-deleted (earlier tests may delete them)
|
||||||
|
await db_session.execute(
|
||||||
|
text("UPDATE projects SET is_deleted = false, deleted_at = NULL WHERE id = :id"),
|
||||||
|
{"id": seed_project["id"]},
|
||||||
|
)
|
||||||
|
await db_session.execute(
|
||||||
|
text("UPDATE tasks SET is_deleted = false, deleted_at = NULL WHERE id = :id"),
|
||||||
|
{"id": seed_task["id"]},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
r = await client.get(f"/projects/{seed_project['id']}?tab=tasks")
|
||||||
|
assert seed_task["title"] in r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Project Contact Management
|
||||||
|
# ===========================================================================
|
||||||
|
class TestProjectContactManagement:
|
||||||
|
"""Test adding and removing contacts on project detail."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_contact_to_project(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession,
|
||||||
|
seed_project: dict, seed_contact: dict,
|
||||||
|
):
|
||||||
|
"""Adding a contact to a project creates a junction entry."""
|
||||||
|
# First remove existing seed junction to test fresh add
|
||||||
|
await db_session.execute(
|
||||||
|
text("DELETE FROM contact_projects WHERE project_id = :pid AND contact_id = :cid"),
|
||||||
|
{"pid": seed_project["id"], "cid": seed_contact["id"]},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
r = await client.post(f"/projects/{seed_project['id']}/contacts/add", data={
|
||||||
|
"contact_id": seed_contact["id"],
|
||||||
|
"role": "lead",
|
||||||
|
}, follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT role FROM contact_projects WHERE project_id = :pid AND contact_id = :cid"),
|
||||||
|
{"pid": seed_project["id"], "cid": seed_contact["id"]},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
assert row.role == "lead"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_contact_from_project(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession,
|
||||||
|
seed_project: dict, seed_contact: dict,
|
||||||
|
):
|
||||||
|
"""Removing a contact from a project deletes the junction entry."""
|
||||||
|
# Ensure junction exists
|
||||||
|
await db_session.execute(
|
||||||
|
text("INSERT INTO contact_projects (contact_id, project_id, role) VALUES (:cid, :pid, 'test') ON CONFLICT DO NOTHING"),
|
||||||
|
{"cid": seed_contact["id"], "pid": seed_project["id"]},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
r = await client.post(f"/projects/{seed_project['id']}/contacts/{seed_contact['id']}/remove", follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT count(*) FROM contact_projects WHERE project_id = :pid AND contact_id = :cid"),
|
||||||
|
{"pid": seed_project["id"], "cid": seed_contact["id"]},
|
||||||
|
)
|
||||||
|
assert result.scalar() == 0
|
||||||
|
|
||||||
|
# Re-insert for other tests
|
||||||
|
await db_session.execute(
|
||||||
|
text("INSERT INTO contact_projects (contact_id, project_id, role) VALUES (:cid, :pid, 'stakeholder') ON CONFLICT DO NOTHING"),
|
||||||
|
{"cid": seed_contact["id"], "pid": seed_project["id"]},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Meeting Detail Tabs
|
||||||
|
# ===========================================================================
|
||||||
|
class TestMeetingDetailTabs:
|
||||||
|
"""Test tabbed meeting detail page."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_all_meeting_tabs_return_200(self, client: AsyncClient, seed_meeting: dict):
|
||||||
|
"""Every tab on meeting detail returns 200."""
|
||||||
|
for tab in ("overview", "notes", "weblinks", "files", "lists", "processes", "contacts"):
|
||||||
|
r = await client.get(f"/meetings/{seed_meeting['id']}?tab={tab}")
|
||||||
|
assert r.status_code == 200, f"Meeting tab '{tab}' returned {r.status_code}"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_meeting_contacts_tab_shows_seed_contact(
|
||||||
|
self, client: AsyncClient, seed_meeting: dict, seed_contact: dict,
|
||||||
|
):
|
||||||
|
"""Contacts tab shows the linked contact."""
|
||||||
|
r = await client.get(f"/meetings/{seed_meeting['id']}?tab=contacts")
|
||||||
|
assert seed_contact["first_name"] in r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Meeting Contact Management
|
||||||
|
# ===========================================================================
|
||||||
|
class TestMeetingContactManagement:
|
||||||
|
"""Test adding and removing contacts on meeting detail."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_contact_to_meeting(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession,
|
||||||
|
seed_meeting: dict, seed_contact: dict,
|
||||||
|
):
|
||||||
|
"""Adding a contact to a meeting creates a junction entry."""
|
||||||
|
await db_session.execute(
|
||||||
|
text("DELETE FROM contact_meetings WHERE meeting_id = :mid AND contact_id = :cid"),
|
||||||
|
{"mid": seed_meeting["id"], "cid": seed_contact["id"]},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
r = await client.post(f"/meetings/{seed_meeting['id']}/contacts/add", data={
|
||||||
|
"contact_id": seed_contact["id"],
|
||||||
|
"role": "organizer",
|
||||||
|
}, follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT role FROM contact_meetings WHERE meeting_id = :mid AND contact_id = :cid"),
|
||||||
|
{"mid": seed_meeting["id"], "cid": seed_contact["id"]},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
assert row.role == "organizer"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_contact_from_meeting(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession,
|
||||||
|
seed_meeting: dict, seed_contact: dict,
|
||||||
|
):
|
||||||
|
"""Removing a contact from a meeting deletes the junction entry."""
|
||||||
|
await db_session.execute(
|
||||||
|
text("INSERT INTO contact_meetings (contact_id, meeting_id, role) VALUES (:cid, :mid, 'attendee') ON CONFLICT DO NOTHING"),
|
||||||
|
{"cid": seed_contact["id"], "mid": seed_meeting["id"]},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
r = await client.post(f"/meetings/{seed_meeting['id']}/contacts/{seed_contact['id']}/remove", follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT count(*) FROM contact_meetings WHERE meeting_id = :mid AND contact_id = :cid"),
|
||||||
|
{"mid": seed_meeting["id"], "cid": seed_contact["id"]},
|
||||||
|
)
|
||||||
|
assert result.scalar() == 0
|
||||||
|
|
||||||
|
# Re-insert for other tests
|
||||||
|
await db_session.execute(
|
||||||
|
text("INSERT INTO contact_meetings (contact_id, meeting_id, role) VALUES (:cid, :mid, 'attendee') ON CONFLICT DO NOTHING"),
|
||||||
|
{"cid": seed_contact["id"], "mid": seed_meeting["id"]},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# List Contact Management
|
||||||
|
# ===========================================================================
|
||||||
|
class TestListContactManagement:
|
||||||
|
"""Test adding and removing contacts on list detail."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_add_contact_to_list(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession,
|
||||||
|
seed_list: dict, seed_contact: dict,
|
||||||
|
):
|
||||||
|
"""Adding a contact to a list creates a junction entry."""
|
||||||
|
await db_session.execute(
|
||||||
|
text("DELETE FROM contact_lists WHERE list_id = :lid AND contact_id = :cid"),
|
||||||
|
{"lid": seed_list["id"], "cid": seed_contact["id"]},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
r = await client.post(f"/lists/{seed_list['id']}/contacts/add", data={
|
||||||
|
"contact_id": seed_contact["id"],
|
||||||
|
"role": "owner",
|
||||||
|
}, follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT role FROM contact_lists WHERE list_id = :lid AND contact_id = :cid"),
|
||||||
|
{"lid": seed_list["id"], "cid": seed_contact["id"]},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
assert row.role == "owner"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_contact_from_list(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession,
|
||||||
|
seed_list: dict, seed_contact: dict,
|
||||||
|
):
|
||||||
|
"""Removing a contact from a list deletes the junction entry."""
|
||||||
|
await db_session.execute(
|
||||||
|
text("INSERT INTO contact_lists (contact_id, list_id, role) VALUES (:cid, :lid, 'test') ON CONFLICT DO NOTHING"),
|
||||||
|
{"cid": seed_contact["id"], "lid": seed_list["id"]},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
r = await client.post(f"/lists/{seed_list['id']}/contacts/{seed_contact['id']}/remove", follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT count(*) FROM contact_lists WHERE list_id = :lid AND contact_id = :cid"),
|
||||||
|
{"lid": seed_list["id"], "cid": seed_contact["id"]},
|
||||||
|
)
|
||||||
|
assert result.scalar() == 0
|
||||||
|
|
||||||
|
# Re-insert for other tests
|
||||||
|
await db_session.execute(
|
||||||
|
text("INSERT INTO contact_lists (contact_id, list_id, role) VALUES (:cid, :lid, 'contributor') ON CONFLICT DO NOTHING"),
|
||||||
|
{"cid": seed_contact["id"], "lid": seed_list["id"]},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# History Page
|
||||||
|
# ===========================================================================
|
||||||
|
class TestHistoryPage:
|
||||||
|
"""Test the change history page."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_page_loads(self, client: AsyncClient):
|
||||||
|
"""History page returns 200."""
|
||||||
|
r = await client.get("/history/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "Change History" in r.text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_shows_seed_entities(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession, seed_task: dict,
|
||||||
|
):
|
||||||
|
"""History page shows recently modified seed entities."""
|
||||||
|
# Ensure seed task is not soft-deleted (earlier tests may delete it)
|
||||||
|
await db_session.execute(
|
||||||
|
text("UPDATE tasks SET is_deleted = false, deleted_at = NULL WHERE id = :id"),
|
||||||
|
{"id": seed_task["id"]},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
r = await client.get("/history/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert seed_task["title"] in r.text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_entity_type_filter(self, client: AsyncClient):
|
||||||
|
"""History page can filter by entity type."""
|
||||||
|
for entity_type in ("tasks", "notes", "projects", "contacts"):
|
||||||
|
r = await client.get(f"/history/?entity_type={entity_type}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_history_shows_modified_vs_created(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict,
|
||||||
|
):
|
||||||
|
"""Items with updated_at different from created_at show as 'modified'."""
|
||||||
|
tag = _uid()
|
||||||
|
note_id = str(uuid.uuid4())
|
||||||
|
await db_session.execute(
|
||||||
|
text("INSERT INTO notes (id, title, domain_id, body, content_format, is_deleted, created_at, updated_at) "
|
||||||
|
"VALUES (:id, :title, :did, 'body', 'markdown', false, now() - interval '1 hour', now())"),
|
||||||
|
{"id": note_id, "title": f"HistMod-{tag}", "did": seed_domain["id"]},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
r = await client.get("/history/?entity_type=notes")
|
||||||
|
assert f"HistMod-{tag}" in r.text
|
||||||
|
assert "modified" in r.text
|
||||||
|
|
||||||
|
await db_session.execute(text("DELETE FROM notes WHERE id = :id"), {"id": note_id})
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Search Prefix Matching
|
||||||
|
# ===========================================================================
|
||||||
|
class TestSearchPrefixMatching:
|
||||||
|
"""Test the improved search with prefix tsquery and ILIKE fallback."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_api_short_query_rejected(self, client: AsyncClient):
|
||||||
|
"""Query shorter than 2 chars returns empty results."""
|
||||||
|
r = await client.get("/search/api?q=a")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert len(data["results"]) == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_api_result_structure(self, client: AsyncClient):
|
||||||
|
"""Search results have expected fields."""
|
||||||
|
r = await client.get("/search/api?q=Test")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
if data["results"]:
|
||||||
|
result = data["results"][0]
|
||||||
|
assert "type" in result
|
||||||
|
assert "id" in result
|
||||||
|
assert "name" in result
|
||||||
|
assert "url" in result
|
||||||
|
assert "icon" in result
|
||||||
|
assert "rank" in result
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_page_returns_200(self, client: AsyncClient):
|
||||||
|
"""Full search page works."""
|
||||||
|
r = await client.get("/search/?q=Test")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_search_ilike_fallback(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict,
|
||||||
|
):
|
||||||
|
"""ILIKE fallback finds items that tsvector might miss."""
|
||||||
|
tag = _uid()
|
||||||
|
# Create a note with a very specific title
|
||||||
|
note_id = str(uuid.uuid4())
|
||||||
|
await db_session.execute(
|
||||||
|
text("INSERT INTO notes (id, title, domain_id, body, content_format, is_deleted, created_at, updated_at) "
|
||||||
|
"VALUES (:id, :title, :did, 'body', 'markdown', false, now(), now())"),
|
||||||
|
{"id": note_id, "title": f"XqzNote{tag}", "did": seed_domain["id"]},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
r = await client.get(f"/search/api?q=XqzNote{tag}&entity_type=notes")
|
||||||
|
data = r.json()
|
||||||
|
ids = [res["id"] for res in data["results"]]
|
||||||
|
assert note_id in ids, "ILIKE fallback should find exact substring match"
|
||||||
|
|
||||||
|
await db_session.execute(text("DELETE FROM notes WHERE id = :id"), {"id": note_id})
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Projects API (Dynamic Filtering)
|
||||||
|
# ===========================================================================
|
||||||
|
class TestProjectsAPI:
|
||||||
|
"""Test the /projects/api/by-domain JSON endpoint."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_projects_by_domain_returns_json(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict,
|
||||||
|
):
|
||||||
|
"""API returns JSON list of projects for a domain."""
|
||||||
|
# Ensure seed project is not soft-deleted (earlier tests may delete it)
|
||||||
|
await db_session.execute(
|
||||||
|
text("UPDATE projects SET is_deleted = false, deleted_at = NULL WHERE id = :id AND is_deleted = true"),
|
||||||
|
{"id": "a0000000-0000-0000-0000-000000000003"},
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
r = await client.get(f"/projects/api/by-domain?domain_id={seed_domain['id']}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert isinstance(data, list)
|
||||||
|
assert any(p["name"] == "Test Project" for p in data)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_projects_by_domain_empty(self, client: AsyncClient):
|
||||||
|
"""API returns empty list for nonexistent domain."""
|
||||||
|
fake_id = str(uuid.uuid4())
|
||||||
|
r = await client.get(f"/projects/api/by-domain?domain_id={fake_id}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_projects_by_domain_no_param(self, client: AsyncClient):
|
||||||
|
"""API without domain_id returns all active projects."""
|
||||||
|
r = await client.get("/projects/api/by-domain")
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert isinstance(data, list)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Eisenhower Matrix Filters
|
||||||
|
# ===========================================================================
|
||||||
|
class TestEisenhowerFilters:
|
||||||
|
"""Test Eisenhower matrix page with filter parameters."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_eisenhower_page_loads(self, client: AsyncClient):
|
||||||
|
"""Eisenhower page returns 200."""
|
||||||
|
r = await client.get("/eisenhower/")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_eisenhower_filter_by_domain(
|
||||||
|
self, client: AsyncClient, seed_domain: dict,
|
||||||
|
):
|
||||||
|
"""Eisenhower page accepts domain_id filter."""
|
||||||
|
r = await client.get(f"/eisenhower/?domain_id={seed_domain['id']}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_eisenhower_filter_by_status(self, client: AsyncClient):
|
||||||
|
"""Eisenhower page accepts status filter."""
|
||||||
|
r = await client.get("/eisenhower/?status=open")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_eisenhower_combined_filters(
|
||||||
|
self, client: AsyncClient, seed_domain: dict, seed_project: dict,
|
||||||
|
):
|
||||||
|
"""Eisenhower page accepts multiple filters."""
|
||||||
|
r = await client.get(
|
||||||
|
f"/eisenhower/?domain_id={seed_domain['id']}&project_id={seed_project['id']}&status=open"
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Focus Filters
|
||||||
|
# ===========================================================================
|
||||||
|
class TestFocusFilters:
|
||||||
|
"""Test focus page filter parameters."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_focus_filter_by_domain(
|
||||||
|
self, client: AsyncClient, seed_domain: dict,
|
||||||
|
):
|
||||||
|
"""Focus page accepts domain_id filter for available tasks."""
|
||||||
|
r = await client.get(f"/focus/?domain_id={seed_domain['id']}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_focus_filter_by_project(
|
||||||
|
self, client: AsyncClient, seed_project: dict,
|
||||||
|
):
|
||||||
|
"""Focus page accepts project_id filter."""
|
||||||
|
r = await client.get(f"/focus/?project_id={seed_project['id']}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_focus_combined_filters(
|
||||||
|
self, client: AsyncClient, seed_domain: dict, seed_project: dict,
|
||||||
|
):
|
||||||
|
"""Focus page accepts multiple filters."""
|
||||||
|
r = await client.get(
|
||||||
|
f"/focus/?domain_id={seed_domain['id']}&project_id={seed_project['id']}"
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Entity Create with task_id/meeting_id
|
||||||
|
# ===========================================================================
|
||||||
|
class TestEntityCreateWithParentContext:
|
||||||
|
"""Test creating entities with task_id or meeting_id context."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_note_with_task_id(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession,
|
||||||
|
seed_task: dict, seed_domain: dict,
|
||||||
|
):
|
||||||
|
"""Creating a note with task_id sets the FK and redirects to task tab."""
|
||||||
|
tag = _uid()
|
||||||
|
r = await client.post("/notes/create", data={
|
||||||
|
"title": f"TaskNote-{tag}",
|
||||||
|
"domain_id": seed_domain["id"],
|
||||||
|
"body": "Test body",
|
||||||
|
"content_format": "markdown",
|
||||||
|
"task_id": seed_task["id"],
|
||||||
|
}, follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
# Should redirect back to task's notes tab
|
||||||
|
location = r.headers.get("location", "")
|
||||||
|
assert "tab=notes" in location
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT task_id FROM notes WHERE title = :t AND is_deleted = false"),
|
||||||
|
{"t": f"TaskNote-{tag}"},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
assert str(row.task_id) == seed_task["id"]
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
await db_session.execute(
|
||||||
|
text("DELETE FROM notes WHERE title = :t"), {"t": f"TaskNote-{tag}"}
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_note_form_with_task_prefill(
|
||||||
|
self, client: AsyncClient, seed_task: dict,
|
||||||
|
):
|
||||||
|
"""Note create form with task_id query param loads correctly."""
|
||||||
|
r = await client.get(f"/notes/create?task_id={seed_task['id']}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert seed_task["id"] in r.text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_weblink_with_task_id(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession, seed_task: dict,
|
||||||
|
):
|
||||||
|
"""Creating a weblink with task_id sets the FK."""
|
||||||
|
tag = _uid()
|
||||||
|
r = await client.post("/weblinks/create", data={
|
||||||
|
"label": f"TaskWeblink-{tag}",
|
||||||
|
"url": "https://example.com/test",
|
||||||
|
"task_id": seed_task["id"],
|
||||||
|
}, follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT task_id FROM weblinks WHERE label = :l AND is_deleted = false"),
|
||||||
|
{"l": f"TaskWeblink-{tag}"},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
assert str(row.task_id) == seed_task["id"]
|
||||||
|
|
||||||
|
await db_session.execute(
|
||||||
|
text("DELETE FROM weblinks WHERE label = :l"), {"l": f"TaskWeblink-{tag}"}
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_list_with_task_id(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession,
|
||||||
|
seed_task: dict, seed_domain: dict,
|
||||||
|
):
|
||||||
|
"""Creating a list with task_id sets the FK."""
|
||||||
|
tag = _uid()
|
||||||
|
r = await client.post("/lists/create", data={
|
||||||
|
"name": f"TaskList-{tag}",
|
||||||
|
"domain_id": seed_domain["id"],
|
||||||
|
"list_type": "checklist",
|
||||||
|
"task_id": seed_task["id"],
|
||||||
|
}, follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT task_id FROM lists WHERE name = :n AND is_deleted = false"),
|
||||||
|
{"n": f"TaskList-{tag}"},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
assert str(row.task_id) == seed_task["id"]
|
||||||
|
|
||||||
|
await db_session.execute(
|
||||||
|
text("DELETE FROM lists WHERE name = :n"), {"n": f"TaskList-{tag}"}
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_decision_with_task_id(
|
||||||
|
self, client: AsyncClient, db_session: AsyncSession, seed_task: dict,
|
||||||
|
):
|
||||||
|
"""Creating a decision with task_id sets the FK."""
|
||||||
|
tag = _uid()
|
||||||
|
r = await client.post("/decisions/create", data={
|
||||||
|
"title": f"TaskDecision-{tag}",
|
||||||
|
"status": "proposed",
|
||||||
|
"impact": "medium",
|
||||||
|
"task_id": seed_task["id"],
|
||||||
|
}, follow_redirects=False)
|
||||||
|
assert r.status_code == 303
|
||||||
|
|
||||||
|
result = await db_session.execute(
|
||||||
|
text("SELECT task_id FROM decisions WHERE title = :t AND is_deleted = false"),
|
||||||
|
{"t": f"TaskDecision-{tag}"},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
assert row is not None
|
||||||
|
assert str(row.task_id) == seed_task["id"]
|
||||||
|
|
||||||
|
await db_session.execute(
|
||||||
|
text("DELETE FROM decisions WHERE title = :t"), {"t": f"TaskDecision-{tag}"}
|
||||||
|
)
|
||||||
|
await db_session.commit()
|
||||||
|
|||||||
Reference in New Issue
Block a user