Compare commits
15 Commits
dbd40485ba
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c42fe9dc13 | |||
| 34b232de5f | |||
| 497436a0a3 | |||
| 75b055299a | |||
| 41b974c804 | |||
| ff9be1249a | |||
| c8a1d5ba40 | |||
| 0ed86ee2dc | |||
| cf84d6d2dd | |||
| 9dedf6dbf2 | |||
| bcb0007217 | |||
| 8499c99721 | |||
| d792f89fe6 | |||
| c21cbf5e9b | |||
| 21bbb169f9 |
@@ -158,6 +158,10 @@ class BaseRepository:
|
|||||||
"rationale", "decided_at", "superseded_by_id",
|
"rationale", "decided_at", "superseded_by_id",
|
||||||
"start_at", "end_at", "location", "agenda", "transcript", "notes_body",
|
"start_at", "end_at", "location", "agenda", "transcript", "notes_body",
|
||||||
"priority", "recurrence", "mime_type",
|
"priority", "recurrence", "mime_type",
|
||||||
|
"category", "instructions", "expected_output", "estimated_days",
|
||||||
|
"contact_id", "started_at",
|
||||||
|
"weekly_hours", "effective_from",
|
||||||
|
"task_id", "meeting_id",
|
||||||
}
|
}
|
||||||
clean_data = {}
|
clean_data = {}
|
||||||
for k, v in data.items():
|
for k, v in data.items():
|
||||||
@@ -244,3 +248,72 @@ class BaseRepository:
|
|||||||
text(f"UPDATE {self.table} SET sort_order = :order WHERE id = :id"),
|
text(f"UPDATE {self.table} SET sort_order = :order WHERE id = :id"),
|
||||||
{"order": (i + 1) * 10, "id": str(id)}
|
{"order": (i + 1) * 10, "id": str(id)}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def swap_sort_order(self, id_a: str, id_b: str) -> None:
|
||||||
|
"""Swap sort_order between two rows."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
text(f"SELECT id, sort_order FROM {self.table} WHERE id IN (:a, :b)"),
|
||||||
|
{"a": str(id_a), "b": str(id_b)},
|
||||||
|
)
|
||||||
|
rows = {str(r._mapping["id"]): r._mapping["sort_order"] for r in result}
|
||||||
|
if len(rows) != 2:
|
||||||
|
return
|
||||||
|
await self.db.execute(
|
||||||
|
text(f"UPDATE {self.table} SET sort_order = :order WHERE id = :id"),
|
||||||
|
{"order": rows[str(id_b)], "id": str(id_a)},
|
||||||
|
)
|
||||||
|
await self.db.execute(
|
||||||
|
text(f"UPDATE {self.table} SET sort_order = :order WHERE id = :id"),
|
||||||
|
{"order": rows[str(id_a)], "id": str(id_b)},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def move_in_order(self, item_id: str, direction: str, filters: dict | None = None) -> None:
|
||||||
|
"""Move an item up or down within its sort group.
|
||||||
|
|
||||||
|
Handles lazy initialization (all sort_order=0) and swaps with neighbor.
|
||||||
|
filters: optional dict to scope the group (e.g. {"list_id": some_id}).
|
||||||
|
"""
|
||||||
|
where_clauses = ["is_deleted = false"]
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if filters:
|
||||||
|
for i, (key, value) in enumerate(filters.items()):
|
||||||
|
if value is None:
|
||||||
|
where_clauses.append(f"{key} IS NULL")
|
||||||
|
else:
|
||||||
|
param_name = f"mf_{i}"
|
||||||
|
where_clauses.append(f"{key} = :{param_name}")
|
||||||
|
params[param_name] = value
|
||||||
|
|
||||||
|
where_sql = " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
result = await self.db.execute(
|
||||||
|
text(f"SELECT id, sort_order FROM {self.table} WHERE {where_sql} ORDER BY sort_order, created_at"),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
items = [dict(r._mapping) for r in result]
|
||||||
|
if len(items) < 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Lazy init: if all sort_order are 0, assign incremental values
|
||||||
|
if all(r["sort_order"] == 0 for r in items):
|
||||||
|
for i, r in enumerate(items):
|
||||||
|
await self.db.execute(
|
||||||
|
text(f"UPDATE {self.table} SET sort_order = :order WHERE id = :id"),
|
||||||
|
{"order": (i + 1) * 10, "id": str(r["id"])},
|
||||||
|
)
|
||||||
|
# Re-fetch
|
||||||
|
result = await self.db.execute(
|
||||||
|
text(f"SELECT id, sort_order FROM {self.table} WHERE {where_sql} ORDER BY sort_order, created_at"),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
items = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
ids = [str(r["id"]) for r in items]
|
||||||
|
if item_id not in ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
idx = ids.index(item_id)
|
||||||
|
if direction == "up" and idx > 0:
|
||||||
|
await self.swap_sort_order(ids[idx], ids[idx - 1])
|
||||||
|
elif direction == "down" and idx < len(ids) - 1:
|
||||||
|
await self.swap_sort_order(ids[idx], ids[idx + 1])
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_prod
|
DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_prod
|
||||||
FILE_STORAGE_PATH: /opt/lifeos/files/prod
|
FILE_STORAGE_PATH: /opt/lifeos/webdav
|
||||||
ENVIRONMENT: production
|
ENVIRONMENT: production
|
||||||
command: uvicorn main:app --host 0.0.0.0 --port 8002 --workers 1
|
command: uvicorn main:app --host 0.0.0.0 --port 8002 --workers 1
|
||||||
ports:
|
ports:
|
||||||
- "8002:8002"
|
- "8002:8002"
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/lifeos/prod/files:/opt/lifeos/files/prod
|
- /opt/lifeos/webdav:/opt/lifeos/webdav
|
||||||
networks:
|
networks:
|
||||||
- lifeos_network
|
- lifeos_network
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -29,13 +29,13 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_dev
|
DATABASE_URL: postgresql+asyncpg://postgres:${DB_PASSWORD}@lifeos-db:5432/lifeos_dev
|
||||||
FILE_STORAGE_PATH: /opt/lifeos/files/dev
|
FILE_STORAGE_PATH: /opt/lifeos/webdav
|
||||||
ENVIRONMENT: development
|
ENVIRONMENT: development
|
||||||
command: uvicorn main:app --host 0.0.0.0 --port 8003 --workers 1 --reload
|
command: uvicorn main:app --host 0.0.0.0 --port 8003 --workers 1 --reload
|
||||||
ports:
|
ports:
|
||||||
- "8003:8003"
|
- "8003:8003"
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/lifeos/dev/files:/opt/lifeos/files/dev
|
- /opt/lifeos/webdav:/opt/lifeos/webdav
|
||||||
- .:/app # hot reload in dev
|
- .:/app # hot reload in dev
|
||||||
networks:
|
networks:
|
||||||
- lifeos_network
|
- lifeos_network
|
||||||
|
|||||||
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.
|
||||||
46
main.py
46
main.py
@@ -39,6 +39,11 @@ from routers import (
|
|||||||
weblinks as weblinks_router,
|
weblinks as weblinks_router,
|
||||||
appointments as appointments_router,
|
appointments as appointments_router,
|
||||||
time_tracking as time_tracking_router,
|
time_tracking as time_tracking_router,
|
||||||
|
processes as processes_router,
|
||||||
|
calendar as calendar_router,
|
||||||
|
time_budgets as time_budgets_router,
|
||||||
|
eisenhower as eisenhower_router,
|
||||||
|
history as history_router,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -150,12 +155,48 @@ async def dashboard(request: Request):
|
|||||||
"""))
|
"""))
|
||||||
stats = dict(result.first()._mapping)
|
stats = dict(result.first()._mapping)
|
||||||
|
|
||||||
|
# Overdue projects (target_date in the past)
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT p.id, p.name, p.priority, p.target_date, p.status,
|
||||||
|
d.name as domain_name, d.color as domain_color,
|
||||||
|
count(t.id) FILTER (WHERE t.is_deleted = false) as task_count,
|
||||||
|
count(t.id) FILTER (WHERE t.is_deleted = false AND t.status = 'done') as done_count
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN domains d ON p.domain_id = d.id
|
||||||
|
LEFT JOIN tasks t ON t.project_id = p.id
|
||||||
|
WHERE p.is_deleted = false AND p.status IN ('active', 'on_hold')
|
||||||
|
AND p.target_date < CURRENT_DATE
|
||||||
|
GROUP BY p.id, d.name, d.color
|
||||||
|
ORDER BY p.target_date ASC
|
||||||
|
LIMIT 10
|
||||||
|
"""))
|
||||||
|
overdue_projects = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# Upcoming project deadlines (next 30 days)
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT p.id, p.name, p.priority, p.target_date, p.status,
|
||||||
|
d.name as domain_name, d.color as domain_color,
|
||||||
|
count(t.id) FILTER (WHERE t.is_deleted = false) as task_count,
|
||||||
|
count(t.id) FILTER (WHERE t.is_deleted = false AND t.status = 'done') as done_count
|
||||||
|
FROM projects p
|
||||||
|
LEFT JOIN domains d ON p.domain_id = d.id
|
||||||
|
LEFT JOIN tasks t ON t.project_id = p.id
|
||||||
|
WHERE p.is_deleted = false AND p.status IN ('active', 'on_hold')
|
||||||
|
AND p.target_date >= CURRENT_DATE AND p.target_date <= CURRENT_DATE + INTERVAL '30 days'
|
||||||
|
GROUP BY p.id, d.name, d.color
|
||||||
|
ORDER BY p.target_date ASC
|
||||||
|
LIMIT 10
|
||||||
|
"""))
|
||||||
|
upcoming_projects = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
return templates.TemplateResponse("dashboard.html", {
|
return templates.TemplateResponse("dashboard.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"sidebar": sidebar,
|
"sidebar": sidebar,
|
||||||
"focus_items": focus_items,
|
"focus_items": focus_items,
|
||||||
"overdue_tasks": overdue_tasks,
|
"overdue_tasks": overdue_tasks,
|
||||||
"upcoming_tasks": upcoming_tasks,
|
"upcoming_tasks": upcoming_tasks,
|
||||||
|
"overdue_projects": overdue_projects,
|
||||||
|
"upcoming_projects": upcoming_projects,
|
||||||
"stats": stats,
|
"stats": stats,
|
||||||
"page_title": "Dashboard",
|
"page_title": "Dashboard",
|
||||||
"active_nav": "dashboard",
|
"active_nav": "dashboard",
|
||||||
@@ -193,3 +234,8 @@ app.include_router(decisions_router.router)
|
|||||||
app.include_router(weblinks_router.router)
|
app.include_router(weblinks_router.router)
|
||||||
app.include_router(appointments_router.router)
|
app.include_router(appointments_router.router)
|
||||||
app.include_router(time_tracking_router.router)
|
app.include_router(time_tracking_router.router)
|
||||||
|
app.include_router(processes_router.router)
|
||||||
|
app.include_router(calendar_router.router)
|
||||||
|
app.include_router(time_budgets_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
@@ -27,11 +27,13 @@ TRASH_ENTITIES = [
|
|||||||
{"table": "meetings", "label": "Meetings", "name_col": "title", "url": "/meetings/{id}"},
|
{"table": "meetings", "label": "Meetings", "name_col": "title", "url": "/meetings/{id}"},
|
||||||
{"table": "decisions", "label": "Decisions", "name_col": "title", "url": "/decisions/{id}"},
|
{"table": "decisions", "label": "Decisions", "name_col": "title", "url": "/decisions/{id}"},
|
||||||
{"table": "files", "label": "Files", "name_col": "original_filename", "url": "/files"},
|
{"table": "files", "label": "Files", "name_col": "original_filename", "url": "/files"},
|
||||||
{"table": "weblinks", "label": "Weblinks", "name_col": "label", "url": "/weblinks"},
|
{"table": "link_folders", "label": "Link Folders", "name_col": "name", "url": "/weblinks"},
|
||||||
{"table": "weblink_folders", "label": "Weblink Folders", "name_col": "name", "url": "/weblinks"},
|
|
||||||
{"table": "appointments", "label": "Appointments", "name_col": "title", "url": "/appointments/{id}"},
|
{"table": "appointments", "label": "Appointments", "name_col": "title", "url": "/appointments/{id}"},
|
||||||
{"table": "capture", "label": "Capture", "name_col": "raw_text", "url": "/capture"},
|
{"table": "capture", "label": "Capture", "name_col": "raw_text", "url": "/capture"},
|
||||||
{"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"},
|
{"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"},
|
||||||
|
{"table": "processes", "label": "Processes", "name_col": "name", "url": "/processes/{id}"},
|
||||||
|
{"table": "process_runs", "label": "Process Runs", "name_col": "title", "url": "/processes/runs/{id}"},
|
||||||
|
{"table": "time_budgets", "label": "Time Budgets", "name_col": "id", "url": "/time-budgets"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -300,3 +300,15 @@ async def delete_appointment(
|
|||||||
repo = BaseRepository("appointments", db)
|
repo = BaseRepository("appointments", db)
|
||||||
await repo.soft_delete(appointment_id)
|
await repo.soft_delete(appointment_id)
|
||||||
return RedirectResponse(url="/appointments", status_code=303)
|
return RedirectResponse(url="/appointments", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_appointment(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("appointments", db)
|
||||||
|
await repo.move_in_order(item_id, direction)
|
||||||
|
return RedirectResponse(url=request.headers.get("referer", "/appointments"), status_code=303)
|
||||||
|
|||||||
138
routers/calendar.py
Normal file
138
routers/calendar.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""Calendar: unified read-only month view of appointments, meetings, and tasks."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import text
|
||||||
|
from datetime import date, timedelta
|
||||||
|
import calendar
|
||||||
|
|
||||||
|
from core.database import get_db
|
||||||
|
from core.sidebar import get_sidebar_data
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/calendar", tags=["calendar"])
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def calendar_view(
|
||||||
|
request: Request,
|
||||||
|
year: int = None,
|
||||||
|
month: int = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
today = date.today()
|
||||||
|
year = year or today.year
|
||||||
|
month = month or today.month
|
||||||
|
|
||||||
|
# Clamp to valid range
|
||||||
|
if month < 1 or month > 12:
|
||||||
|
month = today.month
|
||||||
|
if year < 2000 or year > 2100:
|
||||||
|
year = today.year
|
||||||
|
|
||||||
|
first_day = date(year, month, 1)
|
||||||
|
last_day = date(year, month, calendar.monthrange(year, month)[1])
|
||||||
|
|
||||||
|
# Prev/next month
|
||||||
|
prev_month = first_day - timedelta(days=1)
|
||||||
|
next_month = last_day + timedelta(days=1)
|
||||||
|
|
||||||
|
sidebar = await get_sidebar_data(db)
|
||||||
|
|
||||||
|
# Appointments in this month (by start_at)
|
||||||
|
appt_result = await db.execute(text("""
|
||||||
|
SELECT id, title, start_at, end_at, all_day, location
|
||||||
|
FROM appointments
|
||||||
|
WHERE is_deleted = false
|
||||||
|
AND start_at::date >= :first AND start_at::date <= :last
|
||||||
|
ORDER BY start_at
|
||||||
|
"""), {"first": first_day, "last": last_day})
|
||||||
|
appointments = [dict(r._mapping) for r in appt_result]
|
||||||
|
|
||||||
|
# Meetings in this month (by meeting_date)
|
||||||
|
meet_result = await db.execute(text("""
|
||||||
|
SELECT id, title, meeting_date, start_at, location, status
|
||||||
|
FROM meetings
|
||||||
|
WHERE is_deleted = false
|
||||||
|
AND meeting_date >= :first AND meeting_date <= :last
|
||||||
|
ORDER BY meeting_date, start_at
|
||||||
|
"""), {"first": first_day, "last": last_day})
|
||||||
|
meetings = [dict(r._mapping) for r in meet_result]
|
||||||
|
|
||||||
|
# Tasks with due dates in this month (open/in_progress only)
|
||||||
|
task_result = await db.execute(text("""
|
||||||
|
SELECT t.id, t.title, t.due_date, t.priority, t.status,
|
||||||
|
p.name as project_name
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN projects p ON t.project_id = p.id
|
||||||
|
WHERE t.is_deleted = false
|
||||||
|
AND t.status IN ('open', 'in_progress')
|
||||||
|
AND t.due_date >= :first AND t.due_date <= :last
|
||||||
|
ORDER BY t.due_date, t.priority
|
||||||
|
"""), {"first": first_day, "last": last_day})
|
||||||
|
tasks = [dict(r._mapping) for r in task_result]
|
||||||
|
|
||||||
|
# Build day-indexed event map
|
||||||
|
days_map = {}
|
||||||
|
for a in appointments:
|
||||||
|
day = a["start_at"].date().day if a["start_at"] else None
|
||||||
|
if day:
|
||||||
|
days_map.setdefault(day, []).append({
|
||||||
|
"type": "appointment",
|
||||||
|
"id": a["id"],
|
||||||
|
"title": a["title"],
|
||||||
|
"time": None if a["all_day"] else a["start_at"].strftime("%-I:%M %p"),
|
||||||
|
"url": f"/appointments/{a['id']}",
|
||||||
|
"all_day": a["all_day"],
|
||||||
|
})
|
||||||
|
|
||||||
|
for m in meetings:
|
||||||
|
day = m["meeting_date"].day if m["meeting_date"] else None
|
||||||
|
if day:
|
||||||
|
time_str = m["start_at"].strftime("%-I:%M %p") if m["start_at"] else None
|
||||||
|
days_map.setdefault(day, []).append({
|
||||||
|
"type": "meeting",
|
||||||
|
"id": m["id"],
|
||||||
|
"title": m["title"],
|
||||||
|
"time": time_str,
|
||||||
|
"url": f"/meetings/{m['id']}",
|
||||||
|
"all_day": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
for t in tasks:
|
||||||
|
day = t["due_date"].day if t["due_date"] else None
|
||||||
|
if day:
|
||||||
|
days_map.setdefault(day, []).append({
|
||||||
|
"type": "task",
|
||||||
|
"id": t["id"],
|
||||||
|
"title": t["title"],
|
||||||
|
"time": None,
|
||||||
|
"url": f"/tasks/{t['id']}",
|
||||||
|
"priority": t["priority"],
|
||||||
|
"project_name": t.get("project_name"),
|
||||||
|
"all_day": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build calendar grid (weeks of days)
|
||||||
|
# Monday=0, Sunday=6
|
||||||
|
cal = calendar.Calendar(firstweekday=6) # Sunday start
|
||||||
|
weeks = cal.monthdayscalendar(year, month)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("calendar.html", {
|
||||||
|
"request": request,
|
||||||
|
"sidebar": sidebar,
|
||||||
|
"year": year,
|
||||||
|
"month": month,
|
||||||
|
"month_name": calendar.month_name[month],
|
||||||
|
"weeks": weeks,
|
||||||
|
"days_map": days_map,
|
||||||
|
"today": today,
|
||||||
|
"first_day": first_day,
|
||||||
|
"prev_year": prev_month.year,
|
||||||
|
"prev_month": prev_month.month,
|
||||||
|
"next_year": next_month.year,
|
||||||
|
"next_month": next_month.month,
|
||||||
|
"page_title": f"Calendar - {calendar.month_name[month]} {year}",
|
||||||
|
"active_nav": "calendar",
|
||||||
|
})
|
||||||
@@ -13,6 +13,7 @@ from sqlalchemy import text
|
|||||||
from core.database import get_db
|
from core.database import get_db
|
||||||
from core.base_repository import BaseRepository
|
from core.base_repository import BaseRepository
|
||||||
from core.sidebar import get_sidebar_data
|
from core.sidebar import get_sidebar_data
|
||||||
|
from routers.weblinks import get_default_folder_id
|
||||||
|
|
||||||
router = APIRouter(prefix="/capture", tags=["capture"])
|
router = APIRouter(prefix="/capture", tags=["capture"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
@@ -24,7 +25,7 @@ CONVERT_TYPES = {
|
|||||||
"list_item": "List Item",
|
"list_item": "List Item",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"decision": "Decision",
|
"decision": "Decision",
|
||||||
"weblink": "Weblink",
|
"link": "Link",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ async def list_capture(request: Request, show: str = "inbox", db: AsyncSession =
|
|||||||
async def add_capture(
|
async def add_capture(
|
||||||
request: Request,
|
request: Request,
|
||||||
raw_text: str = Form(...),
|
raw_text: str = Form(...),
|
||||||
|
redirect_to: Optional[str] = Form(None),
|
||||||
area_id: Optional[str] = Form(None),
|
area_id: Optional[str] = Form(None),
|
||||||
project_id: Optional[str] = Form(None),
|
project_id: Optional[str] = Form(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -95,7 +97,8 @@ async def add_capture(
|
|||||||
data["project_id"] = project_id
|
data["project_id"] = project_id
|
||||||
await repo.create(data)
|
await repo.create(data)
|
||||||
|
|
||||||
return RedirectResponse(url="/capture", status_code=303)
|
url = redirect_to if redirect_to and redirect_to.startswith("/") else "/capture"
|
||||||
|
return RedirectResponse(url=url, status_code=303)
|
||||||
|
|
||||||
|
|
||||||
# ---- Batch undo (must be before /{capture_id} routes) ----
|
# ---- Batch undo (must be before /{capture_id} routes) ----
|
||||||
@@ -307,8 +310,8 @@ async def convert_to_decision(
|
|||||||
return RedirectResponse(url=f"/decisions/{decision['id']}", status_code=303)
|
return RedirectResponse(url=f"/decisions/{decision['id']}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{capture_id}/to-weblink")
|
@router.post("/{capture_id}/to-link")
|
||||||
async def convert_to_weblink(
|
async def convert_to_link(
|
||||||
capture_id: str, request: Request,
|
capture_id: str, request: Request,
|
||||||
label: Optional[str] = Form(None),
|
label: Optional[str] = Form(None),
|
||||||
url: Optional[str] = Form(None),
|
url: Optional[str] = Form(None),
|
||||||
@@ -319,7 +322,7 @@ async def convert_to_weblink(
|
|||||||
if not item:
|
if not item:
|
||||||
return RedirectResponse(url="/capture", status_code=303)
|
return RedirectResponse(url="/capture", status_code=303)
|
||||||
|
|
||||||
weblink_repo = BaseRepository("weblinks", db)
|
link_repo = BaseRepository("links", db)
|
||||||
raw = item["raw_text"]
|
raw = item["raw_text"]
|
||||||
url_match = re.search(r'https?://\S+', raw)
|
url_match = re.search(r'https?://\S+', raw)
|
||||||
link_url = (url.strip() if url and url.strip() else None) or (url_match.group(0) if url_match else raw)
|
link_url = (url.strip() if url and url.strip() else None) or (url_match.group(0) if url_match else raw)
|
||||||
@@ -328,13 +331,20 @@ async def convert_to_weblink(
|
|||||||
link_label = link_url
|
link_label = link_url
|
||||||
|
|
||||||
data = {"label": link_label, "url": link_url}
|
data = {"label": link_label, "url": link_url}
|
||||||
weblink = await weblink_repo.create(data)
|
link = await link_repo.create(data)
|
||||||
|
|
||||||
|
# Assign to Default folder
|
||||||
|
default_fid = await get_default_folder_id(db)
|
||||||
|
await db.execute(text("""
|
||||||
|
INSERT INTO folder_links (folder_id, link_id)
|
||||||
|
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
|
||||||
|
"""), {"fid": default_fid, "lid": link["id"]})
|
||||||
|
|
||||||
await capture_repo.update(capture_id, {
|
await capture_repo.update(capture_id, {
|
||||||
"processed": True, "converted_to_type": "weblink",
|
"processed": True, "converted_to_type": "link",
|
||||||
"converted_to_id": str(weblink["id"]),
|
"converted_to_id": str(link["id"]),
|
||||||
})
|
})
|
||||||
return RedirectResponse(url="/weblinks", status_code=303)
|
return RedirectResponse(url="/links", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{capture_id}/dismiss")
|
@router.post("/{capture_id}/dismiss")
|
||||||
|
|||||||
@@ -124,3 +124,15 @@ async def delete_contact(contact_id: str, db: AsyncSession = Depends(get_db)):
|
|||||||
repo = BaseRepository("contacts", db)
|
repo = BaseRepository("contacts", db)
|
||||||
await repo.soft_delete(contact_id)
|
await repo.soft_delete(contact_id)
|
||||||
return RedirectResponse(url="/contacts", status_code=303)
|
return RedirectResponse(url="/contacts", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_contact(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("contacts", db)
|
||||||
|
await repo.move_in_order(item_id, direction)
|
||||||
|
return RedirectResponse(url=request.headers.get("referer", "/contacts"), status_code=303)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
@@ -209,3 +216,15 @@ async def delete_decision(decision_id: str, request: Request, db: AsyncSession =
|
|||||||
repo = BaseRepository("decisions", db)
|
repo = BaseRepository("decisions", db)
|
||||||
await repo.soft_delete(decision_id)
|
await repo.soft_delete(decision_id)
|
||||||
return RedirectResponse(url="/decisions", status_code=303)
|
return RedirectResponse(url="/decisions", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_decision(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("decisions", db)
|
||||||
|
await repo.move_in_order(item_id, direction)
|
||||||
|
return RedirectResponse(url=request.headers.get("referer", "/decisions"), status_code=303)
|
||||||
|
|||||||
120
routers/eisenhower.py
Normal file
120
routers/eisenhower.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""Eisenhower Matrix: read-only 2x2 priority/urgency grid of open tasks."""
|
||||||
|
|
||||||
|
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 core.database import get_db
|
||||||
|
from core.base_repository import BaseRepository
|
||||||
|
from core.sidebar import get_sidebar_data
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/eisenhower", tags=["eisenhower"])
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def eisenhower_matrix(
|
||||||
|
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),
|
||||||
|
):
|
||||||
|
sidebar = await get_sidebar_data(db)
|
||||||
|
|
||||||
|
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,
|
||||||
|
t.context, t.estimated_minutes,
|
||||||
|
p.name as project_name,
|
||||||
|
d.name as domain_name, d.color as domain_color
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN projects p ON t.project_id = p.id
|
||||||
|
LEFT JOIN domains d ON t.domain_id = d.id
|
||||||
|
WHERE {where_sql}
|
||||||
|
ORDER BY t.priority, t.due_date NULLS LAST, t.title
|
||||||
|
"""), params)
|
||||||
|
tasks = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# Classify into quadrants
|
||||||
|
from datetime import date, timedelta
|
||||||
|
today = date.today()
|
||||||
|
urgent_cutoff = today + timedelta(days=7)
|
||||||
|
|
||||||
|
quadrants = {
|
||||||
|
"do_first": [],
|
||||||
|
"schedule": [],
|
||||||
|
"delegate": [],
|
||||||
|
"eliminate": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for t in tasks:
|
||||||
|
important = t["priority"] in (1, 2)
|
||||||
|
urgent = (
|
||||||
|
t["due_date"] is not None
|
||||||
|
and t["due_date"] <= urgent_cutoff
|
||||||
|
)
|
||||||
|
|
||||||
|
if important and urgent:
|
||||||
|
quadrants["do_first"].append(t)
|
||||||
|
elif important and not urgent:
|
||||||
|
quadrants["schedule"].append(t)
|
||||||
|
elif not important and urgent:
|
||||||
|
quadrants["delegate"].append(t)
|
||||||
|
else:
|
||||||
|
quadrants["eliminate"].append(t)
|
||||||
|
|
||||||
|
counts = {k: len(v) for k, v in quadrants.items()}
|
||||||
|
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", {
|
||||||
|
"request": request,
|
||||||
|
"sidebar": sidebar,
|
||||||
|
"quadrants": quadrants,
|
||||||
|
"counts": counts,
|
||||||
|
"total": total,
|
||||||
|
"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",
|
||||||
|
"active_nav": "eisenhower",
|
||||||
|
})
|
||||||
339
routers/files.py
339
routers/files.py
@@ -1,12 +1,11 @@
|
|||||||
"""Files: upload, download, list, preview, and polymorphic entity attachment."""
|
"""Files: upload, download, list, preview, folder-aware storage, and WebDAV sync."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import uuid
|
import mimetypes
|
||||||
import shutil
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from fastapi import APIRouter, Request, Form, Depends, UploadFile, File as FastAPIFile
|
from fastapi import APIRouter, Request, Form, Depends, UploadFile, File as FastAPIFile
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import RedirectResponse, FileResponse
|
from fastapi.responses import RedirectResponse, FileResponse, HTMLResponse
|
||||||
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
|
||||||
@@ -18,7 +17,7 @@ from core.sidebar import get_sidebar_data
|
|||||||
router = APIRouter(prefix="/files", tags=["files"])
|
router = APIRouter(prefix="/files", tags=["files"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
FILE_STORAGE_PATH = os.getenv("FILE_STORAGE_PATH", "/opt/lifeos/files/dev")
|
FILE_STORAGE_PATH = os.getenv("FILE_STORAGE_PATH", "/opt/lifeos/webdav")
|
||||||
|
|
||||||
# Ensure storage dir exists
|
# Ensure storage dir exists
|
||||||
Path(FILE_STORAGE_PATH).mkdir(parents=True, exist_ok=True)
|
Path(FILE_STORAGE_PATH).mkdir(parents=True, exist_ok=True)
|
||||||
@@ -29,38 +28,261 @@ PREVIEWABLE = {
|
|||||||
"application/pdf", "text/plain", "text/html", "text/csv",
|
"application/pdf", "text/plain", "text/html", "text/csv",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Files to skip during sync
|
||||||
|
SKIP_FILES = {".DS_Store", "Thumbs.db", ".gitkeep", "desktop.ini"}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_path(item):
|
||||||
|
"""Resolve a DB record's relative storage_path to an absolute path."""
|
||||||
|
return os.path.join(FILE_STORAGE_PATH, item["storage_path"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_folders():
|
||||||
|
"""Walk FILE_STORAGE_PATH and return sorted list of relative folder paths."""
|
||||||
|
folders = []
|
||||||
|
for dirpath, dirnames, _filenames in os.walk(FILE_STORAGE_PATH):
|
||||||
|
# Skip hidden directories
|
||||||
|
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
||||||
|
rel = os.path.relpath(dirpath, FILE_STORAGE_PATH)
|
||||||
|
if rel != ".":
|
||||||
|
folders.append(rel)
|
||||||
|
return sorted(folders)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_collision(folder_abs, filename):
|
||||||
|
"""If filename exists in folder_abs, return name (2).ext, name (3).ext, etc."""
|
||||||
|
target = os.path.join(folder_abs, filename)
|
||||||
|
if not os.path.exists(target):
|
||||||
|
return filename
|
||||||
|
|
||||||
|
name, ext = os.path.splitext(filename)
|
||||||
|
counter = 2
|
||||||
|
while True:
|
||||||
|
candidate = f"{name} ({counter}){ext}"
|
||||||
|
if not os.path.exists(os.path.join(folder_abs, candidate)):
|
||||||
|
return candidate
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_files(db: AsyncSession):
|
||||||
|
"""Sync filesystem state with the database.
|
||||||
|
|
||||||
|
- Files on disk not in DB → create record
|
||||||
|
- Active DB records with missing files → soft-delete
|
||||||
|
Returns dict with added/removed counts.
|
||||||
|
"""
|
||||||
|
added = 0
|
||||||
|
removed = 0
|
||||||
|
|
||||||
|
# Build set of all relative file paths on disk
|
||||||
|
disk_files = set()
|
||||||
|
for dirpath, dirnames, filenames in os.walk(FILE_STORAGE_PATH):
|
||||||
|
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
||||||
|
for fname in filenames:
|
||||||
|
if fname in SKIP_FILES or fname.startswith("."):
|
||||||
|
continue
|
||||||
|
abs_path = os.path.join(dirpath, fname)
|
||||||
|
rel_path = os.path.relpath(abs_path, FILE_STORAGE_PATH)
|
||||||
|
disk_files.add(rel_path)
|
||||||
|
|
||||||
|
# Get ALL DB records (including soft-deleted) to avoid re-creating deleted files
|
||||||
|
result = await db.execute(text(
|
||||||
|
"SELECT id, storage_path, is_deleted FROM files"
|
||||||
|
))
|
||||||
|
db_records = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# Build lookup maps
|
||||||
|
all_db_paths = {r["storage_path"] for r in db_records}
|
||||||
|
active_db_paths = {r["storage_path"] for r in db_records if not r["is_deleted"]}
|
||||||
|
deleted_on_disk = {r["storage_path"]: r for r in db_records
|
||||||
|
if r["is_deleted"] and r["storage_path"] in disk_files}
|
||||||
|
|
||||||
|
# New on disk, not in DB at all → create record
|
||||||
|
new_files = disk_files - all_db_paths
|
||||||
|
for rel_path in new_files:
|
||||||
|
abs_path = os.path.join(FILE_STORAGE_PATH, rel_path)
|
||||||
|
filename = os.path.basename(rel_path)
|
||||||
|
mime_type = mimetypes.guess_type(filename)[0]
|
||||||
|
try:
|
||||||
|
size_bytes = os.path.getsize(abs_path)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
repo = BaseRepository("files", db)
|
||||||
|
await repo.create({
|
||||||
|
"filename": filename,
|
||||||
|
"original_filename": filename,
|
||||||
|
"storage_path": rel_path,
|
||||||
|
"mime_type": mime_type,
|
||||||
|
"size_bytes": size_bytes,
|
||||||
|
})
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
# Soft-deleted in DB but still on disk → restore
|
||||||
|
for rel_path, record in deleted_on_disk.items():
|
||||||
|
repo = BaseRepository("files", db)
|
||||||
|
await repo.restore(record["id"])
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
# Active in DB but missing from disk → soft-delete
|
||||||
|
missing_files = active_db_paths - disk_files
|
||||||
|
for record in db_records:
|
||||||
|
if record["storage_path"] in missing_files and not record["is_deleted"]:
|
||||||
|
repo = BaseRepository("files", db)
|
||||||
|
await repo.soft_delete(record["id"])
|
||||||
|
removed += 1
|
||||||
|
|
||||||
|
return {"added": added, "removed": removed}
|
||||||
|
|
||||||
|
|
||||||
|
SORT_OPTIONS = {
|
||||||
|
"path": "storage_path ASC",
|
||||||
|
"path_desc": "storage_path DESC",
|
||||||
|
"name": "original_filename ASC",
|
||||||
|
"name_desc": "original_filename DESC",
|
||||||
|
"date": "created_at DESC",
|
||||||
|
"date_asc": "created_at ASC",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Map type filter values to mime prefixes/patterns
|
||||||
|
TYPE_FILTERS = {
|
||||||
|
"image": "image/%",
|
||||||
|
"document": "application/pdf",
|
||||||
|
"text": "text/%",
|
||||||
|
"spreadsheet": "application/vnd.%sheet%",
|
||||||
|
"archive": "application/%zip%",
|
||||||
|
}
|
||||||
|
|
||||||
|
PER_PAGE = 50
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def list_files(
|
async def list_files(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
folder: Optional[str] = None,
|
||||||
|
sort: Optional[str] = None,
|
||||||
|
q: Optional[str] = None,
|
||||||
|
file_type: Optional[str] = None,
|
||||||
|
tag: Optional[str] = None,
|
||||||
|
page: int = 1,
|
||||||
context_type: Optional[str] = None,
|
context_type: Optional[str] = None,
|
||||||
context_id: Optional[str] = None,
|
context_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)
|
||||||
|
|
||||||
|
# Auto-sync on page load
|
||||||
|
sync_result = await sync_files(db)
|
||||||
|
|
||||||
|
folders = get_folders()
|
||||||
|
|
||||||
|
order_by = SORT_OPTIONS.get(sort, "storage_path ASC")
|
||||||
|
|
||||||
|
# Normalize folder param from form: " " (space) = root, "" = all, None = all
|
||||||
|
if folder is not None:
|
||||||
|
if folder.strip() == "" and folder != "":
|
||||||
|
# Space-only value means root folder
|
||||||
|
folder = ""
|
||||||
|
elif folder.strip() == "":
|
||||||
|
# Empty string means "all folders" (no filter)
|
||||||
|
folder = None
|
||||||
|
|
||||||
|
# Build dynamic WHERE clauses
|
||||||
|
where_clauses = ["f.is_deleted = false"]
|
||||||
|
params = {}
|
||||||
|
|
||||||
if context_type and context_id:
|
if context_type and context_id:
|
||||||
# Files attached to a specific entity
|
where_clauses.append("fm.context_type = :ct AND fm.context_id = :cid")
|
||||||
result = await db.execute(text("""
|
params["ct"] = context_type
|
||||||
|
params["cid"] = context_id
|
||||||
|
|
||||||
|
if folder is not None:
|
||||||
|
if folder == "":
|
||||||
|
where_clauses.append("f.storage_path NOT LIKE '%/%'")
|
||||||
|
else:
|
||||||
|
where_clauses.append("f.storage_path LIKE :prefix")
|
||||||
|
params["prefix"] = folder + "/%"
|
||||||
|
|
||||||
|
if q and q.strip():
|
||||||
|
search_terms = q.strip().split()
|
||||||
|
tsquery = " & ".join(f"{t}:*" for t in search_terms)
|
||||||
|
where_clauses.append("f.search_vector @@ to_tsquery('english', :tsquery)")
|
||||||
|
params["tsquery"] = tsquery
|
||||||
|
|
||||||
|
if file_type and file_type in TYPE_FILTERS:
|
||||||
|
where_clauses.append("f.mime_type LIKE :mime_pattern")
|
||||||
|
params["mime_pattern"] = TYPE_FILTERS[file_type]
|
||||||
|
|
||||||
|
if tag and tag.strip():
|
||||||
|
where_clauses.append(":tag = ANY(f.tags)")
|
||||||
|
params["tag"] = tag.strip()
|
||||||
|
|
||||||
|
where_sql = " AND ".join(where_clauses)
|
||||||
|
|
||||||
|
# Count total for pagination
|
||||||
|
if context_type and context_id:
|
||||||
|
count_sql = f"""
|
||||||
|
SELECT COUNT(*) FROM files f
|
||||||
|
JOIN file_mappings fm ON fm.file_id = f.id
|
||||||
|
WHERE {where_sql}
|
||||||
|
"""
|
||||||
|
else:
|
||||||
|
count_sql = f"SELECT COUNT(*) FROM files f WHERE {where_sql}"
|
||||||
|
|
||||||
|
total = (await db.execute(text(count_sql), params)).scalar()
|
||||||
|
|
||||||
|
# Paginate
|
||||||
|
if page < 1:
|
||||||
|
page = 1
|
||||||
|
offset = (page - 1) * PER_PAGE
|
||||||
|
total_pages = max(1, (total + PER_PAGE - 1) // PER_PAGE)
|
||||||
|
|
||||||
|
# Query
|
||||||
|
if context_type and context_id:
|
||||||
|
query_sql = f"""
|
||||||
SELECT f.*, fm.context_type, fm.context_id
|
SELECT f.*, fm.context_type, fm.context_id
|
||||||
FROM files f
|
FROM files f
|
||||||
JOIN file_mappings fm ON fm.file_id = f.id
|
JOIN file_mappings fm ON fm.file_id = f.id
|
||||||
WHERE f.is_deleted = false AND fm.context_type = :ct AND fm.context_id = :cid
|
WHERE {where_sql}
|
||||||
ORDER BY f.created_at DESC
|
ORDER BY {order_by}
|
||||||
"""), {"ct": context_type, "cid": context_id})
|
LIMIT :lim OFFSET :off
|
||||||
|
"""
|
||||||
else:
|
else:
|
||||||
# All files
|
query_sql = f"""
|
||||||
result = await db.execute(text("""
|
|
||||||
SELECT f.* FROM files f
|
SELECT f.* FROM files f
|
||||||
WHERE f.is_deleted = false
|
WHERE {where_sql}
|
||||||
ORDER BY f.created_at DESC
|
ORDER BY {order_by}
|
||||||
LIMIT 100
|
LIMIT :lim OFFSET :off
|
||||||
"""))
|
"""
|
||||||
|
|
||||||
|
params["lim"] = PER_PAGE
|
||||||
|
params["off"] = offset
|
||||||
|
result = await db.execute(text(query_sql), params)
|
||||||
items = [dict(r._mapping) for r in result]
|
items = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# Add derived folder field for display
|
||||||
|
for item in items:
|
||||||
|
dirname = os.path.dirname(item["storage_path"])
|
||||||
|
item["folder"] = dirname if dirname else "/"
|
||||||
|
|
||||||
|
# Get all unique tags for the tag filter dropdown
|
||||||
|
tag_result = await db.execute(text(
|
||||||
|
"SELECT DISTINCT unnest(tags) AS tag FROM files WHERE is_deleted = false AND tags IS NOT NULL ORDER BY tag"
|
||||||
|
))
|
||||||
|
all_tags = [r._mapping["tag"] for r in tag_result]
|
||||||
|
|
||||||
return templates.TemplateResponse("files.html", {
|
return templates.TemplateResponse("files.html", {
|
||||||
"request": request, "sidebar": sidebar, "items": items,
|
"request": request, "sidebar": sidebar, "items": items,
|
||||||
|
"folders": folders, "current_folder": folder,
|
||||||
|
"current_sort": sort or "path",
|
||||||
|
"current_q": q or "",
|
||||||
|
"current_type": file_type or "",
|
||||||
|
"current_tag": tag or "",
|
||||||
|
"current_page": page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"total_files": total,
|
||||||
|
"all_tags": all_tags,
|
||||||
|
"sync_result": sync_result,
|
||||||
"context_type": context_type or "",
|
"context_type": context_type or "",
|
||||||
"context_id": context_id or "",
|
"context_id": context_id or "",
|
||||||
"page_title": "Files", "active_nav": "files",
|
"page_title": "Files", "active_nav": "files",
|
||||||
@@ -70,13 +292,16 @@ async def list_files(
|
|||||||
@router.get("/upload")
|
@router.get("/upload")
|
||||||
async def upload_form(
|
async def upload_form(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
folder: Optional[str] = None,
|
||||||
context_type: Optional[str] = None,
|
context_type: Optional[str] = None,
|
||||||
context_id: Optional[str] = None,
|
context_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)
|
||||||
|
folders = get_folders()
|
||||||
return templates.TemplateResponse("file_upload.html", {
|
return templates.TemplateResponse("file_upload.html", {
|
||||||
"request": request, "sidebar": sidebar,
|
"request": request, "sidebar": sidebar,
|
||||||
|
"folders": folders, "prefill_folder": folder or "",
|
||||||
"context_type": context_type or "",
|
"context_type": context_type or "",
|
||||||
"context_id": context_id or "",
|
"context_id": context_id or "",
|
||||||
"page_title": "Upload File", "active_nav": "files",
|
"page_title": "Upload File", "active_nav": "files",
|
||||||
@@ -89,19 +314,41 @@ async def upload_file(
|
|||||||
file: UploadFile = FastAPIFile(...),
|
file: UploadFile = FastAPIFile(...),
|
||||||
description: Optional[str] = Form(None),
|
description: Optional[str] = Form(None),
|
||||||
tags: Optional[str] = Form(None),
|
tags: Optional[str] = Form(None),
|
||||||
|
folder: Optional[str] = Form(None),
|
||||||
|
new_folder: Optional[str] = Form(None),
|
||||||
context_type: Optional[str] = Form(None),
|
context_type: Optional[str] = Form(None),
|
||||||
context_id: Optional[str] = Form(None),
|
context_id: Optional[str] = Form(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
# Generate storage filename
|
# Determine target folder
|
||||||
file_uuid = str(uuid.uuid4())
|
target_folder = ""
|
||||||
|
if new_folder and new_folder.strip():
|
||||||
|
target_folder = new_folder.strip().strip("/")
|
||||||
|
elif folder and folder.strip():
|
||||||
|
target_folder = folder.strip()
|
||||||
|
|
||||||
|
# Build absolute folder path and ensure it exists
|
||||||
|
if target_folder:
|
||||||
|
folder_abs = os.path.join(FILE_STORAGE_PATH, target_folder)
|
||||||
|
else:
|
||||||
|
folder_abs = FILE_STORAGE_PATH
|
||||||
|
os.makedirs(folder_abs, exist_ok=True)
|
||||||
|
|
||||||
|
# Use original filename, handle collisions
|
||||||
original = file.filename or "unknown"
|
original = file.filename or "unknown"
|
||||||
safe_name = original.replace("/", "_").replace("\\", "_")
|
safe_name = original.replace("/", "_").replace("\\", "_")
|
||||||
storage_name = f"{file_uuid}_{safe_name}"
|
final_name = resolve_collision(folder_abs, safe_name)
|
||||||
storage_path = os.path.join(FILE_STORAGE_PATH, storage_name)
|
|
||||||
|
# Build relative storage path
|
||||||
|
if target_folder:
|
||||||
|
storage_path = os.path.join(target_folder, final_name)
|
||||||
|
else:
|
||||||
|
storage_path = final_name
|
||||||
|
|
||||||
|
abs_path = os.path.join(FILE_STORAGE_PATH, storage_path)
|
||||||
|
|
||||||
# Save to disk
|
# Save to disk
|
||||||
with open(storage_path, "wb") as f:
|
with open(abs_path, "wb") as f:
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
f.write(content)
|
f.write(content)
|
||||||
|
|
||||||
@@ -110,7 +357,7 @@ async def upload_file(
|
|||||||
# Insert file record
|
# Insert file record
|
||||||
repo = BaseRepository("files", db)
|
repo = BaseRepository("files", db)
|
||||||
data = {
|
data = {
|
||||||
"filename": storage_name,
|
"filename": final_name,
|
||||||
"original_filename": original,
|
"original_filename": original,
|
||||||
"storage_path": storage_path,
|
"storage_path": storage_path,
|
||||||
"mime_type": file.content_type,
|
"mime_type": file.content_type,
|
||||||
@@ -139,15 +386,26 @@ async def upload_file(
|
|||||||
return RedirectResponse(url="/files", status_code=303)
|
return RedirectResponse(url="/files", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync")
|
||||||
|
async def manual_sync(request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Manual sync trigger."""
|
||||||
|
await sync_files(db)
|
||||||
|
return RedirectResponse(url="/files", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{file_id}/download")
|
@router.get("/{file_id}/download")
|
||||||
async def download_file(file_id: str, db: AsyncSession = Depends(get_db)):
|
async def download_file(file_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
repo = BaseRepository("files", db)
|
repo = BaseRepository("files", db)
|
||||||
item = await repo.get(file_id)
|
item = await repo.get(file_id)
|
||||||
if not item or not os.path.exists(item["storage_path"]):
|
if not item:
|
||||||
|
return RedirectResponse(url="/files", status_code=303)
|
||||||
|
|
||||||
|
abs_path = _resolve_path(item)
|
||||||
|
if not os.path.exists(abs_path):
|
||||||
return RedirectResponse(url="/files", status_code=303)
|
return RedirectResponse(url="/files", status_code=303)
|
||||||
|
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path=item["storage_path"],
|
path=abs_path,
|
||||||
filename=item["original_filename"],
|
filename=item["original_filename"],
|
||||||
media_type=item.get("mime_type") or "application/octet-stream",
|
media_type=item.get("mime_type") or "application/octet-stream",
|
||||||
)
|
)
|
||||||
@@ -163,10 +421,12 @@ async def preview_file(file_id: str, request: Request, db: AsyncSession = Depend
|
|||||||
return RedirectResponse(url="/files", status_code=303)
|
return RedirectResponse(url="/files", status_code=303)
|
||||||
|
|
||||||
can_preview = item.get("mime_type", "") in PREVIEWABLE
|
can_preview = item.get("mime_type", "") in PREVIEWABLE
|
||||||
|
folder = os.path.dirname(item["storage_path"])
|
||||||
|
|
||||||
return templates.TemplateResponse("file_preview.html", {
|
return templates.TemplateResponse("file_preview.html", {
|
||||||
"request": request, "sidebar": sidebar, "item": item,
|
"request": request, "sidebar": sidebar, "item": item,
|
||||||
"can_preview": can_preview,
|
"can_preview": can_preview,
|
||||||
|
"folder": folder if folder else "/",
|
||||||
"page_title": item["original_filename"], "active_nav": "files",
|
"page_title": item["original_filename"], "active_nav": "files",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -176,13 +436,34 @@ async def serve_file(file_id: str, db: AsyncSession = Depends(get_db)):
|
|||||||
"""Serve file inline (for img src, iframe, etc)."""
|
"""Serve file inline (for img src, iframe, etc)."""
|
||||||
repo = BaseRepository("files", db)
|
repo = BaseRepository("files", db)
|
||||||
item = await repo.get(file_id)
|
item = await repo.get(file_id)
|
||||||
if not item or not os.path.exists(item["storage_path"]):
|
if not item:
|
||||||
return RedirectResponse(url="/files", status_code=303)
|
return RedirectResponse(url="/files", status_code=303)
|
||||||
|
|
||||||
return FileResponse(
|
abs_path = _resolve_path(item)
|
||||||
path=item["storage_path"],
|
if not os.path.exists(abs_path):
|
||||||
media_type=item.get("mime_type") or "application/octet-stream",
|
return RedirectResponse(url="/files", status_code=303)
|
||||||
)
|
|
||||||
|
mime = item.get("mime_type") or "application/octet-stream"
|
||||||
|
|
||||||
|
# Wrap text files in HTML with forced white background / dark text
|
||||||
|
if mime.startswith("text/"):
|
||||||
|
try:
|
||||||
|
with open(abs_path, "r", errors="replace") as f:
|
||||||
|
text_content = f.read()
|
||||||
|
except Exception:
|
||||||
|
return FileResponse(path=abs_path, media_type=mime)
|
||||||
|
|
||||||
|
from html import escape
|
||||||
|
html = (
|
||||||
|
'<!DOCTYPE html><html><head><meta charset="utf-8">'
|
||||||
|
'<style>body{background:#fff;color:#1a1a1a;font-family:monospace;'
|
||||||
|
'font-size:14px;padding:16px;margin:0;white-space:pre-wrap;'
|
||||||
|
'word-wrap:break-word;}</style></head><body>'
|
||||||
|
f'{escape(text_content)}</body></html>'
|
||||||
|
)
|
||||||
|
return HTMLResponse(content=html)
|
||||||
|
|
||||||
|
return FileResponse(path=abs_path, media_type=mime)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{file_id}/delete")
|
@router.post("/{file_id}/delete")
|
||||||
|
|||||||
@@ -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",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -87,6 +121,20 @@ async def add_to_focus(
|
|||||||
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
|
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_focus(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
focus_date: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("daily_focus", db)
|
||||||
|
parsed_date = date.fromisoformat(focus_date)
|
||||||
|
await repo.move_in_order(item_id, direction, filters={"focus_date": parsed_date})
|
||||||
|
return RedirectResponse(url=f"/focus?focus_date={focus_date}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{focus_id}/toggle")
|
@router.post("/{focus_id}/toggle")
|
||||||
async def toggle_focus(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
async def toggle_focus(focus_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
repo = BaseRepository("daily_focus", db)
|
repo = BaseRepository("daily_focus", db)
|
||||||
|
|||||||
90
routers/history.py
Normal file
90
routers/history.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""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"),
|
||||||
|
("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",
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Links: URL references attached to domains/projects."""
|
"""Links: URL references attached to domains/projects/tasks/meetings."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Form, Depends
|
from fastapi import APIRouter, Request, Form, Depends
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -10,6 +10,7 @@ from typing import Optional
|
|||||||
from core.database import get_db
|
from core.database import get_db
|
||||||
from core.base_repository import BaseRepository
|
from core.base_repository import BaseRepository
|
||||||
from core.sidebar import get_sidebar_data
|
from core.sidebar import get_sidebar_data
|
||||||
|
from routers.weblinks import get_default_folder_id
|
||||||
|
|
||||||
router = APIRouter(prefix="/links", tags=["links"])
|
router = APIRouter(prefix="/links", tags=["links"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
@@ -45,7 +46,14 @@ async def list_links(request: Request, domain_id: Optional[str] = None, db: Asyn
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/create")
|
@router.get("/create")
|
||||||
async def create_form(request: Request, domain_id: Optional[str] = None, project_id: Optional[str] = None, db: AsyncSession = Depends(get_db)):
|
async def create_form(
|
||||||
|
request: Request,
|
||||||
|
domain_id: Optional[str] = None,
|
||||||
|
project_id: Optional[str] = None,
|
||||||
|
task_id: Optional[str] = None,
|
||||||
|
meeting_id: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
sidebar = await get_sidebar_data(db)
|
sidebar = await get_sidebar_data(db)
|
||||||
domains_repo = BaseRepository("domains", db)
|
domains_repo = BaseRepository("domains", db)
|
||||||
domains = await domains_repo.list()
|
domains = await domains_repo.list()
|
||||||
@@ -54,22 +62,48 @@ async def create_form(request: Request, domain_id: Optional[str] = None, project
|
|||||||
return templates.TemplateResponse("link_form.html", {
|
return templates.TemplateResponse("link_form.html", {
|
||||||
"request": request, "sidebar": sidebar, "domains": domains, "projects": projects,
|
"request": request, "sidebar": sidebar, "domains": domains, "projects": projects,
|
||||||
"page_title": "New Link", "active_nav": "links",
|
"page_title": "New Link", "active_nav": "links",
|
||||||
"item": None, "prefill_domain_id": domain_id or "", "prefill_project_id": project_id or "",
|
"item": None,
|
||||||
|
"prefill_domain_id": domain_id or "",
|
||||||
|
"prefill_project_id": project_id or "",
|
||||||
|
"prefill_task_id": task_id or "",
|
||||||
|
"prefill_meeting_id": meeting_id or "",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/create")
|
@router.post("/create")
|
||||||
async def create_link(
|
async def create_link(
|
||||||
request: Request, label: str = Form(...), url: str = Form(...),
|
request: Request, label: str = Form(...), url: str = Form(...),
|
||||||
domain_id: str = Form(...), project_id: Optional[str] = Form(None),
|
domain_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None),
|
||||||
description: Optional[str] = Form(None), db: AsyncSession = Depends(get_db),
|
task_id: Optional[str] = Form(None), meeting_id: Optional[str] = Form(None),
|
||||||
|
description: Optional[str] = Form(None), tags: Optional[str] = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
repo = BaseRepository("links", db)
|
repo = BaseRepository("links", db)
|
||||||
data = {"label": label, "url": url, "domain_id": domain_id, "description": description}
|
data = {"label": label, "url": url, "description": description}
|
||||||
|
if domain_id and domain_id.strip():
|
||||||
|
data["domain_id"] = domain_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
|
||||||
await repo.create(data)
|
if task_id and task_id.strip():
|
||||||
referer = request.headers.get("referer", "/links")
|
data["task_id"] = task_id
|
||||||
|
if meeting_id and meeting_id.strip():
|
||||||
|
data["meeting_id"] = meeting_id
|
||||||
|
if tags and tags.strip():
|
||||||
|
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||||
|
link = await repo.create(data)
|
||||||
|
|
||||||
|
# Assign to Default folder
|
||||||
|
default_fid = await get_default_folder_id(db)
|
||||||
|
await db.execute(text("""
|
||||||
|
INSERT INTO folder_links (folder_id, link_id)
|
||||||
|
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
|
||||||
|
"""), {"fid": default_fid, "lid": link["id"]})
|
||||||
|
|
||||||
|
# Redirect back to context if created from task/meeting
|
||||||
|
if task_id and task_id.strip():
|
||||||
|
return RedirectResponse(url=f"/tasks/{task_id}?tab=links", status_code=303)
|
||||||
|
if meeting_id and meeting_id.strip():
|
||||||
|
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=links", status_code=303)
|
||||||
return RedirectResponse(url="/links", status_code=303)
|
return RedirectResponse(url="/links", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@@ -87,22 +121,31 @@ async def edit_form(link_id: str, request: Request, db: AsyncSession = Depends(g
|
|||||||
return templates.TemplateResponse("link_form.html", {
|
return templates.TemplateResponse("link_form.html", {
|
||||||
"request": request, "sidebar": sidebar, "domains": domains, "projects": projects,
|
"request": request, "sidebar": sidebar, "domains": domains, "projects": projects,
|
||||||
"page_title": "Edit Link", "active_nav": "links",
|
"page_title": "Edit Link", "active_nav": "links",
|
||||||
"item": item, "prefill_domain_id": "", "prefill_project_id": "",
|
"item": item,
|
||||||
|
"prefill_domain_id": "", "prefill_project_id": "",
|
||||||
|
"prefill_task_id": "", "prefill_meeting_id": "",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{link_id}/edit")
|
@router.post("/{link_id}/edit")
|
||||||
async def update_link(
|
async def update_link(
|
||||||
link_id: str, label: str = Form(...), url: str = Form(...),
|
link_id: str, label: str = Form(...), url: str = Form(...),
|
||||||
domain_id: str = Form(...), project_id: Optional[str] = Form(None),
|
domain_id: Optional[str] = Form(None), project_id: Optional[str] = Form(None),
|
||||||
description: Optional[str] = Form(None), db: AsyncSession = Depends(get_db),
|
description: Optional[str] = Form(None), tags: Optional[str] = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
repo = BaseRepository("links", db)
|
repo = BaseRepository("links", db)
|
||||||
await repo.update(link_id, {
|
data = {
|
||||||
"label": label, "url": url, "domain_id": domain_id,
|
"label": label, "url": url,
|
||||||
|
"domain_id": domain_id if domain_id and domain_id.strip() else None,
|
||||||
"project_id": project_id if project_id and project_id.strip() else None,
|
"project_id": project_id if project_id and project_id.strip() else None,
|
||||||
"description": description,
|
"description": description,
|
||||||
})
|
}
|
||||||
|
if tags and tags.strip():
|
||||||
|
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||||
|
else:
|
||||||
|
data["tags"] = None
|
||||||
|
await repo.update(link_id, data)
|
||||||
return RedirectResponse(url="/links", status_code=303)
|
return RedirectResponse(url="/links", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@@ -111,3 +154,15 @@ async def delete_link(link_id: str, request: Request, db: AsyncSession = Depends
|
|||||||
repo = BaseRepository("links", db)
|
repo = BaseRepository("links", db)
|
||||||
await repo.soft_delete(link_id)
|
await repo.soft_delete(link_id)
|
||||||
return RedirectResponse(url=request.headers.get("referer", "/links"), status_code=303)
|
return RedirectResponse(url=request.headers.get("referer", "/links"), status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_link(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("links", db)
|
||||||
|
await repo.move_in_order(item_id, direction)
|
||||||
|
return RedirectResponse(url=request.headers.get("referer", "/links"), status_code=303)
|
||||||
|
|||||||
@@ -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,62 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_list(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("lists", db)
|
||||||
|
await repo.move_in_order(item_id, direction)
|
||||||
|
return RedirectResponse(url=request.headers.get("referer", "/lists"), status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{list_id}/items/reorder")
|
||||||
|
async def reorder_list_item(
|
||||||
|
list_id: str,
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
parent_id: Optional[str] = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("list_items", db)
|
||||||
|
filters = {"list_id": list_id}
|
||||||
|
if parent_id:
|
||||||
|
filters["parent_item_id"] = parent_id
|
||||||
|
else:
|
||||||
|
# Top-level items only (no parent)
|
||||||
|
filters["parent_item_id"] = None
|
||||||
|
await repo.move_in_order(item_id, direction, filters=filters)
|
||||||
|
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
|
||||||
|
|||||||
@@ -107,61 +107,147 @@ 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)
|
# Linked projects (always shown in header)
|
||||||
result = await db.execute(text("""
|
result = await db.execute(text("""
|
||||||
SELECT t.*, mt.source,
|
SELECT p.id, p.name, d.color as domain_color
|
||||||
d.name as domain_name, d.color as domain_color,
|
FROM projects p
|
||||||
p.name as project_name
|
JOIN project_meetings pm ON pm.project_id = p.id
|
||||||
FROM meeting_tasks mt
|
LEFT JOIN domains d ON p.domain_id = d.id
|
||||||
JOIN tasks t ON mt.task_id = t.id
|
WHERE pm.meeting_id = :mid AND p.is_deleted = false
|
||||||
LEFT JOIN domains d ON t.domain_id = d.id
|
ORDER BY p.name
|
||||||
LEFT JOIN projects p ON t.project_id = p.id
|
|
||||||
WHERE mt.meeting_id = :mid AND t.is_deleted = false
|
|
||||||
ORDER BY t.sort_order, t.created_at
|
|
||||||
"""), {"mid": meeting_id})
|
"""), {"mid": meeting_id})
|
||||||
action_items = [dict(r._mapping) for r in result]
|
projects = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
# Notes linked to this meeting
|
# Overview data (always needed for overview tab)
|
||||||
result = await db.execute(text("""
|
action_items = []
|
||||||
SELECT * FROM notes
|
decisions = []
|
||||||
WHERE meeting_id = :mid AND is_deleted = false
|
domains = []
|
||||||
ORDER BY created_at
|
tab_data = []
|
||||||
"""), {"mid": meeting_id})
|
all_contacts = []
|
||||||
meeting_notes = [dict(r._mapping) for r in result]
|
all_decisions = []
|
||||||
|
|
||||||
# Decisions from this meeting
|
if tab == "overview":
|
||||||
result = await db.execute(text("""
|
# Action items
|
||||||
SELECT * FROM decisions
|
result = await db.execute(text("""
|
||||||
WHERE meeting_id = :mid AND is_deleted = false
|
SELECT t.*, mt.source,
|
||||||
ORDER BY created_at
|
d.name as domain_name, d.color as domain_color,
|
||||||
"""), {"mid": meeting_id})
|
p.name as project_name
|
||||||
decisions = [dict(r._mapping) for r in result]
|
FROM meeting_tasks mt
|
||||||
|
JOIN tasks t ON mt.task_id = t.id
|
||||||
|
LEFT JOIN domains d ON t.domain_id = d.id
|
||||||
|
LEFT JOIN projects p ON t.project_id = p.id
|
||||||
|
WHERE mt.meeting_id = :mid AND t.is_deleted = false
|
||||||
|
ORDER BY t.sort_order, t.created_at
|
||||||
|
"""), {"mid": meeting_id})
|
||||||
|
action_items = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
# Attendees
|
# Decisions from this meeting
|
||||||
result = await db.execute(text("""
|
result = await db.execute(text("""
|
||||||
SELECT c.*, cm.role FROM contact_meetings cm
|
SELECT * FROM decisions
|
||||||
JOIN contacts c ON cm.contact_id = c.id
|
WHERE meeting_id = :mid AND is_deleted = false
|
||||||
WHERE cm.meeting_id = :mid AND c.is_deleted = false
|
ORDER BY created_at
|
||||||
ORDER BY c.first_name
|
"""), {"mid": meeting_id})
|
||||||
"""), {"mid": meeting_id})
|
decisions = [dict(r._mapping) for r in result]
|
||||||
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 == "links":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT * FROM links
|
||||||
|
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 == "decisions":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT * FROM decisions
|
||||||
|
WHERE meeting_id = :mid AND is_deleted = false
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
"""), {"mid": meeting_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT id, title FROM decisions
|
||||||
|
WHERE (meeting_id IS NULL) AND is_deleted = false
|
||||||
|
ORDER BY created_at DESC LIMIT 50
|
||||||
|
"""))
|
||||||
|
all_decisions = [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"),
|
||||||
|
("links", "SELECT count(*) FROM links 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"),
|
||||||
|
("decisions", "SELECT count(*) FROM decisions 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, "projects": projects,
|
||||||
"domains": domains,
|
"tab": tab, "tab_data": tab_data,
|
||||||
|
"all_contacts": all_contacts, "all_decisions": all_decisions,
|
||||||
|
"counts": counts,
|
||||||
"page_title": item["title"], "active_nav": "meetings",
|
"page_title": item["title"], "active_nav": "meetings",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -266,3 +352,67 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Decision linking ----
|
||||||
|
|
||||||
|
@router.post("/{meeting_id}/decisions/add")
|
||||||
|
async def add_decision(
|
||||||
|
meeting_id: str,
|
||||||
|
decision_id: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(text("""
|
||||||
|
UPDATE decisions SET meeting_id = :mid WHERE id = :did
|
||||||
|
"""), {"mid": meeting_id, "did": decision_id})
|
||||||
|
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=decisions", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{meeting_id}/decisions/{decision_id}/remove")
|
||||||
|
async def remove_decision(
|
||||||
|
meeting_id: str, decision_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(text("""
|
||||||
|
UPDATE decisions SET meeting_id = NULL WHERE id = :did AND meeting_id = :mid
|
||||||
|
"""), {"did": decision_id, "mid": meeting_id})
|
||||||
|
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=decisions", 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)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_meeting(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("meetings", db)
|
||||||
|
await repo.move_in_order(item_id, direction)
|
||||||
|
return RedirectResponse(url=request.headers.get("referer", "/meetings"), 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)
|
||||||
|
|
||||||
|
|
||||||
@@ -179,3 +193,15 @@ async def delete_note(note_id: str, request: Request, db: AsyncSession = Depends
|
|||||||
await repo.soft_delete(note_id)
|
await repo.soft_delete(note_id)
|
||||||
referer = request.headers.get("referer", "/notes")
|
referer = request.headers.get("referer", "/notes")
|
||||||
return RedirectResponse(url=referer, status_code=303)
|
return RedirectResponse(url=referer, status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_note(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("notes", db)
|
||||||
|
await repo.move_in_order(item_id, direction)
|
||||||
|
return RedirectResponse(url=request.headers.get("referer", "/notes"), status_code=303)
|
||||||
|
|||||||
569
routers/processes.py
Normal file
569
routers/processes.py
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
"""Processes: reusable workflows/checklists with runs and step tracking."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Form, Depends
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
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.base_repository import BaseRepository
|
||||||
|
from core.sidebar import get_sidebar_data
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/processes", tags=["processes"])
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Process Template CRUD ─────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_processes(
|
||||||
|
request: Request,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
process_type: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
sidebar = await get_sidebar_data(db)
|
||||||
|
filters = {}
|
||||||
|
if status:
|
||||||
|
filters["status"] = status
|
||||||
|
if process_type:
|
||||||
|
filters["process_type"] = process_type
|
||||||
|
|
||||||
|
repo = BaseRepository("processes", db)
|
||||||
|
items = await repo.list(filters=filters, sort="sort_order")
|
||||||
|
|
||||||
|
# Get step counts per process
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT process_id, count(*) as step_count
|
||||||
|
FROM process_steps WHERE is_deleted = false
|
||||||
|
GROUP BY process_id
|
||||||
|
"""))
|
||||||
|
step_counts = {str(r.process_id): r.step_count for r in result}
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
item["step_count"] = step_counts.get(str(item["id"]), 0)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("processes.html", {
|
||||||
|
"request": request, "sidebar": sidebar, "items": items,
|
||||||
|
"current_status": status or "",
|
||||||
|
"current_type": process_type or "",
|
||||||
|
"page_title": "Processes", "active_nav": "processes",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/create")
|
||||||
|
async def create_form(request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
|
sidebar = await get_sidebar_data(db)
|
||||||
|
return templates.TemplateResponse("processes_form.html", {
|
||||||
|
"request": request, "sidebar": sidebar, "item": None,
|
||||||
|
"page_title": "New Process", "active_nav": "processes",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create")
|
||||||
|
async def create_process(
|
||||||
|
request: Request,
|
||||||
|
name: str = Form(...),
|
||||||
|
description: Optional[str] = Form(None),
|
||||||
|
process_type: str = Form("checklist"),
|
||||||
|
status: str = Form("draft"),
|
||||||
|
category: Optional[str] = Form(None),
|
||||||
|
tags: Optional[str] = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("processes", db)
|
||||||
|
data = {
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"process_type": process_type,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
if category and category.strip():
|
||||||
|
data["category"] = category
|
||||||
|
if tags and tags.strip():
|
||||||
|
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||||
|
|
||||||
|
item = await repo.create(data)
|
||||||
|
return RedirectResponse(url=f"/processes/{item['id']}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs")
|
||||||
|
async def list_all_runs(
|
||||||
|
request: Request,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all runs across all processes."""
|
||||||
|
sidebar = await get_sidebar_data(db)
|
||||||
|
|
||||||
|
where = "pr.is_deleted = false"
|
||||||
|
params = {}
|
||||||
|
if status:
|
||||||
|
where += " AND pr.status = :status"
|
||||||
|
params["status"] = status
|
||||||
|
|
||||||
|
result = await db.execute(text(f"""
|
||||||
|
SELECT pr.*, p.name as process_name,
|
||||||
|
proj.name as project_name,
|
||||||
|
(SELECT count(*) FROM process_run_steps WHERE run_id = pr.id AND is_deleted = false) as total_steps,
|
||||||
|
(SELECT count(*) FROM process_run_steps WHERE run_id = pr.id AND is_deleted = false AND status = 'completed') as completed_steps
|
||||||
|
FROM process_runs pr
|
||||||
|
JOIN processes p ON pr.process_id = p.id
|
||||||
|
LEFT JOIN projects proj ON pr.project_id = proj.id
|
||||||
|
WHERE {where}
|
||||||
|
ORDER BY pr.created_at DESC
|
||||||
|
"""), params)
|
||||||
|
items = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
return templates.TemplateResponse("process_runs.html", {
|
||||||
|
"request": request, "sidebar": sidebar, "items": items,
|
||||||
|
"current_status": status or "",
|
||||||
|
"page_title": "All Process Runs", "active_nav": "processes",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs/{run_id}")
|
||||||
|
async def run_detail(run_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""View a specific process run with step checklist."""
|
||||||
|
sidebar = await get_sidebar_data(db)
|
||||||
|
|
||||||
|
# Get the run with process info
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT pr.*, p.name as process_name, p.id as process_id_ref,
|
||||||
|
proj.name as project_name,
|
||||||
|
c.first_name as contact_first, c.last_name as contact_last
|
||||||
|
FROM process_runs pr
|
||||||
|
JOIN processes p ON pr.process_id = p.id
|
||||||
|
LEFT JOIN projects proj ON pr.project_id = proj.id
|
||||||
|
LEFT JOIN contacts c ON pr.contact_id = c.id
|
||||||
|
WHERE pr.id = :id
|
||||||
|
"""), {"id": run_id})
|
||||||
|
run = result.first()
|
||||||
|
if not run:
|
||||||
|
return RedirectResponse(url="/processes/runs", status_code=303)
|
||||||
|
run = dict(run._mapping)
|
||||||
|
|
||||||
|
# Get run steps
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT * FROM process_run_steps
|
||||||
|
WHERE run_id = :run_id AND is_deleted = false
|
||||||
|
ORDER BY sort_order, created_at
|
||||||
|
"""), {"run_id": run_id})
|
||||||
|
steps = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
total = len(steps)
|
||||||
|
completed = sum(1 for s in steps if s["status"] == "completed")
|
||||||
|
|
||||||
|
# Get linked tasks via junction table
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT t.id, t.title, t.status, t.priority,
|
||||||
|
prt.run_step_id,
|
||||||
|
p.name as project_name
|
||||||
|
FROM process_run_tasks prt
|
||||||
|
JOIN tasks t ON prt.task_id = t.id
|
||||||
|
LEFT JOIN projects p ON t.project_id = p.id
|
||||||
|
WHERE prt.run_step_id IN (
|
||||||
|
SELECT id FROM process_run_steps WHERE run_id = :run_id
|
||||||
|
)
|
||||||
|
ORDER BY t.created_at
|
||||||
|
"""), {"run_id": run_id})
|
||||||
|
tasks = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# Map tasks to their steps
|
||||||
|
step_tasks = {}
|
||||||
|
for task in tasks:
|
||||||
|
sid = str(task["run_step_id"])
|
||||||
|
step_tasks.setdefault(sid, []).append(task)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("process_run_detail.html", {
|
||||||
|
"request": request, "sidebar": sidebar,
|
||||||
|
"run": run, "steps": steps, "tasks": tasks,
|
||||||
|
"step_tasks": step_tasks,
|
||||||
|
"total_steps": total, "completed_steps": completed,
|
||||||
|
"page_title": run["title"], "active_nav": "processes",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{process_id}")
|
||||||
|
async def process_detail(process_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
|
repo = BaseRepository("processes", db)
|
||||||
|
sidebar = await get_sidebar_data(db)
|
||||||
|
item = await repo.get(process_id)
|
||||||
|
if not item:
|
||||||
|
return RedirectResponse(url="/processes", status_code=303)
|
||||||
|
|
||||||
|
# Get steps
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT * FROM process_steps
|
||||||
|
WHERE process_id = :pid AND is_deleted = false
|
||||||
|
ORDER BY sort_order, created_at
|
||||||
|
"""), {"pid": process_id})
|
||||||
|
steps = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# Get runs
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT pr.*,
|
||||||
|
proj.name as project_name,
|
||||||
|
(SELECT count(*) FROM process_run_steps WHERE run_id = pr.id AND is_deleted = false) as total_steps,
|
||||||
|
(SELECT count(*) FROM process_run_steps WHERE run_id = pr.id AND is_deleted = false AND status = 'completed') as completed_steps
|
||||||
|
FROM process_runs pr
|
||||||
|
LEFT JOIN projects proj ON pr.project_id = proj.id
|
||||||
|
WHERE pr.process_id = :pid AND pr.is_deleted = false
|
||||||
|
ORDER BY pr.created_at DESC
|
||||||
|
"""), {"pid": process_id})
|
||||||
|
runs = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# Load projects and contacts for "Start Run" form
|
||||||
|
projects_repo = BaseRepository("projects", db)
|
||||||
|
projects = await projects_repo.list()
|
||||||
|
contacts_repo = BaseRepository("contacts", db)
|
||||||
|
contacts = await contacts_repo.list()
|
||||||
|
|
||||||
|
return templates.TemplateResponse("processes_detail.html", {
|
||||||
|
"request": request, "sidebar": sidebar,
|
||||||
|
"item": item, "steps": steps, "runs": runs,
|
||||||
|
"projects": projects, "contacts": contacts,
|
||||||
|
"page_title": item["name"], "active_nav": "processes",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{process_id}/edit")
|
||||||
|
async def edit_form(process_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
|
repo = BaseRepository("processes", db)
|
||||||
|
sidebar = await get_sidebar_data(db)
|
||||||
|
item = await repo.get(process_id)
|
||||||
|
if not item:
|
||||||
|
return RedirectResponse(url="/processes", status_code=303)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("processes_form.html", {
|
||||||
|
"request": request, "sidebar": sidebar, "item": item,
|
||||||
|
"page_title": "Edit Process", "active_nav": "processes",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{process_id}/edit")
|
||||||
|
async def update_process(
|
||||||
|
process_id: str,
|
||||||
|
name: str = Form(...),
|
||||||
|
description: Optional[str] = Form(None),
|
||||||
|
process_type: str = Form("checklist"),
|
||||||
|
status: str = Form("draft"),
|
||||||
|
category: Optional[str] = Form(None),
|
||||||
|
tags: Optional[str] = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("processes", db)
|
||||||
|
data = {
|
||||||
|
"name": name,
|
||||||
|
"description": description,
|
||||||
|
"process_type": process_type,
|
||||||
|
"status": status,
|
||||||
|
"category": category if category and category.strip() else None,
|
||||||
|
}
|
||||||
|
if tags and tags.strip():
|
||||||
|
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
|
||||||
|
else:
|
||||||
|
data["tags"] = None
|
||||||
|
|
||||||
|
await repo.update(process_id, data)
|
||||||
|
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{process_id}/delete")
|
||||||
|
async def delete_process(process_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
|
repo = BaseRepository("processes", db)
|
||||||
|
await repo.soft_delete(process_id)
|
||||||
|
return RedirectResponse(url="/processes", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Process Steps ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{process_id}/steps/add")
|
||||||
|
async def add_step(
|
||||||
|
process_id: str,
|
||||||
|
title: str = Form(...),
|
||||||
|
instructions: Optional[str] = Form(None),
|
||||||
|
expected_output: Optional[str] = Form(None),
|
||||||
|
estimated_days: Optional[str] = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
# Get current max sort_order
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT coalesce(max(sort_order), -1) + 1 as next_order
|
||||||
|
FROM process_steps WHERE process_id = :pid AND is_deleted = false
|
||||||
|
"""), {"pid": process_id})
|
||||||
|
next_order = result.scalar()
|
||||||
|
|
||||||
|
repo = BaseRepository("process_steps", db)
|
||||||
|
data = {
|
||||||
|
"process_id": process_id,
|
||||||
|
"title": title,
|
||||||
|
"sort_order": next_order,
|
||||||
|
}
|
||||||
|
if instructions and instructions.strip():
|
||||||
|
data["instructions"] = instructions
|
||||||
|
if expected_output and expected_output.strip():
|
||||||
|
data["expected_output"] = expected_output
|
||||||
|
if estimated_days and estimated_days.strip():
|
||||||
|
data["estimated_days"] = int(estimated_days)
|
||||||
|
|
||||||
|
await repo.create(data)
|
||||||
|
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{process_id}/steps/{step_id}/edit")
|
||||||
|
async def edit_step(
|
||||||
|
process_id: str,
|
||||||
|
step_id: str,
|
||||||
|
title: str = Form(...),
|
||||||
|
instructions: Optional[str] = Form(None),
|
||||||
|
expected_output: Optional[str] = Form(None),
|
||||||
|
estimated_days: Optional[str] = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("process_steps", db)
|
||||||
|
data = {
|
||||||
|
"title": title,
|
||||||
|
"instructions": instructions if instructions and instructions.strip() else None,
|
||||||
|
"expected_output": expected_output if expected_output and expected_output.strip() else None,
|
||||||
|
"estimated_days": int(estimated_days) if estimated_days and estimated_days.strip() else None,
|
||||||
|
}
|
||||||
|
await repo.update(step_id, data)
|
||||||
|
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{process_id}/steps/{step_id}/delete")
|
||||||
|
async def delete_step(process_id: str, step_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
repo = BaseRepository("process_steps", db)
|
||||||
|
await repo.soft_delete(step_id)
|
||||||
|
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{process_id}/steps/reorder")
|
||||||
|
async def reorder_steps(
|
||||||
|
process_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
form = await request.form()
|
||||||
|
ids = form.getlist("step_ids")
|
||||||
|
if ids:
|
||||||
|
repo = BaseRepository("process_steps", db)
|
||||||
|
await repo.reorder(ids)
|
||||||
|
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Process Runs ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/{process_id}/runs/start")
|
||||||
|
async def start_run(
|
||||||
|
process_id: str,
|
||||||
|
title: str = Form(...),
|
||||||
|
task_generation: str = Form("all_at_once"),
|
||||||
|
project_id: Optional[str] = Form(None),
|
||||||
|
contact_id: Optional[str] = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Start a new process run: snapshot steps, optionally generate tasks."""
|
||||||
|
# Get process
|
||||||
|
proc_repo = BaseRepository("processes", db)
|
||||||
|
process = await proc_repo.get(process_id)
|
||||||
|
if not process:
|
||||||
|
return RedirectResponse(url="/processes", status_code=303)
|
||||||
|
|
||||||
|
# Create the run
|
||||||
|
run_repo = BaseRepository("process_runs", db)
|
||||||
|
run_data = {
|
||||||
|
"process_id": process_id,
|
||||||
|
"title": title,
|
||||||
|
"status": "in_progress",
|
||||||
|
"process_type": process["process_type"],
|
||||||
|
"task_generation": task_generation,
|
||||||
|
"started_at": datetime.now(timezone.utc),
|
||||||
|
}
|
||||||
|
if project_id and project_id.strip():
|
||||||
|
run_data["project_id"] = project_id
|
||||||
|
if contact_id and contact_id.strip():
|
||||||
|
run_data["contact_id"] = contact_id
|
||||||
|
|
||||||
|
run = await run_repo.create(run_data)
|
||||||
|
|
||||||
|
# Snapshot steps from the process template
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT * FROM process_steps
|
||||||
|
WHERE process_id = :pid AND is_deleted = false
|
||||||
|
ORDER BY sort_order, created_at
|
||||||
|
"""), {"pid": process_id})
|
||||||
|
template_steps = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
step_repo = BaseRepository("process_run_steps", db)
|
||||||
|
run_steps = []
|
||||||
|
for step in template_steps:
|
||||||
|
rs = await step_repo.create({
|
||||||
|
"run_id": str(run["id"]),
|
||||||
|
"title": step["title"],
|
||||||
|
"instructions": step.get("instructions"),
|
||||||
|
"status": "pending",
|
||||||
|
"sort_order": step["sort_order"],
|
||||||
|
})
|
||||||
|
run_steps.append(rs)
|
||||||
|
|
||||||
|
# Task generation
|
||||||
|
if run_steps:
|
||||||
|
await _generate_tasks(db, run, run_steps, task_generation)
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/processes/runs/{run['id']}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_tasks(db, run, run_steps, mode):
|
||||||
|
"""Generate tasks for run steps based on mode."""
|
||||||
|
task_repo = BaseRepository("tasks", db)
|
||||||
|
|
||||||
|
# Get a default domain for tasks
|
||||||
|
result = await db.execute(text(
|
||||||
|
"SELECT id FROM domains WHERE is_deleted = false ORDER BY sort_order LIMIT 1"
|
||||||
|
))
|
||||||
|
row = result.first()
|
||||||
|
default_domain_id = str(row[0]) if row else None
|
||||||
|
|
||||||
|
if not default_domain_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
if mode == "all_at_once":
|
||||||
|
steps_to_generate = run_steps
|
||||||
|
else: # step_by_step
|
||||||
|
steps_to_generate = [run_steps[0]]
|
||||||
|
|
||||||
|
for step in steps_to_generate:
|
||||||
|
task_data = {
|
||||||
|
"title": step["title"],
|
||||||
|
"description": step.get("instructions") or "",
|
||||||
|
"status": "open",
|
||||||
|
"priority": 3,
|
||||||
|
"domain_id": default_domain_id,
|
||||||
|
}
|
||||||
|
if run.get("project_id"):
|
||||||
|
task_data["project_id"] = str(run["project_id"])
|
||||||
|
|
||||||
|
task = await task_repo.create(task_data)
|
||||||
|
|
||||||
|
# Link via junction table
|
||||||
|
await db.execute(text("""
|
||||||
|
INSERT INTO process_run_tasks (run_step_id, task_id)
|
||||||
|
VALUES (:rsid, :tid)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
"""), {"rsid": str(step["id"]), "tid": str(task["id"])})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/runs/{run_id}/steps/{step_id}/complete")
|
||||||
|
async def complete_step(
|
||||||
|
run_id: str,
|
||||||
|
step_id: str,
|
||||||
|
notes: Optional[str] = Form(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Mark a run step as completed."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
step_repo = BaseRepository("process_run_steps", db)
|
||||||
|
await step_repo.update(step_id, {
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": now,
|
||||||
|
"notes": notes if notes and notes.strip() else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# If step_by_step mode, generate task for next pending step
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT pr.task_generation FROM process_runs pr WHERE pr.id = :rid
|
||||||
|
"""), {"rid": run_id})
|
||||||
|
run_row = result.first()
|
||||||
|
|
||||||
|
if run_row and run_row.task_generation == "step_by_step":
|
||||||
|
# Find next pending step
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT * FROM process_run_steps
|
||||||
|
WHERE run_id = :rid AND is_deleted = false AND status = 'pending'
|
||||||
|
ORDER BY sort_order LIMIT 1
|
||||||
|
"""), {"rid": run_id})
|
||||||
|
next_step = result.first()
|
||||||
|
|
||||||
|
if next_step:
|
||||||
|
next_step = dict(next_step._mapping)
|
||||||
|
# Get the full run for project_id
|
||||||
|
run_repo = BaseRepository("process_runs", db)
|
||||||
|
run = await run_repo.get(run_id)
|
||||||
|
await _generate_tasks(db, run, [next_step], "all_at_once")
|
||||||
|
|
||||||
|
# Auto-complete run if all steps done
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT count(*) FILTER (WHERE status != 'completed') as pending
|
||||||
|
FROM process_run_steps
|
||||||
|
WHERE run_id = :rid AND is_deleted = false
|
||||||
|
"""), {"rid": run_id})
|
||||||
|
pending = result.scalar()
|
||||||
|
if pending == 0:
|
||||||
|
run_repo = BaseRepository("process_runs", db)
|
||||||
|
await run_repo.update(run_id, {
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": now,
|
||||||
|
})
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/processes/runs/{run_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/runs/{run_id}/steps/{step_id}/uncomplete")
|
||||||
|
async def uncomplete_step(
|
||||||
|
run_id: str,
|
||||||
|
step_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Undo step completion."""
|
||||||
|
step_repo = BaseRepository("process_run_steps", db)
|
||||||
|
await step_repo.update(step_id, {
|
||||||
|
"status": "pending",
|
||||||
|
"completed_at": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# If run was completed, reopen it
|
||||||
|
run_repo = BaseRepository("process_runs", db)
|
||||||
|
run = await run_repo.get(run_id)
|
||||||
|
if run and run["status"] == "completed":
|
||||||
|
await run_repo.update(run_id, {
|
||||||
|
"status": "in_progress",
|
||||||
|
"completed_at": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/processes/runs/{run_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/runs/{run_id}/complete")
|
||||||
|
async def complete_run(run_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Mark entire run as complete."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
run_repo = BaseRepository("process_runs", db)
|
||||||
|
await run_repo.update(run_id, {
|
||||||
|
"status": "completed",
|
||||||
|
"completed_at": now,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Mark all pending steps as completed too
|
||||||
|
await db.execute(text("""
|
||||||
|
UPDATE process_run_steps
|
||||||
|
SET status = 'completed', completed_at = :now, updated_at = :now
|
||||||
|
WHERE run_id = :rid AND status != 'completed' AND is_deleted = false
|
||||||
|
"""), {"rid": run_id, "now": now})
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/processes/runs/{run_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/runs/{run_id}/delete")
|
||||||
|
async def delete_run(run_id: str, db: AsyncSession = Depends(get_db)):
|
||||||
|
# Get process_id before deleting for redirect
|
||||||
|
run_repo = BaseRepository("process_runs", db)
|
||||||
|
run = await run_repo.get(run_id)
|
||||||
|
await run_repo.soft_delete(run_id)
|
||||||
|
if run:
|
||||||
|
return RedirectResponse(url=f"/processes/{run['process_id']}", status_code=303)
|
||||||
|
return RedirectResponse(url="/processes/runs", 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,110 @@ 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
|
|
||||||
result = await db.execute(text("""
|
|
||||||
SELECT * FROM notes WHERE project_id = :pid AND is_deleted = false
|
|
||||||
ORDER BY sort_order, created_at DESC
|
|
||||||
"""), {"pid": project_id})
|
|
||||||
notes = [dict(r._mapping) for r in result]
|
|
||||||
|
|
||||||
# Links
|
|
||||||
result = await db.execute(text("""
|
|
||||||
SELECT * FROM links WHERE project_id = :pid AND is_deleted = false
|
|
||||||
ORDER BY sort_order, created_at
|
|
||||||
"""), {"pid": project_id})
|
|
||||||
links = [dict(r._mapping) for r in result]
|
|
||||||
|
|
||||||
# Progress
|
# Progress
|
||||||
total = len(tasks)
|
total = len(tasks)
|
||||||
done = len([t for t in tasks if t["status"] == "done"])
|
done = len([t for t in tasks if t["status"] == "done"])
|
||||||
progress = round((done / total * 100) if total > 0 else 0)
|
progress = round((done / total * 100) if total > 0 else 0)
|
||||||
|
|
||||||
|
# Tab-specific data
|
||||||
|
notes = []
|
||||||
|
links = []
|
||||||
|
tab_data = []
|
||||||
|
all_contacts = []
|
||||||
|
all_meetings = []
|
||||||
|
|
||||||
|
if tab == "notes":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT * FROM notes WHERE project_id = :pid AND is_deleted = false
|
||||||
|
ORDER BY sort_order, created_at DESC
|
||||||
|
"""), {"pid": project_id})
|
||||||
|
notes = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
elif tab == "links":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT * FROM links WHERE project_id = :pid AND is_deleted = false
|
||||||
|
ORDER BY sort_order, created_at
|
||||||
|
"""), {"pid": project_id})
|
||||||
|
links = [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 = '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 == "meetings":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT m.*, pm.created_at as linked_at
|
||||||
|
FROM meetings m
|
||||||
|
JOIN project_meetings pm ON pm.meeting_id = m.id
|
||||||
|
WHERE pm.project_id = :pid AND m.is_deleted = false
|
||||||
|
ORDER BY m.meeting_date DESC, m.start_at DESC NULLS LAST
|
||||||
|
"""), {"pid": project_id})
|
||||||
|
tab_data = [dict(r._mapping) for r in result]
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT id, title, meeting_date FROM meetings
|
||||||
|
WHERE is_deleted = false ORDER BY meeting_date DESC LIMIT 50
|
||||||
|
"""))
|
||||||
|
all_meetings = [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"),
|
||||||
|
("meetings", "SELECT count(*) FROM meetings m JOIN project_meetings pm ON pm.meeting_id = m.id WHERE pm.project_id = :pid AND m.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,
|
||||||
|
"all_meetings": all_meetings, "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 +348,56 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Meeting linking ----
|
||||||
|
|
||||||
|
@router.post("/{project_id}/meetings/add")
|
||||||
|
async def add_meeting(
|
||||||
|
project_id: str,
|
||||||
|
meeting_id: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(text("""
|
||||||
|
INSERT INTO project_meetings (project_id, meeting_id)
|
||||||
|
VALUES (:pid, :mid) ON CONFLICT DO NOTHING
|
||||||
|
"""), {"pid": project_id, "mid": meeting_id})
|
||||||
|
return RedirectResponse(url=f"/projects/{project_id}?tab=meetings", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/meetings/{meeting_id}/remove")
|
||||||
|
async def remove_meeting(
|
||||||
|
project_id: str, meeting_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await db.execute(text(
|
||||||
|
"DELETE FROM project_meetings WHERE project_id = :pid AND meeting_id = :mid"
|
||||||
|
), {"pid": project_id, "mid": meeting_id})
|
||||||
|
return RedirectResponse(url=f"/projects/{project_id}?tab=meetings", 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,160 +15,155 @@ 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": "processes", "label": "Processes", "table": "processes", "alias": "p",
|
||||||
"label": "Weblinks",
|
"name_col": "p.name", "status_col": "p.status",
|
||||||
"query": """
|
"joins": "",
|
||||||
SELECT w.id, w.label as name, NULL as status,
|
"domain_col": "p.category", "project_col": "NULL",
|
||||||
NULL as domain_name, NULL as project_name,
|
"url": "/processes/{id}", "icon": "process",
|
||||||
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": "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),
|
||||||
@@ -176,33 +172,30 @@ 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})
|
for row in rows:
|
||||||
rows = [dict(r._mapping) for r in result]
|
results.append({
|
||||||
for row in rows:
|
"type": entity["type"],
|
||||||
results.append({
|
"type_label": entity["label"],
|
||||||
"type": entity["type"],
|
"id": str(row["id"]),
|
||||||
"type_label": entity["label"],
|
"name": row["name"],
|
||||||
"id": str(row["id"]),
|
"status": row.get("status"),
|
||||||
"name": row["name"],
|
"context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])),
|
||||||
"status": row.get("status"),
|
"url": entity["url"].format(id=row["id"]),
|
||||||
"context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])),
|
"rank": float(row.get("rank", 0)),
|
||||||
"url": entity["url"].format(id=row["id"]),
|
"icon": entity["icon"],
|
||||||
"rank": float(row.get("rank", 0)),
|
})
|
||||||
"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)
|
||||||
@@ -220,24 +213,22 @@ 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})
|
for row in rows:
|
||||||
rows = [dict(r._mapping) for r in result]
|
results.append({
|
||||||
for row in rows:
|
"type": entity["type"],
|
||||||
results.append({
|
"type_label": entity["label"],
|
||||||
"type": entity["type"],
|
"id": str(row["id"]),
|
||||||
"type_label": entity["label"],
|
"name": row["name"],
|
||||||
"id": str(row["id"]),
|
"status": row.get("status"),
|
||||||
"name": row["name"],
|
"context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])),
|
||||||
"status": row.get("status"),
|
"url": entity["url"].format(id=row["id"]),
|
||||||
"context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])),
|
"icon": entity["icon"],
|
||||||
"url": entity["url"].format(id=row["id"]),
|
})
|
||||||
"icon": entity["icon"],
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
return templates.TemplateResponse("search.html", {
|
return templates.TemplateResponse("search.html", {
|
||||||
"request": request, "sidebar": sidebar,
|
"request": request, "sidebar": sidebar,
|
||||||
|
|||||||
141
routers/tasks.py
141
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,19 +216,101 @@ 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)
|
||||||
result = await db.execute(text("""
|
subtasks = []
|
||||||
SELECT * FROM tasks WHERE parent_id = :tid AND is_deleted = false
|
if tab == "overview":
|
||||||
ORDER BY sort_order, created_at
|
result = await db.execute(text("""
|
||||||
"""), {"tid": task_id})
|
SELECT * FROM tasks WHERE parent_id = :tid AND is_deleted = false
|
||||||
subtasks = [dict(r._mapping) for r in result]
|
ORDER BY sort_order, created_at
|
||||||
|
"""), {"tid": task_id})
|
||||||
|
subtasks = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
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 == "links":
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT * FROM links 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"),
|
||||||
|
("links", "SELECT count(*) FROM links 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,42 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reorder")
|
||||||
|
async def reorder_task(
|
||||||
|
request: Request,
|
||||||
|
item_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("tasks", db)
|
||||||
|
await repo.move_in_order(item_id, direction)
|
||||||
|
return RedirectResponse(url=request.headers.get("referer", "/tasks"), status_code=303)
|
||||||
|
|||||||
189
routers/time_budgets.py
Normal file
189
routers/time_budgets.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"""Time Budgets: weekly hour allocations per domain with actual vs budgeted comparison."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends, Form
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import text
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from core.database import get_db
|
||||||
|
from core.base_repository import BaseRepository
|
||||||
|
from core.sidebar import get_sidebar_data
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/time-budgets", tags=["time_budgets"])
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_time_budgets(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
sidebar = await get_sidebar_data(db)
|
||||||
|
|
||||||
|
# Get current budget per domain (most recent effective_from <= today)
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT DISTINCT ON (tb.domain_id)
|
||||||
|
tb.*, d.name as domain_name, d.color as domain_color
|
||||||
|
FROM time_budgets tb
|
||||||
|
JOIN domains d ON tb.domain_id = d.id
|
||||||
|
WHERE tb.is_deleted = false AND d.is_deleted = false
|
||||||
|
AND tb.effective_from <= CURRENT_DATE
|
||||||
|
ORDER BY tb.domain_id, tb.effective_from DESC
|
||||||
|
"""))
|
||||||
|
current_budgets = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
# Get actual hours per domain this week (Mon-Sun)
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT t.domain_id,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE
|
||||||
|
WHEN te.duration_minutes IS NOT NULL THEN te.duration_minutes
|
||||||
|
WHEN te.end_at IS NOT NULL THEN EXTRACT(EPOCH FROM (te.end_at - te.start_at)) / 60
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
), 0) / 60.0 as actual_hours
|
||||||
|
FROM time_entries te
|
||||||
|
JOIN tasks t ON te.task_id = t.id
|
||||||
|
WHERE te.is_deleted = false
|
||||||
|
AND te.start_at >= date_trunc('week', CURRENT_DATE)
|
||||||
|
AND te.start_at < date_trunc('week', CURRENT_DATE) + INTERVAL '7 days'
|
||||||
|
AND t.domain_id IS NOT NULL
|
||||||
|
GROUP BY t.domain_id
|
||||||
|
"""))
|
||||||
|
actual_map = {str(r._mapping["domain_id"]): float(r._mapping["actual_hours"]) for r in result}
|
||||||
|
|
||||||
|
# Attach actual hours to budgets
|
||||||
|
for b in current_budgets:
|
||||||
|
b["actual_hours"] = round(actual_map.get(str(b["domain_id"]), 0), 1)
|
||||||
|
b["weekly_hours_float"] = float(b["weekly_hours"])
|
||||||
|
if b["weekly_hours_float"] > 0:
|
||||||
|
b["pct"] = round(b["actual_hours"] / b["weekly_hours_float"] * 100)
|
||||||
|
else:
|
||||||
|
b["pct"] = 0
|
||||||
|
|
||||||
|
total_budgeted = sum(float(b["weekly_hours"]) for b in current_budgets)
|
||||||
|
overcommitted = total_budgeted > 168
|
||||||
|
|
||||||
|
# Also get all budgets (including future / historical) for full list
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT tb.*, d.name as domain_name, d.color as domain_color
|
||||||
|
FROM time_budgets tb
|
||||||
|
JOIN domains d ON tb.domain_id = d.id
|
||||||
|
WHERE tb.is_deleted = false AND d.is_deleted = false
|
||||||
|
ORDER BY tb.effective_from DESC, d.name
|
||||||
|
"""))
|
||||||
|
all_budgets = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
return templates.TemplateResponse("time_budgets.html", {
|
||||||
|
"request": request,
|
||||||
|
"sidebar": sidebar,
|
||||||
|
"current_budgets": current_budgets,
|
||||||
|
"all_budgets": all_budgets,
|
||||||
|
"total_budgeted": total_budgeted,
|
||||||
|
"overcommitted": overcommitted,
|
||||||
|
"count": len(all_budgets),
|
||||||
|
"page_title": "Time Budgets",
|
||||||
|
"active_nav": "time_budgets",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/create")
|
||||||
|
async def create_form(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
sidebar = await get_sidebar_data(db)
|
||||||
|
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT id, name, color FROM domains
|
||||||
|
WHERE is_deleted = false ORDER BY sort_order, name
|
||||||
|
"""))
|
||||||
|
domains = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
return templates.TemplateResponse("time_budgets_form.html", {
|
||||||
|
"request": request,
|
||||||
|
"sidebar": sidebar,
|
||||||
|
"budget": None,
|
||||||
|
"domains": domains,
|
||||||
|
"page_title": "New Time Budget",
|
||||||
|
"active_nav": "time_budgets",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/create")
|
||||||
|
async def create_budget(
|
||||||
|
request: Request,
|
||||||
|
domain_id: str = Form(...),
|
||||||
|
weekly_hours: str = Form(...),
|
||||||
|
effective_from: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("time_budgets", db)
|
||||||
|
data = {
|
||||||
|
"domain_id": domain_id,
|
||||||
|
"weekly_hours": float(weekly_hours),
|
||||||
|
"effective_from": effective_from,
|
||||||
|
}
|
||||||
|
budget = await repo.create(data)
|
||||||
|
return RedirectResponse(url="/time-budgets", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{budget_id}/edit")
|
||||||
|
async def edit_form(
|
||||||
|
request: Request,
|
||||||
|
budget_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
sidebar = await get_sidebar_data(db)
|
||||||
|
repo = BaseRepository("time_budgets", db)
|
||||||
|
budget = await repo.get(budget_id)
|
||||||
|
|
||||||
|
if not budget:
|
||||||
|
return RedirectResponse(url="/time-budgets", status_code=303)
|
||||||
|
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT id, name, color FROM domains
|
||||||
|
WHERE is_deleted = false ORDER BY sort_order, name
|
||||||
|
"""))
|
||||||
|
domains = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
return templates.TemplateResponse("time_budgets_form.html", {
|
||||||
|
"request": request,
|
||||||
|
"sidebar": sidebar,
|
||||||
|
"budget": budget,
|
||||||
|
"domains": domains,
|
||||||
|
"page_title": "Edit Time Budget",
|
||||||
|
"active_nav": "time_budgets",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{budget_id}/edit")
|
||||||
|
async def update_budget(
|
||||||
|
request: Request,
|
||||||
|
budget_id: str,
|
||||||
|
domain_id: str = Form(...),
|
||||||
|
weekly_hours: str = Form(...),
|
||||||
|
effective_from: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("time_budgets", db)
|
||||||
|
data = {
|
||||||
|
"domain_id": domain_id,
|
||||||
|
"weekly_hours": float(weekly_hours),
|
||||||
|
"effective_from": effective_from,
|
||||||
|
}
|
||||||
|
await repo.update(budget_id, data)
|
||||||
|
return RedirectResponse(url="/time-budgets", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{budget_id}/delete")
|
||||||
|
async def delete_budget(
|
||||||
|
request: Request,
|
||||||
|
budget_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
repo = BaseRepository("time_budgets", db)
|
||||||
|
await repo.soft_delete(budget_id)
|
||||||
|
return RedirectResponse(url="/time-budgets", status_code=303)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Weblinks: organized bookmark directory with recursive folders."""
|
"""Bookmarks: organized folder directory for links."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Form, Depends
|
from fastapi import APIRouter, Request, Form, Depends
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -11,12 +11,25 @@ from core.database import get_db
|
|||||||
from core.base_repository import BaseRepository
|
from core.base_repository import BaseRepository
|
||||||
from core.sidebar import get_sidebar_data
|
from core.sidebar import get_sidebar_data
|
||||||
|
|
||||||
router = APIRouter(prefix="/weblinks", tags=["weblinks"])
|
router = APIRouter(prefix="/weblinks", tags=["bookmarks"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_default_folder_id(db: AsyncSession) -> str:
|
||||||
|
"""Return the Default folder id, creating it if it doesn't exist."""
|
||||||
|
result = await db.execute(text(
|
||||||
|
"SELECT id FROM link_folders WHERE name = 'Default' AND is_deleted = false ORDER BY created_at LIMIT 1"
|
||||||
|
))
|
||||||
|
row = result.first()
|
||||||
|
if row:
|
||||||
|
return str(row[0])
|
||||||
|
repo = BaseRepository("link_folders", db)
|
||||||
|
folder = await repo.create({"name": "Default", "sort_order": 0})
|
||||||
|
return str(folder["id"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
async def list_weblinks(
|
async def list_bookmarks(
|
||||||
request: Request,
|
request: Request,
|
||||||
folder_id: Optional[str] = None,
|
folder_id: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -25,10 +38,10 @@ async def list_weblinks(
|
|||||||
|
|
||||||
# Get all folders for tree nav
|
# Get all folders for tree nav
|
||||||
result = await db.execute(text("""
|
result = await db.execute(text("""
|
||||||
SELECT wf.*, (SELECT count(*) FROM folder_weblinks fw WHERE fw.folder_id = wf.id) as link_count
|
SELECT lf.*, (SELECT count(*) FROM folder_links fl WHERE fl.folder_id = lf.id) as link_count
|
||||||
FROM weblink_folders wf
|
FROM link_folders lf
|
||||||
WHERE wf.is_deleted = false
|
WHERE lf.is_deleted = false
|
||||||
ORDER BY wf.sort_order, wf.name
|
ORDER BY lf.sort_order, lf.name
|
||||||
"""))
|
"""))
|
||||||
all_folders = [dict(r._mapping) for r in result]
|
all_folders = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
@@ -48,29 +61,41 @@ async def list_weblinks(
|
|||||||
current_folder = f
|
current_folder = f
|
||||||
break
|
break
|
||||||
|
|
||||||
# Get weblinks (filtered by folder or all unfiled)
|
# Get links (filtered by folder or all)
|
||||||
|
available_links = []
|
||||||
if folder_id:
|
if folder_id:
|
||||||
result = await db.execute(text("""
|
result = await db.execute(text("""
|
||||||
SELECT w.* FROM weblinks w
|
SELECT l.* FROM links l
|
||||||
JOIN folder_weblinks fw ON fw.weblink_id = w.id
|
JOIN folder_links fl ON fl.link_id = l.id
|
||||||
WHERE fw.folder_id = :fid AND w.is_deleted = false
|
WHERE fl.folder_id = :fid AND l.is_deleted = false
|
||||||
ORDER BY fw.sort_order, w.label
|
ORDER BY fl.sort_order, l.label
|
||||||
"""), {"fid": folder_id})
|
"""), {"fid": folder_id})
|
||||||
else:
|
items = [dict(r._mapping) for r in result]
|
||||||
# Show all weblinks
|
|
||||||
|
# Links NOT in this folder (for "add existing" dropdown)
|
||||||
result = await db.execute(text("""
|
result = await db.execute(text("""
|
||||||
SELECT w.* FROM weblinks w
|
SELECT l.id, l.label FROM links l
|
||||||
WHERE w.is_deleted = false
|
WHERE l.is_deleted = false
|
||||||
ORDER BY w.sort_order, w.label
|
AND l.id NOT IN (SELECT link_id FROM folder_links WHERE folder_id = :fid)
|
||||||
|
ORDER BY l.label
|
||||||
|
"""), {"fid": folder_id})
|
||||||
|
available_links = [dict(r._mapping) for r in result]
|
||||||
|
else:
|
||||||
|
# Show all links
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT l.* FROM links l
|
||||||
|
WHERE l.is_deleted = false
|
||||||
|
ORDER BY l.sort_order, l.label
|
||||||
"""))
|
"""))
|
||||||
items = [dict(r._mapping) for r in result]
|
items = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
return templates.TemplateResponse("weblinks.html", {
|
return templates.TemplateResponse("weblinks.html", {
|
||||||
"request": request, "sidebar": sidebar, "items": items,
|
"request": request, "sidebar": sidebar, "items": items,
|
||||||
"top_folders": top_folders, "child_folder_map": child_folder_map,
|
"top_folders": top_folders, "child_folder_map": child_folder_map,
|
||||||
"current_folder": current_folder,
|
"current_folder": current_folder,
|
||||||
"current_folder_id": folder_id or "",
|
"current_folder_id": folder_id or "",
|
||||||
"page_title": "Weblinks", "active_nav": "weblinks",
|
"available_links": available_links,
|
||||||
|
"page_title": "Bookmarks", "active_nav": "bookmarks",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -78,83 +103,97 @@ 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)
|
||||||
result = await db.execute(text(
|
result = await db.execute(text(
|
||||||
"SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name"
|
"SELECT id, name FROM link_folders WHERE is_deleted = false ORDER BY name"
|
||||||
))
|
))
|
||||||
folders = [dict(r._mapping) for r in result]
|
folders = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
return templates.TemplateResponse("weblink_form.html", {
|
return templates.TemplateResponse("weblink_form.html", {
|
||||||
"request": request, "sidebar": sidebar,
|
"request": request, "sidebar": sidebar,
|
||||||
"folders": folders,
|
"folders": folders,
|
||||||
"page_title": "New Weblink", "active_nav": "weblinks",
|
"page_title": "New Link", "active_nav": "bookmarks",
|
||||||
"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 "",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/create")
|
@router.post("/create")
|
||||||
async def create_weblink(
|
async def create_link(
|
||||||
request: Request,
|
request: Request,
|
||||||
label: str = Form(...),
|
label: str = Form(...),
|
||||||
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("links", 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()]
|
||||||
|
|
||||||
weblink = await repo.create(data)
|
link = await repo.create(data)
|
||||||
|
|
||||||
# Add to folder if specified
|
# Add to folder (default if none specified)
|
||||||
if folder_id and folder_id.strip():
|
effective_folder = folder_id if folder_id and folder_id.strip() else await get_default_folder_id(db)
|
||||||
await db.execute(text("""
|
await db.execute(text("""
|
||||||
INSERT INTO folder_weblinks (folder_id, weblink_id)
|
INSERT INTO folder_links (folder_id, link_id)
|
||||||
VALUES (:fid, :wid) ON CONFLICT DO NOTHING
|
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
|
||||||
"""), {"fid": folder_id, "wid": weblink["id"]})
|
"""), {"fid": effective_folder, "lid": link["id"]})
|
||||||
|
|
||||||
|
if task_id and task_id.strip():
|
||||||
|
return RedirectResponse(url=f"/tasks/{task_id}?tab=links", status_code=303)
|
||||||
|
if meeting_id and meeting_id.strip():
|
||||||
|
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=links", 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)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{weblink_id}/edit")
|
@router.get("/{link_id}/edit")
|
||||||
async def edit_form(weblink_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
async def edit_form(link_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
repo = BaseRepository("weblinks", db)
|
repo = BaseRepository("links", db)
|
||||||
sidebar = await get_sidebar_data(db)
|
sidebar = await get_sidebar_data(db)
|
||||||
item = await repo.get(weblink_id)
|
item = await repo.get(link_id)
|
||||||
if not item:
|
if not item:
|
||||||
return RedirectResponse(url="/weblinks", status_code=303)
|
return RedirectResponse(url="/weblinks", status_code=303)
|
||||||
|
|
||||||
result = await db.execute(text(
|
result = await db.execute(text(
|
||||||
"SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name"
|
"SELECT id, name FROM link_folders WHERE is_deleted = false ORDER BY name"
|
||||||
))
|
))
|
||||||
folders = [dict(r._mapping) for r in result]
|
folders = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
# Current folder assignment
|
# Current folder assignment
|
||||||
result = await db.execute(text(
|
result = await db.execute(text(
|
||||||
"SELECT folder_id FROM folder_weblinks WHERE weblink_id = :wid LIMIT 1"
|
"SELECT folder_id FROM folder_links WHERE link_id = :lid LIMIT 1"
|
||||||
), {"wid": weblink_id})
|
), {"lid": link_id})
|
||||||
row = result.first()
|
row = result.first()
|
||||||
current_folder_id = str(row[0]) if row else ""
|
current_folder_id = str(row[0]) if row else ""
|
||||||
|
|
||||||
return templates.TemplateResponse("weblink_form.html", {
|
return templates.TemplateResponse("weblink_form.html", {
|
||||||
"request": request, "sidebar": sidebar,
|
"request": request, "sidebar": sidebar,
|
||||||
"folders": folders,
|
"folders": folders,
|
||||||
"page_title": "Edit Weblink", "active_nav": "weblinks",
|
"page_title": "Edit Link", "active_nav": "bookmarks",
|
||||||
"item": item,
|
"item": item,
|
||||||
"prefill_folder_id": current_folder_id,
|
"prefill_folder_id": current_folder_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{weblink_id}/edit")
|
@router.post("/{link_id}/edit")
|
||||||
async def update_weblink(
|
async def update_link(
|
||||||
weblink_id: str,
|
link_id: str,
|
||||||
label: str = Form(...),
|
label: str = Form(...),
|
||||||
url: str = Form(...),
|
url: str = Form(...),
|
||||||
description: Optional[str] = Form(None),
|
description: Optional[str] = Form(None),
|
||||||
@@ -162,7 +201,7 @@ async def update_weblink(
|
|||||||
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("links", db)
|
||||||
data = {
|
data = {
|
||||||
"label": label, "url": url,
|
"label": label, "url": url,
|
||||||
"description": description if description and description.strip() else None,
|
"description": description if description and description.strip() else None,
|
||||||
@@ -172,41 +211,131 @@ async def update_weblink(
|
|||||||
else:
|
else:
|
||||||
data["tags"] = None
|
data["tags"] = None
|
||||||
|
|
||||||
await repo.update(weblink_id, data)
|
await repo.update(link_id, data)
|
||||||
|
|
||||||
# Update folder assignment
|
# Update folder assignment
|
||||||
await db.execute(text("DELETE FROM folder_weblinks WHERE weblink_id = :wid"), {"wid": weblink_id})
|
await db.execute(text("DELETE FROM folder_links WHERE link_id = :lid"), {"lid": link_id})
|
||||||
if folder_id and folder_id.strip():
|
if folder_id and folder_id.strip():
|
||||||
await db.execute(text("""
|
await db.execute(text("""
|
||||||
INSERT INTO folder_weblinks (folder_id, weblink_id)
|
INSERT INTO folder_links (folder_id, link_id)
|
||||||
VALUES (:fid, :wid) ON CONFLICT DO NOTHING
|
VALUES (:fid, :lid) ON CONFLICT DO NOTHING
|
||||||
"""), {"fid": folder_id, "wid": weblink_id})
|
"""), {"fid": folder_id, "lid": link_id})
|
||||||
|
|
||||||
return RedirectResponse(url="/weblinks", status_code=303)
|
return RedirectResponse(url="/weblinks", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{weblink_id}/delete")
|
@router.post("/{link_id}/delete")
|
||||||
async def delete_weblink(weblink_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
async def delete_link(link_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
repo = BaseRepository("weblinks", db)
|
repo = BaseRepository("links", db)
|
||||||
await repo.soft_delete(weblink_id)
|
await repo.soft_delete(link_id)
|
||||||
referer = request.headers.get("referer", "/weblinks")
|
referer = request.headers.get("referer", "/weblinks")
|
||||||
return RedirectResponse(url=referer, status_code=303)
|
return RedirectResponse(url=referer, status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Reorder links within a folder ----
|
||||||
|
|
||||||
|
@router.post("/folders/{folder_id}/reorder")
|
||||||
|
async def reorder_link(
|
||||||
|
folder_id: str,
|
||||||
|
link_id: str = Form(...),
|
||||||
|
direction: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
# Get all folder_links for this folder, ordered by sort_order then created_at
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT link_id, sort_order FROM folder_links
|
||||||
|
WHERE folder_id = :fid
|
||||||
|
ORDER BY sort_order, created_at
|
||||||
|
"""), {"fid": folder_id})
|
||||||
|
rows = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
|
||||||
|
|
||||||
|
# Lazy-initialize sort_order if all zeros
|
||||||
|
all_zero = all(r["sort_order"] == 0 for r in rows)
|
||||||
|
if all_zero:
|
||||||
|
for i, r in enumerate(rows):
|
||||||
|
await db.execute(text("""
|
||||||
|
UPDATE folder_links SET sort_order = :so
|
||||||
|
WHERE folder_id = :fid AND link_id = :lid
|
||||||
|
"""), {"so": (i + 1) * 10, "fid": folder_id, "lid": r["link_id"]})
|
||||||
|
r["sort_order"] = (i + 1) * 10
|
||||||
|
|
||||||
|
# Find target index
|
||||||
|
idx = None
|
||||||
|
for i, r in enumerate(rows):
|
||||||
|
if str(r["link_id"]) == link_id:
|
||||||
|
idx = i
|
||||||
|
break
|
||||||
|
|
||||||
|
if idx is None:
|
||||||
|
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
|
||||||
|
|
||||||
|
# Determine swap partner
|
||||||
|
if direction == "up" and idx > 0:
|
||||||
|
swap_idx = idx - 1
|
||||||
|
elif direction == "down" and idx < len(rows) - 1:
|
||||||
|
swap_idx = idx + 1
|
||||||
|
else:
|
||||||
|
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
|
||||||
|
|
||||||
|
# Swap sort_order values
|
||||||
|
so_a, so_b = rows[idx]["sort_order"], rows[swap_idx]["sort_order"]
|
||||||
|
lid_a, lid_b = rows[idx]["link_id"], rows[swap_idx]["link_id"]
|
||||||
|
|
||||||
|
await db.execute(text("""
|
||||||
|
UPDATE folder_links SET sort_order = :so
|
||||||
|
WHERE folder_id = :fid AND link_id = :lid
|
||||||
|
"""), {"so": so_b, "fid": folder_id, "lid": lid_a})
|
||||||
|
await db.execute(text("""
|
||||||
|
UPDATE folder_links SET sort_order = :so
|
||||||
|
WHERE folder_id = :fid AND link_id = :lid
|
||||||
|
"""), {"so": so_a, "fid": folder_id, "lid": lid_b})
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- Add existing link to folder ----
|
||||||
|
|
||||||
|
@router.post("/folders/{folder_id}/add-link")
|
||||||
|
async def add_link_to_folder(
|
||||||
|
folder_id: str,
|
||||||
|
link_id: str = Form(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
# Remove link from any existing folder (single-folder membership)
|
||||||
|
await db.execute(text("DELETE FROM folder_links WHERE link_id = :lid"), {"lid": link_id})
|
||||||
|
|
||||||
|
# Get max sort_order in target folder
|
||||||
|
result = await db.execute(text("""
|
||||||
|
SELECT COALESCE(MAX(sort_order), 0) FROM folder_links WHERE folder_id = :fid
|
||||||
|
"""), {"fid": folder_id})
|
||||||
|
max_so = result.scalar()
|
||||||
|
|
||||||
|
# Insert into target folder at end
|
||||||
|
await db.execute(text("""
|
||||||
|
INSERT INTO folder_links (folder_id, link_id, sort_order)
|
||||||
|
VALUES (:fid, :lid, :so) ON CONFLICT DO NOTHING
|
||||||
|
"""), {"fid": folder_id, "lid": link_id, "so": max_so + 10})
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/weblinks?folder_id={folder_id}", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
# ---- Folders ----
|
# ---- Folders ----
|
||||||
|
|
||||||
@router.get("/folders/create")
|
@router.get("/folders/create")
|
||||||
async def create_folder_form(request: Request, db: AsyncSession = Depends(get_db)):
|
async def create_folder_form(request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
sidebar = await get_sidebar_data(db)
|
sidebar = await get_sidebar_data(db)
|
||||||
result = await db.execute(text(
|
result = await db.execute(text(
|
||||||
"SELECT id, name FROM weblink_folders WHERE is_deleted = false ORDER BY name"
|
"SELECT id, name FROM link_folders WHERE is_deleted = false ORDER BY name"
|
||||||
))
|
))
|
||||||
parent_folders = [dict(r._mapping) for r in result]
|
parent_folders = [dict(r._mapping) for r in result]
|
||||||
|
|
||||||
return templates.TemplateResponse("weblink_folder_form.html", {
|
return templates.TemplateResponse("weblink_folder_form.html", {
|
||||||
"request": request, "sidebar": sidebar,
|
"request": request, "sidebar": sidebar,
|
||||||
"parent_folders": parent_folders,
|
"parent_folders": parent_folders,
|
||||||
"page_title": "New Folder", "active_nav": "weblinks",
|
"page_title": "New Folder", "active_nav": "bookmarks",
|
||||||
"item": None,
|
"item": None,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -218,7 +347,7 @@ async def create_folder(
|
|||||||
parent_id: Optional[str] = Form(None),
|
parent_id: Optional[str] = Form(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
repo = BaseRepository("weblink_folders", db)
|
repo = BaseRepository("link_folders", db)
|
||||||
data = {"name": name}
|
data = {"name": name}
|
||||||
if parent_id and parent_id.strip():
|
if parent_id and parent_id.strip():
|
||||||
data["parent_id"] = parent_id
|
data["parent_id"] = parent_id
|
||||||
@@ -228,6 +357,13 @@ async def create_folder(
|
|||||||
|
|
||||||
@router.post("/folders/{folder_id}/delete")
|
@router.post("/folders/{folder_id}/delete")
|
||||||
async def delete_folder(folder_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
async def delete_folder(folder_id: str, request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
repo = BaseRepository("weblink_folders", db)
|
# Prevent deleting the Default folder
|
||||||
|
result = await db.execute(text(
|
||||||
|
"SELECT name FROM link_folders WHERE id = :id"
|
||||||
|
), {"id": folder_id})
|
||||||
|
row = result.first()
|
||||||
|
if row and row[0] == "Default":
|
||||||
|
return RedirectResponse(url="/weblinks", status_code=303)
|
||||||
|
repo = BaseRepository("link_folders", db)
|
||||||
await repo.soft_delete(folder_id)
|
await repo.soft_delete(folder_id)
|
||||||
return RedirectResponse(url="/weblinks", status_code=303)
|
return RedirectResponse(url="/weblinks", status_code=303)
|
||||||
|
|||||||
@@ -170,13 +170,6 @@ function escHtml(s) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ---- Mobile More Panel ----
|
|
||||||
|
|
||||||
function toggleMobileMore() {
|
|
||||||
const panel = document.getElementById('mobile-more-panel');
|
|
||||||
if (panel) panel.classList.toggle('open');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Timer Pill (topbar running timer) ----
|
// ---- Timer Pill (topbar running timer) ----
|
||||||
|
|
||||||
let timerStartAt = null;
|
let timerStartAt = null;
|
||||||
@@ -241,3 +234,19 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
pollTimer();
|
pollTimer();
|
||||||
setInterval(pollTimer, 30000);
|
setInterval(pollTimer, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Mobile More Panel ----
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
var btn = document.getElementById('mobMoreBtn');
|
||||||
|
var panel = document.getElementById('mobMore');
|
||||||
|
var overlay = document.getElementById('mobOverlay');
|
||||||
|
if (!btn || !panel || !overlay) return;
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
panel.classList.toggle('open');
|
||||||
|
overlay.classList.toggle('open');
|
||||||
|
});
|
||||||
|
overlay.addEventListener('click', function() {
|
||||||
|
panel.classList.remove('open');
|
||||||
|
overlay.classList.remove('open');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
809
static/style.css
809
static/style.css
@@ -152,11 +152,11 @@ a:hover { color: var(--accent-hover); }
|
|||||||
.nav-item {
|
.nav-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
font-size: 0.80rem;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 0.92rem;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
@@ -201,7 +201,7 @@ a:hover { color: var(--accent-hover); }
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.80rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -233,7 +233,7 @@ a:hover { color: var(--accent-hover); }
|
|||||||
.project-link {
|
.project-link {
|
||||||
display: block;
|
display: block;
|
||||||
padding: 4px 10px 4px 18px;
|
padding: 4px 10px 4px 18px;
|
||||||
font-size: 0.85rem;
|
font-size: 0.80rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -369,6 +369,7 @@ a:hover { color: var(--accent-hover); }
|
|||||||
|
|
||||||
.btn-sm { padding: 5px 10px; font-size: 0.8rem; }
|
.btn-sm { padding: 5px 10px; font-size: 0.8rem; }
|
||||||
.btn-xs { padding: 3px 8px; font-size: 0.75rem; }
|
.btn-xs { padding: 3px 8px; font-size: 0.75rem; }
|
||||||
|
.btn-capture { min-height: 44px; font-size: 0.95rem; padding: 10px 20px; }
|
||||||
|
|
||||||
/* ---- Cards ---- */
|
/* ---- Cards ---- */
|
||||||
.card {
|
.card {
|
||||||
@@ -400,8 +401,8 @@ a:hover { color: var(--accent-hover); }
|
|||||||
.list-row {
|
.list-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
padding: 10px 12px;
|
padding: 6px 12px;
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
transition: background var(--transition);
|
transition: background var(--transition);
|
||||||
}
|
}
|
||||||
@@ -465,6 +466,7 @@ a:hover { color: var(--accent-hover); }
|
|||||||
|
|
||||||
.row-title {
|
.row-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
font-size: 0.80rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -561,6 +563,7 @@ a:hover { color: var(--accent-hover); }
|
|||||||
.form-input,
|
.form-input,
|
||||||
.form-select,
|
.form-select,
|
||||||
.form-textarea {
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
font-family: var(--font-body);
|
font-family: var(--font-body);
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
padding: 9px 12px;
|
padding: 9px 12px;
|
||||||
@@ -748,6 +751,25 @@ a:hover { color: var(--accent-hover); }
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-card-link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-link .stat-card {
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-link:hover .stat-card {
|
||||||
|
border-color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card-link:hover {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -771,17 +793,15 @@ a:hover { color: var(--accent-hover); }
|
|||||||
.capture-item {
|
.capture-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
padding: 12px;
|
padding: 6px 12px;
|
||||||
background: var(--surface);
|
border-bottom: 1px solid var(--border);
|
||||||
border: 1px solid var(--border);
|
transition: background var(--transition);
|
||||||
border-radius: var(--radius);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.capture-text {
|
.capture-text {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 0.92rem;
|
font-size: 0.80rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.capture-actions {
|
.capture-actions {
|
||||||
@@ -794,22 +814,32 @@ a:hover { color: var(--accent-hover); }
|
|||||||
.focus-item {
|
.focus-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
padding: 14px 16px;
|
padding: 6px 12px;
|
||||||
background: var(--surface);
|
border-bottom: 1px solid var(--border);
|
||||||
border: 1px solid var(--border);
|
transition: background var(--transition);
|
||||||
border-radius: var(--radius);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
transition: all var(--transition);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.focus-item:hover { border-color: var(--accent); }
|
.focus-item:hover { background: var(--surface2); }
|
||||||
.focus-item.completed { opacity: 0.6; }
|
.focus-item.completed { opacity: 0.6; }
|
||||||
.focus-item.completed .focus-title { text-decoration: line-through; }
|
.focus-item.completed .focus-title { text-decoration: line-through; }
|
||||||
|
|
||||||
.focus-title { flex: 1; font-weight: 500; }
|
.focus-title { flex: 1; font-size: 0.80rem; font-weight: 500; }
|
||||||
.focus-meta { font-size: 0.78rem; color: var(--muted); }
|
.focus-meta { font-size: 0.78rem; color: var(--muted); }
|
||||||
|
|
||||||
|
/* ---- Alerts ---- */
|
||||||
|
.alert {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.alert-warning {
|
||||||
|
background: var(--amber-soft);
|
||||||
|
color: var(--amber);
|
||||||
|
border: 1px solid var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Utility ---- */
|
/* ---- Utility ---- */
|
||||||
.text-muted { color: var(--muted); }
|
.text-muted { color: var(--muted); }
|
||||||
.text-sm { font-size: 0.82rem; }
|
.text-sm { font-size: 0.82rem; }
|
||||||
@@ -857,6 +887,51 @@ a:hover { color: var(--accent-hover); }
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Quick Capture (Topbar) ---- */
|
||||||
|
.quick-capture-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-capture-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
pointer-events: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-capture-input {
|
||||||
|
width: 250px;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 6px 12px 6px 26px;
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-capture-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-capture-input::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-capture-submit {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Search Modal ---- */
|
/* ---- Search Modal ---- */
|
||||||
.search-modal {
|
.search-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -1013,136 +1088,156 @@ a:hover { color: var(--accent-hover); }
|
|||||||
.weblink-folder-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 500; }
|
.weblink-folder-item.active { background: var(--accent-soft); color: var(--accent); font-weight: 500; }
|
||||||
.weblinks-content { flex: 1; min-width: 0; }
|
.weblinks-content { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
/* ---- Project Progress Mini Bar (dashboard) ---- */
|
||||||
|
.project-progress-mini {
|
||||||
|
width: 60px;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.project-progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--green);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Reorder Grip Handle ---- */
|
||||||
|
.reorder-grip {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0;
|
||||||
|
margin-right: 4px;
|
||||||
|
opacity: 0.3;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.reorder-grip:hover { opacity: 0.9; }
|
||||||
|
.reorder-grip form { display: block; margin: 0; padding: 0; line-height: 0; }
|
||||||
|
.grip-btn {
|
||||||
|
display: block;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 10px;
|
||||||
|
width: 12px;
|
||||||
|
height: 10px;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 10px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.grip-btn:hover { color: var(--accent); }
|
||||||
|
|
||||||
/* ---- Scrollbar ---- */
|
/* ---- Scrollbar ---- */
|
||||||
::-webkit-scrollbar { width: 6px; }
|
::-webkit-scrollbar { width: 6px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
|
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
|
||||||
|
|
||||||
/* ---- Mobile Bottom Nav ---- */
|
|
||||||
.mobile-bottom-bar {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 56px;
|
|
||||||
background: var(--surface);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
z-index: 200;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-around;
|
|
||||||
padding: 0 4px;
|
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-nav-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 2px;
|
|
||||||
flex: 1;
|
|
||||||
padding: 6px 0;
|
|
||||||
color: var(--muted);
|
|
||||||
text-decoration: none;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color var(--transition);
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-nav-item svg {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-nav-item span {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-nav-item.active {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile More Panel */
|
|
||||||
.mobile-more-panel {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 199;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-more-panel.open {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-more-backdrop {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-more-content {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 56px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
background: var(--surface);
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
|
||||||
padding: 8px 0;
|
|
||||||
transform: translateY(100%);
|
|
||||||
transition: transform 200ms ease;
|
|
||||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-more-panel.open .mobile-more-content {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-more-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 20px;
|
|
||||||
color: var(--text);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: background var(--transition);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-more-item:hover {
|
|
||||||
background: var(--surface2);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-more-item svg {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
color: var(--muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Responsive ---- */
|
/* ---- Responsive ---- */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
body { padding-bottom: 60px; }
|
||||||
.sidebar { display: none; }
|
.sidebar { display: none; }
|
||||||
.main-content { margin-left: 0; }
|
.main-content { margin-left: 0; }
|
||||||
.form-grid { grid-template-columns: 1fr; }
|
.form-grid { grid-template-columns: 1fr; }
|
||||||
.dashboard-grid { grid-template-columns: 1fr; }
|
.dashboard-grid { grid-template-columns: 1fr; }
|
||||||
.page-content { padding: 16px; padding-bottom: 72px; }
|
.page-content { padding: 16px; padding-bottom: 72px; }
|
||||||
.mobile-bottom-bar { display: flex; }
|
/* Topbar: stack vertically on mobile */
|
||||||
|
.topbar {
|
||||||
|
height: auto;
|
||||||
|
min-height: auto;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 8px 12px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-env {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-capture-form {
|
||||||
|
order: unset;
|
||||||
|
width: 100%;
|
||||||
|
flex: unset;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-capture-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-capture-input {
|
||||||
|
flex: 1;
|
||||||
|
width: auto;
|
||||||
|
height: 36px;
|
||||||
|
min-height: auto;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-capture-submit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-capture-submit:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-trigger {
|
||||||
|
width: 100%;
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Capture form mobile */
|
||||||
|
.capture-form-card { padding: 12px; }
|
||||||
|
.capture-form-card .form-textarea {
|
||||||
|
font-size: 16px; /* prevents iOS zoom */
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
.capture-form-card .btn-capture {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Capture items stack on mobile */
|
||||||
|
.capture-item {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.capture-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.search-type-appointments { background: var(--amber-soft); color: var(--amber); }
|
.search-type-appointments { background: var(--amber-soft); color: var(--amber); }
|
||||||
|
|
||||||
@@ -1223,3 +1318,441 @@ a:hover { color: var(--accent-hover); }
|
|||||||
.timer-detail-stop:hover {
|
.timer-detail-stop:hover {
|
||||||
background: var(--red, #ef4444)33;
|
background: var(--red, #ef4444)33;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Calendar View ---- */
|
||||||
|
.cal-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-month-label {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
min-width: 180px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-dot-appointment { background: var(--amber); }
|
||||||
|
.cal-dot-meeting { background: var(--purple); }
|
||||||
|
.cal-dot-task { background: var(--accent); }
|
||||||
|
|
||||||
|
.cal-grid {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-header-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-header-cell {
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-week-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-week-row + .cal-week-row {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-cell {
|
||||||
|
padding: 4px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
min-height: 100px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-empty {
|
||||||
|
background: var(--surface2);
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-today {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-num {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 2px 4px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-today .cal-day-num {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-events {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event {
|
||||||
|
display: block;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-decoration: none;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
transition: opacity var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event-appointment {
|
||||||
|
background: var(--amber-soft);
|
||||||
|
color: var(--amber);
|
||||||
|
border-left: 2px solid var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event-meeting {
|
||||||
|
background: var(--purple-soft);
|
||||||
|
color: var(--purple);
|
||||||
|
border-left: 2px solid var(--purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event-task {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event-time {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event-title {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cal-week-row { min-height: 60px; }
|
||||||
|
.cal-day-cell { min-height: 60px; padding: 2px; }
|
||||||
|
.cal-day-empty { min-height: 60px; }
|
||||||
|
.cal-day-num { font-size: 0.72rem; }
|
||||||
|
.cal-event { font-size: 0.65rem; padding: 1px 2px; }
|
||||||
|
.cal-event-time { display: none; }
|
||||||
|
.cal-month-label { font-size: 1rem; min-width: 140px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Eisenhower Matrix ---- */
|
||||||
|
.eisenhower-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 32px 1fr 1fr;
|
||||||
|
grid-template-rows: auto auto auto;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eisenhower-y-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
writing-mode: vertical-lr;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eisenhower-x-spacer {
|
||||||
|
/* empty cell under the y-labels */
|
||||||
|
}
|
||||||
|
|
||||||
|
.eisenhower-x-label {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eisenhower-quadrant {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eisenhower-q1 { border-top: 3px solid var(--red); }
|
||||||
|
.eisenhower-q2 { border-top: 3px solid var(--accent); }
|
||||||
|
.eisenhower-q3 { border-top: 3px solid var(--amber); }
|
||||||
|
.eisenhower-q4 { border-top: 3px solid var(--muted); }
|
||||||
|
|
||||||
|
.eisenhower-quadrant-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eisenhower-quadrant-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eisenhower-quadrant-subtitle {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eisenhower-task-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eisenhower-task {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eisenhower-task:hover {
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eisenhower-task-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eisenhower-task-due {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eisenhower-task-due.overdue {
|
||||||
|
color: var(--red);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eisenhower-task-project {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--surface2);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eisenhower-empty {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-red { background: var(--red); color: #fff; }
|
||||||
|
.badge-amber { background: var(--amber); color: #fff; }
|
||||||
|
.badge-accent { background: var(--accent); color: #fff; }
|
||||||
|
.badge-muted { background: var(--muted); color: #fff; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.eisenhower-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: auto;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
.eisenhower-y-label,
|
||||||
|
.eisenhower-x-label,
|
||||||
|
.eisenhower-x-spacer { display: none; }
|
||||||
|
.eisenhower-quadrant { min-height: 120px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === MOBILE NAV BAR === */
|
||||||
|
.mob-nav {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mob-nav {
|
||||||
|
display: flex !important;
|
||||||
|
position: fixed !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
height: 56px !important;
|
||||||
|
background: var(--surface) !important;
|
||||||
|
border-top: 1px solid var(--border) !important;
|
||||||
|
z-index: 9999 !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: space-around !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-nav__item {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
flex: 1 1 0 !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 20% !important;
|
||||||
|
height: 56px !important;
|
||||||
|
padding: 6px 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
color: var(--muted) !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
background: none !important;
|
||||||
|
border: none !important;
|
||||||
|
font-family: inherit !important;
|
||||||
|
font-size: 10px !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
line-height: 1 !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
-webkit-tap-highlight-color: transparent !important;
|
||||||
|
gap: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-nav__item svg {
|
||||||
|
width: 22px !important;
|
||||||
|
height: 22px !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-nav__item span {
|
||||||
|
font-size: 10px !important;
|
||||||
|
line-height: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-nav__item--active {
|
||||||
|
color: var(--accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-overlay {
|
||||||
|
display: none !important;
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
background: rgba(0,0,0,0.5) !important;
|
||||||
|
z-index: 10000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-overlay.open {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-more {
|
||||||
|
display: none !important;
|
||||||
|
position: fixed !important;
|
||||||
|
bottom: 56px !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
background: var(--surface) !important;
|
||||||
|
border-top: 1px solid var(--border) !important;
|
||||||
|
border-radius: 12px 12px 0 0 !important;
|
||||||
|
z-index: 10001 !important;
|
||||||
|
padding: 16px !important;
|
||||||
|
grid-template-columns: repeat(3, 1fr) !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-more.open {
|
||||||
|
display: grid !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-more__item {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
gap: 6px !important;
|
||||||
|
padding: 12px 4px !important;
|
||||||
|
color: var(--text) !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
font-size: 0.78rem !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-more__item:hover {
|
||||||
|
background: var(--surface2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mob-more__item svg {
|
||||||
|
width: 22px !important;
|
||||||
|
height: 22px !important;
|
||||||
|
color: var(--muted) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
{% set appt_date = appt.start_at.strftime('%A, %B %-d, %Y') if appt.start_at else 'No Date' %}
|
{% set appt_date = appt.start_at.strftime('%A, %B %-d, %Y') if appt.start_at else 'No Date' %}
|
||||||
{% if appt_date != current_date.value %}
|
{% if appt_date != current_date.value %}
|
||||||
{% if not loop.first %}</div>{% endif %}
|
{% if not loop.first %}</div>{% endif %}
|
||||||
<div class="date-group-label" style="padding: 12px 12px 4px; font-size: 0.78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; {% if not loop.first %}border-top: 1px solid var(--border); margin-top: 4px;{% endif %}">
|
<div class="date-group-label" style="padding: 6px 12px 4px; font-size: 0.78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; {% if not loop.first %}border-top: 1px solid var(--border); margin-top: 4px;{% endif %}">
|
||||||
{{ appt_date }}
|
{{ appt_date }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -30,6 +30,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
|
{% with reorder_url="/appointments/reorder", item_id=appt.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
<div style="flex-shrink: 0; min-width: 60px;">
|
<div style="flex-shrink: 0; min-width: 60px;">
|
||||||
{% if appt.all_day %}
|
{% if appt.all_day %}
|
||||||
<span class="status-badge status-active" style="font-size: 0.72rem;">All Day</span>
|
<span class="status-badge status-active" style="font-size: 0.72rem;">All Day</span>
|
||||||
|
|||||||
@@ -5,6 +5,17 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{ page_title or "Life OS" }} - Life OS</title>
|
<title>{{ page_title or "Life OS" }} - Life OS</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<style>
|
||||||
|
/* Critical mob-nav positioning - inline to guarantee it loads */
|
||||||
|
.mob-nav{display:none}
|
||||||
|
@media(max-width:768px){
|
||||||
|
.mob-nav{display:flex!important;position:fixed!important;bottom:0!important;left:0!important;right:0!important;height:56px!important;background:var(--surface)!important;border-top:1px solid var(--border)!important;z-index:9999!important;flex-direction:row!important;flex-wrap:nowrap!important;align-items:center!important;justify-content:space-around!important;padding:0!important;margin:0!important;overflow:hidden!important}
|
||||||
|
.mob-overlay{display:none!important;position:fixed!important;top:0!important;left:0!important;right:0!important;bottom:0!important;background:rgba(0,0,0,.5)!important;z-index:10000!important}
|
||||||
|
.mob-overlay.open{display:block!important}
|
||||||
|
.mob-more{display:none!important;position:fixed!important;bottom:56px!important;left:0!important;right:0!important;background:var(--surface)!important;border-top:1px solid var(--border)!important;border-radius:12px 12px 0 0!important;z-index:10001!important;padding:16px!important;grid-template-columns:repeat(3,1fr)!important;gap:8px!important}
|
||||||
|
.mob-more.open{display:grid!important}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-layout">
|
<div class="app-layout">
|
||||||
@@ -48,6 +59,10 @@
|
|||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||||||
Lists
|
Lists
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/processes" class="nav-item {{ 'active' if active_nav == 'processes' }}">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
Processes
|
||||||
|
</a>
|
||||||
<a href="/meetings" class="nav-item {{ 'active' if active_nav == 'meetings' }}">
|
<a href="/meetings" class="nav-item {{ 'active' if active_nav == 'meetings' }}">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||||
Meetings
|
Meetings
|
||||||
@@ -56,6 +71,14 @@
|
|||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||||
Appointments
|
Appointments
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/calendar" class="nav-item {{ 'active' if active_nav == 'calendar' }}">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/><rect x="7" y="14" width="3" height="3" rx="0.5"/><rect x="14" y="14" width="3" height="3" rx="0.5"/></svg>
|
||||||
|
Calendar
|
||||||
|
</a>
|
||||||
|
<a href="/eisenhower" class="nav-item {{ 'active' if active_nav == 'eisenhower' }}">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg>
|
||||||
|
Eisenhower
|
||||||
|
</a>
|
||||||
<a href="/decisions" class="nav-item {{ 'active' if active_nav == 'decisions' }}">
|
<a href="/decisions" class="nav-item {{ 'active' if active_nav == 'decisions' }}">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M9 12l2 2 4-4"/></svg>
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M9 12l2 2 4-4"/></svg>
|
||||||
Decisions
|
Decisions
|
||||||
@@ -64,14 +87,18 @@
|
|||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>
|
||||||
Files
|
Files
|
||||||
</a>
|
</a>
|
||||||
<a href="/weblinks" class="nav-item {{ 'active' if active_nav == 'weblinks' }}">
|
<a href="/weblinks" class="nav-item {{ 'active' if active_nav == 'bookmarks' }}">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||||
Weblinks
|
Bookmarks
|
||||||
</a>
|
</a>
|
||||||
<a href="/time" class="nav-item {{ 'active' if active_nav == 'time' }}">
|
<a href="/time" class="nav-item {{ 'active' if active_nav == 'time' }}">
|
||||||
<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>
|
<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>
|
||||||
Time Log
|
Time Log
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/time-budgets" class="nav-item {{ 'active' if active_nav == 'time_budgets' }}">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/><path d="M16 3l2 2-2 2"/></svg>
|
||||||
|
Time Budgets
|
||||||
|
</a>
|
||||||
<a href="/capture" class="nav-item {{ 'active' if active_nav == 'capture' }}">
|
<a href="/capture" class="nav-item {{ 'active' if active_nav == 'capture' }}">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
|
||||||
Capture
|
Capture
|
||||||
@@ -104,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
|
||||||
@@ -140,6 +171,12 @@
|
|||||||
<button type="submit" class="timer-pill-stop" title="Stop timer">■</button>
|
<button type="submit" class="timer-pill-stop" title="Stop timer">■</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<form class="quick-capture-form" method="POST" action="/capture/add">
|
||||||
|
<input type="hidden" name="redirect_to" value="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}">
|
||||||
|
<span class="quick-capture-icon">+</span>
|
||||||
|
<input type="text" name="raw_text" class="quick-capture-input" placeholder="Quick capture..." autocomplete="off" required>
|
||||||
|
<button type="submit" class="quick-capture-submit" title="Capture">+</button>
|
||||||
|
</form>
|
||||||
<button class="search-trigger" onclick="openSearch()" title="Search (Cmd/K)">
|
<button class="search-trigger" onclick="openSearch()" title="Search (Cmd/K)">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
<span>Search...</span>
|
<span>Search...</span>
|
||||||
@@ -165,61 +202,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Bottom Nav -->
|
|
||||||
<nav class="mobile-bottom-bar">
|
|
||||||
<a href="/" class="mobile-nav-item {% if active_nav == 'dashboard' %}active{% endif %}">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
|
||||||
<span>Dashboard</span>
|
|
||||||
</a>
|
|
||||||
<a href="/focus" class="mobile-nav-item {% if active_nav == 'focus' %}active{% endif %}">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
|
|
||||||
<span>Focus</span>
|
|
||||||
</a>
|
|
||||||
<a href="/tasks" class="mobile-nav-item {% if active_nav == 'tasks' %}active{% endif %}">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
|
||||||
<span>Tasks</span>
|
|
||||||
</a>
|
|
||||||
<a href="/appointments" class="mobile-nav-item {% if active_nav == 'appointments' %}active{% endif %}">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
|
||||||
<span>Calendar</span>
|
|
||||||
</a>
|
|
||||||
<button class="mobile-nav-item" id="mobile-more-btn" onclick="toggleMobileMore()">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
|
||||||
<span>More</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Mobile More Panel -->
|
|
||||||
<div id="mobile-more-panel" class="mobile-more-panel">
|
|
||||||
<div class="mobile-more-backdrop" onclick="toggleMobileMore()"></div>
|
|
||||||
<div class="mobile-more-content">
|
|
||||||
<a href="/notes" class="mobile-more-item">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
|
|
||||||
Notes
|
|
||||||
</a>
|
|
||||||
<a href="/meetings" class="mobile-more-item">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
|
||||||
Meetings
|
|
||||||
</a>
|
|
||||||
<a href="/decisions" class="mobile-more-item">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"/><path d="M9 12l2 2 4-4"/></svg>
|
|
||||||
Decisions
|
|
||||||
</a>
|
|
||||||
<a href="/contacts" class="mobile-more-item">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
|
||||||
Contacts
|
|
||||||
</a>
|
|
||||||
<a href="/weblinks" class="mobile-more-item">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
|
||||||
Weblinks
|
|
||||||
</a>
|
|
||||||
<a href="/admin/trash" class="mobile-more-item">
|
|
||||||
<svg 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>
|
|
||||||
Admin
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
|
|
||||||
|
<div class="mob-nav" id="mobNav" style="position:fixed;bottom:0;left:0;right:0;z-index:9999">
|
||||||
|
<a href="/" class="mob-nav__item {% if request.url.path == '/' %}mob-nav__item--active{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
|
||||||
|
<span>Home</span>
|
||||||
|
</a>
|
||||||
|
<a href="/focus" class="mob-nav__item {% if '/focus' in request.url.path %}mob-nav__item--active{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>
|
||||||
|
<span>Focus</span>
|
||||||
|
</a>
|
||||||
|
<a href="/tasks" class="mob-nav__item {% if '/tasks' in request.url.path %}mob-nav__item--active{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
|
||||||
|
<span>Tasks</span>
|
||||||
|
</a>
|
||||||
|
<a href="/capture" class="mob-nav__item {% if '/capture' in request.url.path %}mob-nav__item--active{% endif %}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
|
||||||
|
<span>Capture</span>
|
||||||
|
</a>
|
||||||
|
<button class="mob-nav__item" id="mobMoreBtn" type="button">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||||
|
<span>More</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mob-overlay" id="mobOverlay"></div>
|
||||||
|
<div class="mob-more" id="mobMore">
|
||||||
|
<a href="/calendar" class="mob-more__item"><span>Calendar</span></a>
|
||||||
|
<a href="/notes" class="mob-more__item"><span>Notes</span></a>
|
||||||
|
<a href="/meetings" class="mob-more__item"><span>Meetings</span></a>
|
||||||
|
<a href="/decisions" class="mob-more__item"><span>Decisions</span></a>
|
||||||
|
<a href="/contacts" class="mob-more__item"><span>Contacts</span></a>
|
||||||
|
<a href="/processes" class="mob-more__item"><span>Processes</span></a>
|
||||||
|
<a href="/weblinks" class="mob-more__item"><span>Bookmarks</span></a>
|
||||||
|
<a href="/admin" class="mob-more__item"><span>Admin</span></a>
|
||||||
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
58
templates/calendar.html
Normal file
58
templates/calendar.html
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Calendar</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Month Navigation -->
|
||||||
|
<div class="cal-nav">
|
||||||
|
<a href="/calendar?year={{ prev_year }}&month={{ prev_month }}" class="btn btn-secondary btn-sm">← Prev</a>
|
||||||
|
<span class="cal-month-label">{{ month_name }} {{ year }}</span>
|
||||||
|
<a href="/calendar?year={{ next_year }}&month={{ next_month }}" class="btn btn-secondary btn-sm">Next →</a>
|
||||||
|
{% if year != today.year or month != today.month %}
|
||||||
|
<a href="/calendar" class="btn btn-ghost btn-sm">Today</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="cal-legend">
|
||||||
|
<span class="cal-legend-item"><span class="cal-dot cal-dot-appointment"></span> Appointment</span>
|
||||||
|
<span class="cal-legend-item"><span class="cal-dot cal-dot-meeting"></span> Meeting</span>
|
||||||
|
<span class="cal-legend-item"><span class="cal-dot cal-dot-task"></span> Task</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar Grid -->
|
||||||
|
<div class="cal-grid">
|
||||||
|
<div class="cal-header-row">
|
||||||
|
<div class="cal-header-cell">Sun</div>
|
||||||
|
<div class="cal-header-cell">Mon</div>
|
||||||
|
<div class="cal-header-cell">Tue</div>
|
||||||
|
<div class="cal-header-cell">Wed</div>
|
||||||
|
<div class="cal-header-cell">Thu</div>
|
||||||
|
<div class="cal-header-cell">Fri</div>
|
||||||
|
<div class="cal-header-cell">Sat</div>
|
||||||
|
</div>
|
||||||
|
{% for week in weeks %}
|
||||||
|
<div class="cal-week-row">
|
||||||
|
{% for day in week %}
|
||||||
|
<div class="cal-day-cell {{ 'cal-day-empty' if day == 0 }} {{ 'cal-day-today' if day > 0 and today.year == year and today.month == month and today.day == day }}">
|
||||||
|
{% if day > 0 %}
|
||||||
|
<div class="cal-day-num">{{ day }}</div>
|
||||||
|
<div class="cal-events">
|
||||||
|
{% for event in days_map.get(day, []) %}
|
||||||
|
<a href="{{ event.url }}" class="cal-event cal-event-{{ event.type }}" title="{{ event.title }}">
|
||||||
|
{% if event.time %}<span class="cal-event-time">{{ event.time }}</span>{% endif %}
|
||||||
|
<span class="cal-event-title">{{ event.title }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -5,10 +5,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick capture input -->
|
<!-- Quick capture input -->
|
||||||
<div class="card mb-4">
|
<div class="card mb-4 capture-form-card">
|
||||||
<form action="/capture/add" method="post">
|
<form action="/capture/add" method="post">
|
||||||
<label class="form-label mb-2">Quick Capture (one item per line)</label>
|
<label class="form-label mb-2">Quick Capture (one item per line)</label>
|
||||||
<textarea name="raw_text" class="form-textarea" rows="3" placeholder="Type or paste items here... Each line becomes a separate capture item" required></textarea>
|
<textarea name="raw_text" class="form-textarea" rows="3" placeholder="Type or paste to capture..." required></textarea>
|
||||||
<details class="mt-2">
|
<details class="mt-2">
|
||||||
<summary class="text-sm text-muted" style="cursor:pointer">Context (optional)</summary>
|
<summary class="text-sm text-muted" style="cursor:pointer">Context (optional)</summary>
|
||||||
<div class="form-grid mt-2" style="grid-template-columns:1fr 1fr">
|
<div class="form-grid mt-2" style="grid-template-columns:1fr 1fr">
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<div class="mt-2"><button type="submit" class="btn btn-primary">Capture</button></div>
|
<div class="mt-2"><button type="submit" class="btn btn-primary btn-capture">Capture</button></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -53,11 +53,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if items %}
|
{% if items %}
|
||||||
|
<div class="card">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
|
|
||||||
{# Batch header #}
|
{# Batch header #}
|
||||||
{% if item._batch_first and item.import_batch_id %}
|
{% if item._batch_first and item.import_batch_id %}
|
||||||
<div class="flex items-center justify-between mb-2 mt-3" style="padding:6px 12px;background:var(--surface2);border-radius:var(--radius-sm)">
|
<div class="flex items-center justify-between" style="padding:6px 12px;background:var(--surface2);">
|
||||||
<span class="text-xs text-muted">Batch · {{ batches[item.import_batch_id|string] }} items</span>
|
<span class="text-xs text-muted">Batch · {{ batches[item.import_batch_id|string] }} items</span>
|
||||||
<form action="/capture/batch/{{ item.import_batch_id }}/undo" method="post" style="display:inline" data-confirm="Delete all items in this batch?">
|
<form action="/capture/batch/{{ item.import_batch_id }}/undo" method="post" style="display:inline" data-confirm="Delete all items in this batch?">
|
||||||
<button class="btn btn-ghost btn-xs" style="color:var(--red)">Undo batch</button>
|
<button class="btn btn-ghost btn-xs" style="color:var(--red)">Undo batch</button>
|
||||||
@@ -75,8 +76,8 @@
|
|||||||
{% if item.converted_to_id %}
|
{% if item.converted_to_id %}
|
||||||
{% if item.converted_to_type == 'list_item' and item.list_id %}
|
{% if item.converted_to_type == 'list_item' and item.list_id %}
|
||||||
<a href="/lists/{{ item.list_id }}" class="btn btn-ghost btn-xs">View →</a>
|
<a href="/lists/{{ item.list_id }}" class="btn btn-ghost btn-xs">View →</a>
|
||||||
{% elif item.converted_to_type == 'weblink' %}
|
{% elif item.converted_to_type == 'link' %}
|
||||||
<a href="/weblinks" class="btn btn-ghost btn-xs">View →</a>
|
<a href="/links" class="btn btn-ghost btn-xs">View →</a>
|
||||||
{% elif item.converted_to_type in ('task', 'note', 'project', 'contact', 'decision') %}
|
{% elif item.converted_to_type in ('task', 'note', 'project', 'contact', 'decision') %}
|
||||||
<a href="/{{ item.converted_to_type }}s/{{ item.converted_to_id }}" class="btn btn-ghost btn-xs">View →</a>
|
<a href="/{{ item.converted_to_type }}s/{{ item.converted_to_id }}" class="btn btn-ghost btn-xs">View →</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -99,6 +100,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">📥</div>
|
<div class="empty-state-icon">📥</div>
|
||||||
|
|||||||
@@ -132,7 +132,7 @@
|
|||||||
<input type="text" name="title" class="form-input" value="{{ item.raw_text }}" required>
|
<input type="text" name="title" class="form-input" value="{{ item.raw_text }}" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% elif convert_type == 'weblink' %}
|
{% elif convert_type == 'link' %}
|
||||||
<div class="form-group full-width">
|
<div class="form-group full-width">
|
||||||
<label class="form-label">Label</label>
|
<label class="form-label">Label</label>
|
||||||
<input type="text" name="label" class="form-input" value="{{ prefill_label }}" required>
|
<input type="text" name="label" class="form-input" value="{{ prefill_label }}" required>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
|
{% with reorder_url="/contacts/reorder", item_id=item.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
<span class="row-title"><a href="/contacts/{{ item.id }}">{{ item.first_name }} {{ item.last_name or '' }}</a></span>
|
<span class="row-title"><a href="/contacts/{{ item.id }}">{{ item.first_name }} {{ item.last_name or '' }}</a></span>
|
||||||
{% if item.company %}<span class="row-tag">{{ item.company }}</span>{% endif %}
|
{% if item.company %}<span class="row-tag">{{ item.company }}</span>{% endif %}
|
||||||
{% if item.role %}<span class="row-meta">{{ item.role }}</span>{% endif %}
|
{% if item.role %}<span class="row-meta">{{ item.role }}</span>{% endif %}
|
||||||
|
|||||||
@@ -6,22 +6,30 @@
|
|||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="dashboard-grid mb-4">
|
<div class="dashboard-grid mb-4">
|
||||||
<div class="stat-card">
|
<a href="/tasks/?status=open" class="stat-card-link">
|
||||||
<div class="stat-value">{{ stats.open_tasks or 0 }}</div>
|
<div class="stat-card">
|
||||||
<div class="stat-label">Open Tasks</div>
|
<div class="stat-value">{{ stats.open_tasks or 0 }}</div>
|
||||||
</div>
|
<div class="stat-label">Open Tasks</div>
|
||||||
<div class="stat-card">
|
</div>
|
||||||
<div class="stat-value">{{ stats.in_progress or 0 }}</div>
|
</a>
|
||||||
<div class="stat-label">In Progress</div>
|
<a href="/tasks/?status=in_progress" class="stat-card-link">
|
||||||
</div>
|
<div class="stat-card">
|
||||||
<div class="stat-card">
|
<div class="stat-value">{{ stats.in_progress or 0 }}</div>
|
||||||
<div class="stat-value">{{ stats.done_this_week or 0 }}</div>
|
<div class="stat-label">In Progress</div>
|
||||||
<div class="stat-label">Done This Week</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
<div class="stat-card">
|
<a href="/tasks/?status=done" class="stat-card-link">
|
||||||
<div class="stat-value">{{ focus_items|length }}</div>
|
<div class="stat-card">
|
||||||
<div class="stat-label">Today's Focus</div>
|
<div class="stat-value">{{ stats.done_this_week or 0 }}</div>
|
||||||
</div>
|
<div class="stat-label">Done This Week</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<a href="/focus/" class="stat-card-link">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{{ focus_items|length }}</div>
|
||||||
|
<div class="stat-label">Today's Focus</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dashboard-grid">
|
<div class="dashboard-grid">
|
||||||
@@ -83,4 +91,50 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Project Deadlines -->
|
||||||
|
{% if overdue_projects or upcoming_projects %}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">Project Deadlines</h2>
|
||||||
|
<a href="/projects/" class="btn btn-ghost btn-sm">All Projects</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if overdue_projects %}
|
||||||
|
<div class="text-xs text-muted mb-2" style="font-weight:600; color: var(--red);">OVERDUE</div>
|
||||||
|
{% for p in overdue_projects %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="priority-dot priority-{{ p.priority }}"></span>
|
||||||
|
<span class="row-title"><a href="/projects/{{ p.id }}">{{ p.name }}</a></span>
|
||||||
|
{% if p.domain_name %}
|
||||||
|
<span class="row-meta">{{ p.domain_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="project-progress-mini">
|
||||||
|
<div class="project-progress-bar" style="width: {{ ((p.done_count / p.task_count * 100) if p.task_count else 0)|int }}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="row-meta" style="min-width: 32px; text-align: right; font-size: 0.72rem;">{{ p.done_count }}/{{ p.task_count }}</span>
|
||||||
|
<span class="row-meta overdue">{{ p.target_date }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if upcoming_projects %}
|
||||||
|
<div class="text-xs text-muted mb-2 {{ 'mt-3' if overdue_projects }}" style="font-weight:600;">NEXT 30 DAYS</div>
|
||||||
|
{% for p in upcoming_projects %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="priority-dot priority-{{ p.priority }}"></span>
|
||||||
|
<span class="row-title"><a href="/projects/{{ p.id }}">{{ p.name }}</a></span>
|
||||||
|
{% if p.domain_name %}
|
||||||
|
<span class="row-meta">{{ p.domain_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="project-progress-mini">
|
||||||
|
<div class="project-progress-bar" style="width: {{ ((p.done_count / p.task_count * 100) if p.task_count else 0)|int }}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="row-meta" style="min-width: 32px; text-align: right; font-size: 0.72rem;">{{ p.done_count }}/{{ p.task_count }}</span>
|
||||||
|
<span class="row-meta">{{ p.target_date }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
<div class="card mt-3">
|
<div class="card mt-3">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
|
{% with reorder_url="/decisions/reorder", item_id=item.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
<span class="row-title"><a href="/decisions/{{ item.id }}">{{ item.title }}</a></span>
|
<span class="row-title"><a href="/decisions/{{ item.id }}">{{ item.title }}</a></span>
|
||||||
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
|
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
|
||||||
<span class="row-tag impact-{{ item.impact }}">{{ item.impact }}</span>
|
<span class="row-tag impact-{{ item.impact }}">{{ item.impact }}</span>
|
||||||
|
|||||||
184
templates/eisenhower.html
Normal file
184
templates/eisenhower.html
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1>Eisenhower Matrix</h1>
|
||||||
|
<span class="text-muted">{{ total }} open tasks classified by priority & urgency</span>
|
||||||
|
</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">
|
||||||
|
<!-- Axis labels -->
|
||||||
|
<div class="eisenhower-y-label">
|
||||||
|
<span>Important</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Q1: Urgent + Important -->
|
||||||
|
<div class="eisenhower-quadrant eisenhower-q1">
|
||||||
|
<div class="eisenhower-quadrant-header">
|
||||||
|
<h3>Do First</h3>
|
||||||
|
<span class="eisenhower-quadrant-subtitle">Urgent & Important</span>
|
||||||
|
<span class="badge badge-red">{{ counts.do_first }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="eisenhower-task-list">
|
||||||
|
{% for task in quadrants.do_first %}
|
||||||
|
<a href="/tasks/{{ task.id }}" class="eisenhower-task">
|
||||||
|
<span class="priority-dot priority-{{ task.priority }}"></span>
|
||||||
|
<span class="eisenhower-task-title">{{ task.title }}</span>
|
||||||
|
{% if task.due_date %}
|
||||||
|
<span class="eisenhower-task-due {% if task.due_date < today %}overdue{% endif %}">
|
||||||
|
{{ task.due_date.strftime('%b %-d') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if task.project_name %}
|
||||||
|
<span class="eisenhower-task-project">{{ task.project_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="eisenhower-empty">No tasks</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Q2: Not Urgent + Important -->
|
||||||
|
<div class="eisenhower-quadrant eisenhower-q2">
|
||||||
|
<div class="eisenhower-quadrant-header">
|
||||||
|
<h3>Schedule</h3>
|
||||||
|
<span class="eisenhower-quadrant-subtitle">Not Urgent & Important</span>
|
||||||
|
<span class="badge badge-accent">{{ counts.schedule }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="eisenhower-task-list">
|
||||||
|
{% for task in quadrants.schedule %}
|
||||||
|
<a href="/tasks/{{ task.id }}" class="eisenhower-task">
|
||||||
|
<span class="priority-dot priority-{{ task.priority }}"></span>
|
||||||
|
<span class="eisenhower-task-title">{{ task.title }}</span>
|
||||||
|
{% if task.due_date %}
|
||||||
|
<span class="eisenhower-task-due">{{ task.due_date.strftime('%b %-d') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if task.project_name %}
|
||||||
|
<span class="eisenhower-task-project">{{ task.project_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="eisenhower-empty">No tasks</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Second row y-label -->
|
||||||
|
<div class="eisenhower-y-label">
|
||||||
|
<span>Not Important</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Q3: Urgent + Not Important -->
|
||||||
|
<div class="eisenhower-quadrant eisenhower-q3">
|
||||||
|
<div class="eisenhower-quadrant-header">
|
||||||
|
<h3>Delegate</h3>
|
||||||
|
<span class="eisenhower-quadrant-subtitle">Urgent & Not Important</span>
|
||||||
|
<span class="badge badge-amber">{{ counts.delegate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="eisenhower-task-list">
|
||||||
|
{% for task in quadrants.delegate %}
|
||||||
|
<a href="/tasks/{{ task.id }}" class="eisenhower-task">
|
||||||
|
<span class="priority-dot priority-{{ task.priority }}"></span>
|
||||||
|
<span class="eisenhower-task-title">{{ task.title }}</span>
|
||||||
|
{% if task.due_date %}
|
||||||
|
<span class="eisenhower-task-due {% if task.due_date < today %}overdue{% endif %}">
|
||||||
|
{{ task.due_date.strftime('%b %-d') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if task.project_name %}
|
||||||
|
<span class="eisenhower-task-project">{{ task.project_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="eisenhower-empty">No tasks</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Q4: Not Urgent + Not Important -->
|
||||||
|
<div class="eisenhower-quadrant eisenhower-q4">
|
||||||
|
<div class="eisenhower-quadrant-header">
|
||||||
|
<h3>Eliminate</h3>
|
||||||
|
<span class="eisenhower-quadrant-subtitle">Not Urgent & Not Important</span>
|
||||||
|
<span class="badge badge-muted">{{ counts.eliminate }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="eisenhower-task-list">
|
||||||
|
{% for task in quadrants.eliminate %}
|
||||||
|
<a href="/tasks/{{ task.id }}" class="eisenhower-task">
|
||||||
|
<span class="priority-dot priority-{{ task.priority }}"></span>
|
||||||
|
<span class="eisenhower-task-title">{{ task.title }}</span>
|
||||||
|
{% if task.due_date %}
|
||||||
|
<span class="eisenhower-task-due">{{ task.due_date.strftime('%b %-d') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if task.project_name %}
|
||||||
|
<span class="eisenhower-task-project">{{ task.project_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="eisenhower-empty">No tasks</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- X-axis labels -->
|
||||||
|
<div class="eisenhower-x-spacer"></div>
|
||||||
|
<div class="eisenhower-x-label">Urgent</div>
|
||||||
|
<div class="eisenhower-x-label">Not Urgent</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 %}
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
<div class="breadcrumb">
|
<div class="breadcrumb">
|
||||||
<a href="/files">Files</a>
|
<a href="/files">Files</a>
|
||||||
<span class="sep">/</span>
|
<span class="sep">/</span>
|
||||||
|
{% if folder and folder != '/' %}
|
||||||
|
<a href="/files?folder={{ folder }}">{{ folder }}</a>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
{% endif %}
|
||||||
<span>{{ item.original_filename }}</span>
|
<span>{{ item.original_filename }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -17,6 +21,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-meta mt-2">
|
<div class="detail-meta mt-2">
|
||||||
|
<span class="detail-meta-item">Folder: {{ folder }}</span>
|
||||||
{% if item.mime_type %}<span class="detail-meta-item">Type: {{ item.mime_type }}</span>{% endif %}
|
{% if item.mime_type %}<span class="detail-meta-item">Type: {{ item.mime_type }}</span>{% endif %}
|
||||||
{% if item.size_bytes %}<span class="detail-meta-item">Size: {{ "%.1f"|format(item.size_bytes / 1024) }} KB</span>{% endif %}
|
{% if item.size_bytes %}<span class="detail-meta-item">Size: {{ "%.1f"|format(item.size_bytes / 1024) }} KB</span>{% endif %}
|
||||||
{% if item.description %}<span class="detail-meta-item">{{ item.description }}</span>{% endif %}
|
{% if item.description %}<span class="detail-meta-item">{{ item.description }}</span>{% endif %}
|
||||||
@@ -34,7 +39,7 @@
|
|||||||
{% elif item.mime_type == 'application/pdf' %}
|
{% elif item.mime_type == 'application/pdf' %}
|
||||||
<iframe src="/files/{{ item.id }}/serve" style="width: 100%; height: 600px; border: none; border-radius: var(--radius);"></iframe>
|
<iframe src="/files/{{ item.id }}/serve" style="width: 100%; height: 600px; border: none; border-radius: var(--radius);"></iframe>
|
||||||
{% elif item.mime_type and item.mime_type.startswith('text/') %}
|
{% elif item.mime_type and item.mime_type.startswith('text/') %}
|
||||||
<iframe src="/files/{{ item.id }}/serve" style="width: 100%; height: 400px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface2);"></iframe>
|
<iframe src="/files/{{ item.id }}/serve" style="width: 100%; height: 400px; border: 1px solid var(--border); border-radius: var(--radius); background: #fff;"></iframe>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -17,6 +17,21 @@
|
|||||||
<input type="file" name="file" class="form-input" required>
|
<input type="file" name="file" class="form-input" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Folder</label>
|
||||||
|
<select name="folder" class="form-input">
|
||||||
|
<option value="">/ (root)</option>
|
||||||
|
{% for f in folders %}
|
||||||
|
<option value="{{ f }}" {{ 'selected' if prefill_folder == f }}>{{ f }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">New Folder</label>
|
||||||
|
<input type="text" name="new_folder" class="form-input" placeholder="Or create new folder...">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="form-group full-width">
|
<div class="form-group full-width">
|
||||||
<label class="form-label">Description</label>
|
<label class="form-label">Description</label>
|
||||||
<input type="text" name="description" class="form-input" placeholder="Optional description...">
|
<input type="text" name="description" class="form-input" placeholder="Optional description...">
|
||||||
|
|||||||
@@ -1,40 +1,155 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title">Files<span class="page-count">{{ items|length }}</span></h1>
|
<h1 class="page-title">Files<span class="page-count">{{ total_files }}</span></h1>
|
||||||
<a href="/files/upload{{ '?context_type=' ~ context_type ~ '&context_id=' ~ context_id if context_type }}" class="btn btn-primary">+ Upload File</a>
|
<div class="flex gap-2">
|
||||||
|
<form action="/files/sync" method="post" style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-secondary">Sync Files</button>
|
||||||
|
</form>
|
||||||
|
<a href="/files/upload{{ '?folder=' ~ current_folder if current_folder }}{{ '?context_type=' ~ context_type ~ '&context_id=' ~ context_id if context_type }}" class="btn btn-primary">+ Upload File</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if items %}
|
{% if sync_result and (sync_result.added > 0 or sync_result.removed > 0) %}
|
||||||
<div class="card">
|
<div class="flash-message" style="background: var(--accent-soft); border: 1px solid var(--accent); border-radius: var(--radius); padding: 8px 12px; margin-bottom: 16px; color: var(--text); font-size: 0.85rem;">
|
||||||
{% for item in items %}
|
Synced: {{ sync_result.added }} file{{ 's' if sync_result.added != 1 }} added, {{ sync_result.removed }} removed
|
||||||
<div class="list-row">
|
|
||||||
<span class="row-title">
|
|
||||||
<a href="/files/{{ item.id }}/preview">{{ item.original_filename }}</a>
|
|
||||||
</span>
|
|
||||||
{% if item.mime_type %}
|
|
||||||
<span class="row-tag">{{ item.mime_type.split('/')|last }}</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if item.size_bytes %}
|
|
||||||
<span class="row-meta">{{ "%.1f"|format(item.size_bytes / 1024) }} KB</span>
|
|
||||||
{% endif %}
|
|
||||||
{% if item.description %}
|
|
||||||
<span class="row-meta">{{ item.description[:50] }}</span>
|
|
||||||
{% endif %}
|
|
||||||
<div class="row-actions">
|
|
||||||
<a href="/files/{{ item.id }}/download" class="btn btn-ghost btn-xs">Download</a>
|
|
||||||
<form action="/files/{{ item.id }}/delete" method="post" data-confirm="Delete this file?" style="display:inline">
|
|
||||||
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form class="filters-bar" method="get" action="/files" style="display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 16px;">
|
||||||
|
<input type="text" name="q" value="{{ current_q }}" class="form-input" placeholder="Search files..." style="max-width: 220px; padding: 6px 10px; font-size: 0.85rem;">
|
||||||
|
|
||||||
|
<select name="folder" class="form-input" style="max-width: 200px; padding: 6px 10px; font-size: 0.85rem;" onchange="this.form.submit()">
|
||||||
|
<option value="" {{ 'selected' if current_folder is none }}>All folders</option>
|
||||||
|
<option value=" " {{ 'selected' if current_folder is not none and current_folder == '' }}>/ (root)</option>
|
||||||
|
{% for f in folders %}
|
||||||
|
<option value="{{ f }}" {{ 'selected' if current_folder == f }}>{% if '/' in f %} {{ f.split('/')[-1] }}{% else %}{{ f }}{% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select name="file_type" class="form-input" style="max-width: 160px; padding: 6px 10px; font-size: 0.85rem;" onchange="this.form.submit()">
|
||||||
|
<option value="">All types</option>
|
||||||
|
<option value="image" {{ 'selected' if current_type == 'image' }}>Images</option>
|
||||||
|
<option value="document" {{ 'selected' if current_type == 'document' }}>Documents</option>
|
||||||
|
<option value="text" {{ 'selected' if current_type == 'text' }}>Text</option>
|
||||||
|
<option value="spreadsheet" {{ 'selected' if current_type == 'spreadsheet' }}>Spreadsheets</option>
|
||||||
|
<option value="archive" {{ 'selected' if current_type == 'archive' }}>Archives</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{% if all_tags %}
|
||||||
|
<select name="tag" class="form-input" style="max-width: 160px; padding: 6px 10px; font-size: 0.85rem;" onchange="this.form.submit()">
|
||||||
|
<option value="">All tags</option>
|
||||||
|
{% for t in all_tags %}
|
||||||
|
<option value="{{ t }}" {{ 'selected' if current_tag == t }}>{{ t }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if current_q or current_type or current_tag or current_folder is not none %}
|
||||||
|
<input type="hidden" name="sort" value="{{ current_sort }}">
|
||||||
|
<button type="submit" class="btn btn-primary btn-xs">Search</button>
|
||||||
|
<a href="/files" class="btn btn-ghost btn-xs">Clear</a>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" class="btn btn-primary btn-xs">Search</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% set qp = [] %}
|
||||||
|
{% if current_folder is not none %}{% if current_folder == '' %}{{ qp.append('folder= ') or '' }}{% else %}{{ qp.append('folder=' ~ current_folder) or '' }}{% endif %}{% endif %}
|
||||||
|
{% if current_q %}{{ qp.append('q=' ~ current_q) or '' }}{% endif %}
|
||||||
|
{% if current_type %}{{ qp.append('file_type=' ~ current_type) or '' }}{% endif %}
|
||||||
|
{% if current_tag %}{{ qp.append('tag=' ~ current_tag) or '' }}{% endif %}
|
||||||
|
{% set filter_qs = qp | join('&') %}
|
||||||
|
{% set sort_base = '/files?' ~ (filter_qs ~ '&' if filter_qs else '') %}
|
||||||
|
|
||||||
|
{% if items %}
|
||||||
|
<div class="card" style="overflow-x: auto;">
|
||||||
|
<table class="data-table" style="width: 100%; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border); text-align: left;">
|
||||||
|
<th style="padding: 6px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">
|
||||||
|
<a href="{{ sort_base }}sort={{ 'path_desc' if current_sort == 'path' else 'path' }}" style="color: var(--muted); text-decoration: none;">
|
||||||
|
Path {{ '▲' if current_sort == 'path' else ('▼' if current_sort == 'path_desc' else '') }}
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th style="padding: 6px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">
|
||||||
|
<a href="{{ sort_base }}sort={{ 'name_desc' if current_sort == 'name' else 'name' }}" style="color: var(--muted); text-decoration: none;">
|
||||||
|
Name {{ '▲' if current_sort == 'name' else ('▼' if current_sort == 'name_desc' else '') }}
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th style="padding: 6px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">Type</th>
|
||||||
|
<th style="padding: 6px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">Size</th>
|
||||||
|
<th style="padding: 6px 12px; font-weight: 600; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em;">
|
||||||
|
<a href="{{ sort_base }}sort={{ 'date_asc' if current_sort == 'date' else 'date' }}" style="color: var(--muted); text-decoration: none;">
|
||||||
|
Date {{ '▼' if current_sort == 'date' else ('▲' if current_sort == 'date_asc' else '') }}
|
||||||
|
</a>
|
||||||
|
</th>
|
||||||
|
<th style="padding: 6px 12px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in items %}
|
||||||
|
<tr style="border-bottom: 1px solid var(--border);">
|
||||||
|
<td style="padding: 6px 12px; color: var(--muted); font-size: 0.80rem;">{{ item.folder }}</td>
|
||||||
|
<td style="padding: 6px 12px; font-size: 0.80rem;">
|
||||||
|
<a href="/files/{{ item.id }}/preview" style="color: var(--accent);">{{ item.original_filename }}</a>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 6px 12px;">
|
||||||
|
{% if item.mime_type %}<span class="row-tag">{{ item.mime_type.split('/')|last }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 6px 12px; color: var(--muted); font-size: 0.80rem; white-space: nowrap;">
|
||||||
|
{% if item.size_bytes %}{{ "%.1f"|format(item.size_bytes / 1024) }} KB{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 6px 12px; color: var(--muted); font-size: 0.80rem; white-space: nowrap;">
|
||||||
|
{{ item.created_at.strftime('%Y-%m-%d') if item.created_at else '' }}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 6px 12px; text-align: right; white-space: nowrap;">
|
||||||
|
<a href="/files/{{ item.id }}/download" class="btn btn-ghost btn-xs">Download</a>
|
||||||
|
<form action="/files/{{ item.id }}/delete" method="post" data-confirm="Delete this file?" style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
{% set page_base = sort_base ~ 'sort=' ~ current_sort ~ '&' %}
|
||||||
|
<div style="display: flex; justify-content: center; align-items: center; gap: 8px; margin-top: 16px;">
|
||||||
|
{% if current_page > 1 %}
|
||||||
|
<a href="{{ page_base }}page={{ current_page - 1 }}" class="btn btn-ghost btn-xs">Prev</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for p in range(1, total_pages + 1) %}
|
||||||
|
{% if p == current_page %}
|
||||||
|
<span class="btn btn-primary btn-xs" style="pointer-events: none;">{{ p }}</span>
|
||||||
|
{% elif p <= 3 or p >= total_pages - 2 or (p >= current_page - 1 and p <= current_page + 1) %}
|
||||||
|
<a href="{{ page_base }}page={{ p }}" class="btn btn-ghost btn-xs">{{ p }}</a>
|
||||||
|
{% elif p == 4 and current_page > 5 %}
|
||||||
|
<span style="color: var(--muted);">...</span>
|
||||||
|
{% elif p == total_pages - 3 and current_page < total_pages - 4 %}
|
||||||
|
<span style="color: var(--muted);">...</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if current_page < total_pages %}
|
||||||
|
<a href="{{ page_base }}page={{ current_page + 1 }}" class="btn btn-ghost btn-xs">Next</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">📁</div>
|
<div class="empty-state-icon">📁</div>
|
||||||
<div class="empty-state-text">No files uploaded yet</div>
|
{% if current_q or current_type or current_tag %}
|
||||||
|
<div class="empty-state-text">No files match your filters</div>
|
||||||
|
<a href="/files" class="btn btn-secondary">Clear Filters</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state-text">No files{{ ' in this folder' if current_folder is not none else ' uploaded yet' }}</div>
|
||||||
<a href="/files/upload" class="btn btn-primary">Upload First File</a>
|
<a href="/files/upload" class="btn btn-primary">Upload First File</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -14,8 +14,12 @@
|
|||||||
|
|
||||||
<!-- Focus items -->
|
<!-- Focus items -->
|
||||||
{% if items %}
|
{% if items %}
|
||||||
|
<div class="card">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="focus-item {{ 'completed' if item.completed }}">
|
<div class="focus-item {{ 'completed' if item.completed }}">
|
||||||
|
{% with reorder_url="/focus/reorder", item_id=item.id, extra_fields={"focus_date": focus_date|string} %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
<form action="/focus/{{ item.id }}/toggle" method="post" style="display:inline">
|
<form action="/focus/{{ item.id }}/toggle" method="post" style="display:inline">
|
||||||
<div class="row-check">
|
<div class="row-check">
|
||||||
<input type="checkbox" id="f-{{ item.id }}" {{ 'checked' if item.completed }} onchange="this.form.submit()">
|
<input type="checkbox" id="f-{{ item.id }}" {{ 'checked' if item.completed }} onchange="this.form.submit()">
|
||||||
@@ -32,14 +36,37 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state mb-4"><div class="empty-state-text">No focus items for this day</div></div>
|
<div class="empty-state mb-4"><div class="empty-state-text">No focus items for this day</div></div>
|
||||||
{% 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 +79,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 %}
|
||||||
@@ -7,11 +7,14 @@
|
|||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-group"><label class="form-label">Label *</label><input type="text" name="label" class="form-input" required value="{{ item.label if item else '' }}"></div>
|
<div class="form-group"><label class="form-label">Label *</label><input type="text" name="label" class="form-input" required value="{{ item.label if item else '' }}"></div>
|
||||||
<div class="form-group"><label class="form-label">URL *</label><input type="url" name="url" class="form-input" required value="{{ item.url if item else '' }}"></div>
|
<div class="form-group"><label class="form-label">URL *</label><input type="url" name="url" class="form-input" required value="{{ item.url if item else '' }}"></div>
|
||||||
<div class="form-group"><label class="form-label">Domain *</label>
|
<div class="form-group"><label class="form-label">Domain</label>
|
||||||
<select name="domain_id" class="form-select" required>{% for d in domains %}<option value="{{ d.id }}" {{ 'selected' if (item and item.domain_id|string == d.id|string) or (not item and prefill_domain_id == d.id|string) }}>{{ d.name }}</option>{% endfor %}</select></div>
|
<select name="domain_id" class="form-select"><option value="">-- None --</option>{% for d in domains %}<option value="{{ d.id }}" {{ 'selected' if (item and item.domain_id and item.domain_id|string == d.id|string) or (not item and prefill_domain_id == d.id|string) }}>{{ d.name }}</option>{% endfor %}</select></div>
|
||||||
<div class="form-group"><label class="form-label">Project</label>
|
<div class="form-group"><label class="form-label">Project</label>
|
||||||
<select name="project_id" class="form-select"><option value="">-- None --</option>{% for p in projects %}<option value="{{ p.id }}" {{ 'selected' if (item and item.project_id and item.project_id|string == p.id|string) or (not item and prefill_project_id == p.id|string) }}>{{ p.name }}</option>{% endfor %}</select></div>
|
<select name="project_id" class="form-select"><option value="">-- None --</option>{% for p in projects %}<option value="{{ p.id }}" {{ 'selected' if (item and item.project_id and item.project_id|string == p.id|string) or (not item and prefill_project_id == p.id|string) }}>{{ p.name }}</option>{% endfor %}</select></div>
|
||||||
|
<div class="form-group full-width"><label class="form-label">Tags</label><input type="text" name="tags" class="form-input" placeholder="tag1, tag2, ..." value="{{ item.tags|join(', ') if item and item.tags else '' }}"></div>
|
||||||
<div class="form-group full-width"><label class="form-label">Description</label><textarea name="description" class="form-textarea" rows="3">{{ item.description if item and item.description else '' }}</textarea></div>
|
<div class="form-group full-width"><label class="form-label">Description</label><textarea name="description" class="form-textarea" rows="3">{{ item.description if item and item.description else '' }}</textarea></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"><button type="submit" class="btn btn-primary">{{ 'Save' if item else 'Create' }}</button><a href="/links" 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="/links" class="btn btn-secondary">Cancel</a></div>
|
||||||
</div>
|
</div>
|
||||||
</form></div>
|
</form></div>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
|
{% with reorder_url="/links/reorder", item_id=item.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
{% if item.domain_color %}<span class="row-domain-tag" style="background:{{ item.domain_color }}22;color:{{ item.domain_color }}">{{ item.domain_name }}</span>{% endif %}
|
{% if item.domain_color %}<span class="row-domain-tag" style="background:{{ item.domain_color }}22;color:{{ item.domain_color }}">{{ item.domain_name }}</span>{% endif %}
|
||||||
<span class="row-title"><a href="{{ item.url }}" target="_blank">{{ item.label }}</a></span>
|
<span class="row-title"><a href="{{ item.url }}" target="_blank">{{ item.label }}</a></span>
|
||||||
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}
|
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}
|
||||||
|
|||||||
@@ -46,6 +46,9 @@
|
|||||||
<div class="card mt-2">
|
<div class="card mt-2">
|
||||||
{% for li in list_items %}
|
{% for li in list_items %}
|
||||||
<div class="list-row {{ 'completed' if li.completed }}">
|
<div class="list-row {{ 'completed' if li.completed }}">
|
||||||
|
{% with reorder_url="/lists/" ~ item.id ~ "/items/reorder", item_id=li.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
{% if item.list_type == 'checklist' %}
|
{% if item.list_type == 'checklist' %}
|
||||||
<div class="row-check">
|
<div class="row-check">
|
||||||
<form action="/lists/{{ item.id }}/items/{{ li.id }}/toggle" method="post" style="display:inline">
|
<form action="/lists/{{ item.id }}/items/{{ li.id }}/toggle" method="post" style="display:inline">
|
||||||
@@ -68,6 +71,9 @@
|
|||||||
<!-- Child items -->
|
<!-- Child items -->
|
||||||
{% for child in child_map.get(li.id|string, []) %}
|
{% for child in child_map.get(li.id|string, []) %}
|
||||||
<div class="list-row {{ 'completed' if child.completed }}" style="padding-left: 48px;">
|
<div class="list-row {{ 'completed' if child.completed }}" style="padding-left: 48px;">
|
||||||
|
{% with reorder_url="/lists/" ~ item.id ~ "/items/reorder", item_id=child.id, extra_fields={"parent_id": li.id|string} %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
{% if item.list_type == 'checklist' %}
|
{% if item.list_type == 'checklist' %}
|
||||||
<div class="row-check">
|
<div class="row-check">
|
||||||
<form action="/lists/{{ item.id }}/items/{{ child.id }}/toggle" method="post" style="display:inline">
|
<form action="/lists/{{ item.id }}/items/{{ child.id }}/toggle" method="post" style="display:inline">
|
||||||
@@ -95,4 +101,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>
|
||||||
@@ -25,6 +25,9 @@
|
|||||||
<div class="card mt-3">
|
<div class="card mt-3">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
|
{% with reorder_url="/lists/reorder", item_id=item.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
<span class="row-title"><a href="/lists/{{ item.id }}">{{ item.name }}</a></span>
|
<span class="row-title"><a href="/lists/{{ item.id }}">{{ item.name }}</a></span>
|
||||||
<span class="row-meta">
|
<span class="row-meta">
|
||||||
{{ item.completed_count }}/{{ item.item_count }} items
|
{{ item.completed_count }}/{{ item.item_count }} items
|
||||||
@@ -57,4 +60,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 %}
|
||||||
|
|||||||
@@ -23,14 +23,32 @@
|
|||||||
{% if item.start_at and item.end_at %}
|
{% if item.start_at and item.end_at %}
|
||||||
<span class="detail-meta-item">{{ item.start_at.strftime('%H:%M') }} - {{ item.end_at.strftime('%H:%M') }}</span>
|
<span class="detail-meta-item">{{ item.start_at.strftime('%H:%M') }} - {{ item.end_at.strftime('%H:%M') }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if projects %}
|
||||||
|
{% for p in projects %}
|
||||||
|
<span class="detail-meta-item"><a href="/projects/{{ p.id }}" style="color: {{ p.domain_color or 'var(--accent)' }};">{{ p.name }}</a></span>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
{% if item.tags %}
|
{% if item.tags %}
|
||||||
<div class="mt-1">{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}</div>
|
<div class="mt-1">{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}</div>
|
||||||
{% 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=links" class="tab-item {{ 'active' if tab == 'links' }}">Links{% if counts.links %} ({{ counts.links }}){% 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=decisions" class="tab-item {{ 'active' if tab == 'decisions' }}">Decisions{% if counts.decisions %} ({{ counts.decisions }}){% 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 +56,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 +64,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 +84,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 +98,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 +117,121 @@
|
|||||||
</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 == 'links' %}
|
||||||
|
<a href="/links/create?meeting_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Link</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 links 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 == 'decisions' %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<form action="/meetings/{{ item.id }}/decisions/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">Decision</label>
|
||||||
|
<select name="decision_id" class="form-select" required>
|
||||||
|
<option value="">Select decision...</option>
|
||||||
|
{% for d in all_decisions %}
|
||||||
|
<option value="{{ d.id }}">{{ d.title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Link</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<a href="/decisions/create?meeting_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>
|
||||||
|
{% if d.impact %}<span class="row-tag">{{ d.impact }}</span>{% endif %}
|
||||||
|
{% if d.decided_at %}<span class="row-meta">{{ d.decided_at.strftime('%Y-%m-%d') if d.decided_at else '' }}</span>{% endif %}
|
||||||
|
<div class="row-actions">
|
||||||
|
<form action="/meetings/{{ item.id }}/decisions/{{ d.id }}/remove" method="post" style="display:inline">
|
||||||
|
<button class="btn btn-ghost btn-xs" title="Unlink">Unlink</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state"><div class="empty-state-text">No decisions 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 %}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
<div class="card mt-3">
|
<div class="card mt-3">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
|
{% with reorder_url="/meetings/reorder", item_id=item.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
<span class="row-title"><a href="/meetings/{{ item.id }}">{{ item.title }}</a></span>
|
<span class="row-title"><a href="/meetings/{{ item.id }}">{{ item.title }}</a></span>
|
||||||
<span class="row-meta">{{ item.meeting_date }}</span>
|
<span class="row-meta">{{ item.meeting_date }}</span>
|
||||||
{% if item.location %}
|
{% if item.location %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="list-row">
|
<div class="list-row">
|
||||||
|
{% with reorder_url="/notes/reorder", item_id=item.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
{% if item.domain_color %}<span class="row-domain-tag" style="background:{{ item.domain_color }}22;color:{{ item.domain_color }}">{{ item.domain_name }}</span>{% endif %}
|
{% if item.domain_color %}<span class="row-domain-tag" style="background:{{ item.domain_color }}22;color:{{ item.domain_color }}">{{ item.domain_name }}</span>{% endif %}
|
||||||
<span class="row-title"><a href="/notes/{{ item.id }}">{{ item.title }}</a></span>
|
<span class="row-title"><a href="/notes/{{ item.id }}">{{ item.title }}</a></span>
|
||||||
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}
|
{% if item.project_name %}<span class="row-tag">{{ item.project_name }}</span>{% endif %}
|
||||||
|
|||||||
30
templates/partials/reorder_arrows.html
Normal file
30
templates/partials/reorder_arrows.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{#
|
||||||
|
Reorder grip handle. Include with:
|
||||||
|
{% with reorder_url="/focus/reorder", item_id=item.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
Required context vars:
|
||||||
|
reorder_url - POST endpoint for the reorder action
|
||||||
|
item_id - ID of the current item
|
||||||
|
Optional:
|
||||||
|
extra_fields - dict of extra hidden fields (e.g. {"focus_date": "2026-03-03"})
|
||||||
|
#}
|
||||||
|
<span class="reorder-grip">
|
||||||
|
<form action="{{ reorder_url }}" method="post">
|
||||||
|
<input type="hidden" name="item_id" value="{{ item_id }}">
|
||||||
|
<input type="hidden" name="direction" value="up">
|
||||||
|
{% if extra_fields %}{% for k, v in extra_fields.items() %}
|
||||||
|
<input type="hidden" name="{{ k }}" value="{{ v }}">
|
||||||
|
{% endfor %}{% endif %}
|
||||||
|
<button type="submit" class="grip-btn" title="Move up">⠛</button>
|
||||||
|
</form>
|
||||||
|
<form action="{{ reorder_url }}" method="post">
|
||||||
|
<input type="hidden" name="item_id" value="{{ item_id }}">
|
||||||
|
<input type="hidden" name="direction" value="down">
|
||||||
|
{% if extra_fields %}{% for k, v in extra_fields.items() %}
|
||||||
|
<input type="hidden" name="{{ k }}" value="{{ v }}">
|
||||||
|
{% endfor %}{% endif %}
|
||||||
|
<button type="submit" class="grip-btn" title="Move down">⠓</button>
|
||||||
|
</form>
|
||||||
|
</span>
|
||||||
133
templates/process_run_detail.html
Normal file
133
templates/process_run_detail.html
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="/processes">Processes</a>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<a href="/processes/{{ run.process_id_ref }}">{{ run.process_name }}</a>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span>{{ run.title }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-header">
|
||||||
|
<h1 class="detail-title">{{ run.title }}</h1>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if run.status != 'completed' %}
|
||||||
|
<form action="/processes/runs/{{ run.id }}/complete" method="post" data-confirm="Mark this run as complete?" style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Mark Complete</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
<form action="/processes/runs/{{ run.id }}/delete" method="post" data-confirm="Delete this run?" style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-meta mt-2">
|
||||||
|
<span class="status-badge status-{{ run.status }}">{{ run.status|replace('_', ' ') }}</span>
|
||||||
|
<span class="row-tag">{{ run.process_type }}</span>
|
||||||
|
<span class="row-tag">{{ run.task_generation|replace('_', ' ') }}</span>
|
||||||
|
{% if run.project_name %}<span class="detail-meta-item">{{ run.project_name }}</span>{% endif %}
|
||||||
|
{% if run.contact_first %}<span class="detail-meta-item">{{ run.contact_first }} {{ run.contact_last or '' }}</span>{% endif %}
|
||||||
|
{% if run.started_at %}<span class="detail-meta-item">Started {{ run.started_at.strftime('%Y-%m-%d') }}</span>{% endif %}
|
||||||
|
{% if run.completed_at %}<span class="detail-meta-item">Completed {{ run.completed_at.strftime('%Y-%m-%d') }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress Bar -->
|
||||||
|
<div class="card mt-3" style="padding: 16px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 12px;">
|
||||||
|
<span style="font-weight: 600; font-size: 0.9rem;">Progress</span>
|
||||||
|
<div style="flex: 1; height: 8px; background: var(--border); border-radius: 4px; overflow: hidden;">
|
||||||
|
<div style="width: {{ (completed_steps / total_steps * 100)|int if total_steps > 0 else 0 }}%; height: 100%; background: var(--green); border-radius: 4px; transition: width 0.3s;"></div>
|
||||||
|
</div>
|
||||||
|
<span style="font-weight: 600; font-size: 0.9rem;">{{ completed_steps }}/{{ total_steps }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Steps Checklist -->
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Steps</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for step in steps %}
|
||||||
|
<div class="list-row {{ 'completed' if step.status == 'completed' }}" style="align-items: flex-start;">
|
||||||
|
<div class="row-check">
|
||||||
|
{% if step.status == 'completed' %}
|
||||||
|
<form action="/processes/runs/{{ run.id }}/steps/{{ step.id }}/uncomplete" method="post" style="display:inline">
|
||||||
|
<input type="checkbox" id="step-{{ step.id }}" checked onchange="this.form.submit()">
|
||||||
|
<label for="step-{{ step.id }}"></label>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<form action="/processes/runs/{{ run.id }}/steps/{{ step.id }}/complete" method="post" style="display:inline" id="complete-form-{{ step.id }}">
|
||||||
|
<input type="checkbox" id="step-{{ step.id }}" onchange="this.form.submit()">
|
||||||
|
<label for="step-{{ step.id }}"></label>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<span class="row-meta" style="min-width: 20px; font-weight: 600;">{{ loop.index }}</span>
|
||||||
|
<span class="row-title" style="{{ 'text-decoration: line-through; opacity: 0.6;' if step.status == 'completed' }}">{{ step.title }}</span>
|
||||||
|
</div>
|
||||||
|
{% if step.instructions %}
|
||||||
|
<div style="color: var(--muted); font-size: 0.82rem; margin: 4px 0 0 28px;">{{ step.instructions }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if step.completed_at %}
|
||||||
|
<div style="color: var(--green); font-size: 0.78rem; margin: 4px 0 0 28px;">
|
||||||
|
Completed {{ step.completed_at.strftime('%Y-%m-%d %H:%M') }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if step.notes %}
|
||||||
|
<div style="color: var(--muted); font-size: 0.82rem; margin: 2px 0 0 28px; font-style: italic;">{{ step.notes }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if step.status != 'completed' %}
|
||||||
|
<div style="margin: 6px 0 0 28px;">
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" onclick="toggleNotes('{{ step.id }}')">Add Notes</button>
|
||||||
|
<div id="notes-{{ step.id }}" style="display: none; margin-top: 4px;">
|
||||||
|
<form action="/processes/runs/{{ run.id }}/steps/{{ step.id }}/complete" method="post" style="display: flex; gap: 6px; align-items: flex-end;">
|
||||||
|
<input type="text" name="notes" class="form-input" placeholder="Completion notes..." style="flex: 1; height: 32px; font-size: 0.82rem;">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Complete with Notes</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- Show linked tasks for this step -->
|
||||||
|
{% if step_tasks.get(step.id|string) %}
|
||||||
|
<div style="margin: 6px 0 0 28px;">
|
||||||
|
{% for task in step_tasks[step.id|string] %}
|
||||||
|
<div style="display: inline-flex; align-items: center; gap: 4px; margin-right: 8px;">
|
||||||
|
<span class="status-badge status-{{ task.status }}" style="font-size: 0.72rem;">{{ task.status }}</span>
|
||||||
|
<a href="/tasks/{{ task.id }}" style="font-size: 0.82rem;">{{ task.title }}</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- All Generated Tasks -->
|
||||||
|
{% if tasks %}
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Generated Tasks<span class="page-count">{{ tasks|length }}</span></h3>
|
||||||
|
</div>
|
||||||
|
{% for task in tasks %}
|
||||||
|
<div class="list-row {{ 'completed' if task.status == 'done' }}">
|
||||||
|
<span class="priority-dot priority-{{ task.priority }}"></span>
|
||||||
|
<span class="row-title"><a href="/tasks/{{ task.id }}">{{ task.title }}</a></span>
|
||||||
|
{% if task.project_name %}<span class="row-tag">{{ task.project_name }}</span>{% endif %}
|
||||||
|
<span class="status-badge status-{{ task.status }}">{{ task.status|replace('_', ' ') }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleNotes(stepId) {
|
||||||
|
var el = document.getElementById('notes-' + stepId);
|
||||||
|
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
46
templates/process_runs.html
Normal file
46
templates/process_runs.html
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">All Process Runs<span class="page-count">{{ items|length }}</span></h1>
|
||||||
|
<a href="/processes" class="btn btn-secondary">Back to Processes</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="filters-bar" method="get" action="/processes/runs">
|
||||||
|
<select name="status" class="filter-select" onchange="this.form.submit()">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="not_started" {{ 'selected' if current_status == 'not_started' }}>Not Started</option>
|
||||||
|
<option value="in_progress" {{ 'selected' if current_status == 'in_progress' }}>In Progress</option>
|
||||||
|
<option value="completed" {{ 'selected' if current_status == 'completed' }}>Completed</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if items %}
|
||||||
|
<div class="card mt-3">
|
||||||
|
{% for item in items %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/processes/runs/{{ item.id }}">{{ item.title }}</a></span>
|
||||||
|
<span class="row-tag">{{ item.process_name }}</span>
|
||||||
|
<span class="status-badge status-{{ item.status }}">{{ item.status|replace('_', ' ') }}</span>
|
||||||
|
{% if item.total_steps > 0 %}
|
||||||
|
<div class="row-meta" style="display: flex; align-items: center; gap: 6px;">
|
||||||
|
<div style="width: 60px; height: 4px; background: var(--border); border-radius: 2px; overflow: hidden;">
|
||||||
|
<div style="width: {{ (item.completed_steps / item.total_steps * 100)|int }}%; height: 100%; background: var(--green); border-radius: 2px;"></div>
|
||||||
|
</div>
|
||||||
|
<span>{{ item.completed_steps }}/{{ item.total_steps }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.project_name %}
|
||||||
|
<span class="row-tag">{{ item.project_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="row-meta">{{ item.created_at.strftime('%Y-%m-%d') }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state mt-3">
|
||||||
|
<div class="empty-state-icon">▶</div>
|
||||||
|
<div class="empty-state-text">No process runs yet</div>
|
||||||
|
<a href="/processes" class="btn btn-primary">Go to Processes</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
52
templates/processes.html
Normal file
52
templates/processes.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">Processes<span class="page-count">{{ items|length }}</span></h1>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="/processes/runs" class="btn btn-secondary">All Runs</a>
|
||||||
|
<a href="/processes/create" class="btn btn-primary">+ New Process</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="filters-bar" method="get" action="/processes">
|
||||||
|
<select name="status" class="filter-select" onchange="this.form.submit()">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="draft" {{ 'selected' if current_status == 'draft' }}>Draft</option>
|
||||||
|
<option value="active" {{ 'selected' if current_status == 'active' }}>Active</option>
|
||||||
|
<option value="archived" {{ 'selected' if current_status == 'archived' }}>Archived</option>
|
||||||
|
</select>
|
||||||
|
<select name="process_type" class="filter-select" onchange="this.form.submit()">
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="workflow" {{ 'selected' if current_type == 'workflow' }}>Workflow</option>
|
||||||
|
<option value="checklist" {{ 'selected' if current_type == 'checklist' }}>Checklist</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if items %}
|
||||||
|
<div class="card mt-3">
|
||||||
|
{% for item in items %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/processes/{{ item.id }}">{{ item.name }}</a></span>
|
||||||
|
<span class="row-tag">{{ item.process_type }}</span>
|
||||||
|
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
|
||||||
|
<span class="row-meta">{{ item.step_count }} step{{ 's' if item.step_count != 1 }}</span>
|
||||||
|
{% if item.category %}
|
||||||
|
<span class="row-tag">{{ item.category }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="row-actions">
|
||||||
|
<a href="/processes/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
||||||
|
<form action="/processes/{{ item.id }}/delete" method="post" data-confirm="Delete this process?" style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state mt-3">
|
||||||
|
<div class="empty-state-icon">⚙</div>
|
||||||
|
<div class="empty-state-text">No processes yet</div>
|
||||||
|
<a href="/processes/create" class="btn btn-primary">Create First Process</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
183
templates/processes_detail.html
Normal file
183
templates/processes_detail.html
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="/processes">Processes</a>
|
||||||
|
<span class="sep">/</span>
|
||||||
|
<span>{{ item.name }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-header">
|
||||||
|
<h1 class="detail-title">{{ item.name }}</h1>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="/processes/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
|
||||||
|
<form action="/processes/{{ item.id }}/delete" method="post" data-confirm="Delete this process?" style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-meta mt-2">
|
||||||
|
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
|
||||||
|
<span class="row-tag">{{ item.process_type }}</span>
|
||||||
|
{% if item.category %}<span class="detail-meta-item">{{ item.category }}</span>{% endif %}
|
||||||
|
<span class="detail-meta-item">{{ steps|length }} step{{ 's' if steps|length != 1 }}</span>
|
||||||
|
<span class="detail-meta-item">Created {{ item.created_at.strftime('%Y-%m-%d') }}</span>
|
||||||
|
{% if item.tags %}
|
||||||
|
<div class="mt-1">{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if item.description %}
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header"><h3 class="card-title">Description</h3></div>
|
||||||
|
<div class="detail-body" style="padding: 12px 16px;">{{ item.description }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Steps -->
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Steps<span class="page-count">{{ steps|length }}</span></h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for step in steps %}
|
||||||
|
<div class="list-row" style="align-items: flex-start;">
|
||||||
|
<span class="row-meta" style="min-width: 28px; text-align: center; font-weight: 600;">{{ loop.index }}</span>
|
||||||
|
<div style="flex: 1;">
|
||||||
|
<span class="row-title">{{ step.title }}</span>
|
||||||
|
{% if step.instructions %}
|
||||||
|
<div style="color: var(--muted); font-size: 0.82rem; margin-top: 2px;">{{ step.instructions[:120] }}{{ '...' if step.instructions|length > 120 }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if step.expected_output %}
|
||||||
|
<div style="color: var(--muted); font-size: 0.82rem; margin-top: 2px;">Output: {{ step.expected_output[:80] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if step.estimated_days %}
|
||||||
|
<span class="row-meta">{{ step.estimated_days }}d</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="row-actions">
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" onclick="toggleEditStep('{{ step.id }}')">Edit</button>
|
||||||
|
<form action="/processes/{{ item.id }}/steps/{{ step.id }}/delete" method="post" data-confirm="Delete this step?" style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Inline edit form (hidden by default) -->
|
||||||
|
<div id="edit-step-{{ step.id }}" style="display: none; border-bottom: 1px solid var(--border); padding: 12px 16px; background: var(--surface2);">
|
||||||
|
<form action="/processes/{{ item.id }}/steps/{{ step.id }}/edit" method="post">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label class="form-label">Title *</label>
|
||||||
|
<input type="text" name="title" class="form-input" value="{{ step.title }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label class="form-label">Instructions</label>
|
||||||
|
<textarea name="instructions" class="form-textarea" rows="2">{{ step.instructions or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Expected Output</label>
|
||||||
|
<input type="text" name="expected_output" class="form-input" value="{{ step.expected_output or '' }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Estimated Days</label>
|
||||||
|
<input type="number" name="estimated_days" class="form-input" min="0" value="{{ step.estimated_days or '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions" style="margin-top: 8px;">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Save Step</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="toggleEditStep('{{ step.id }}')">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Quick add step -->
|
||||||
|
<form class="quick-add" action="/processes/{{ item.id }}/steps/add" method="post" style="border-top: 1px solid var(--border);">
|
||||||
|
<input type="text" name="title" placeholder="Add a step..." required>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Add Step</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Runs -->
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Runs<span class="page-count">{{ runs|length }}</span></h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for run in runs %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/processes/runs/{{ run.id }}">{{ run.title }}</a></span>
|
||||||
|
<span class="status-badge status-{{ run.status }}">{{ run.status|replace('_', ' ') }}</span>
|
||||||
|
{% if run.total_steps > 0 %}
|
||||||
|
<span class="row-meta">{{ run.completed_steps }}/{{ run.total_steps }} steps</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if run.project_name %}
|
||||||
|
<span class="row-tag">{{ run.project_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="row-meta">{{ run.created_at.strftime('%Y-%m-%d') }}</span>
|
||||||
|
<div class="row-actions">
|
||||||
|
<form action="/processes/runs/{{ run.id }}/delete" method="post" data-confirm="Delete this run?" style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if not runs %}
|
||||||
|
<div style="padding: 16px; color: var(--muted); font-size: 0.85rem;">No runs yet</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Start Run Form -->
|
||||||
|
{% if steps %}
|
||||||
|
<div style="border-top: 1px solid var(--border); padding: 12px 16px;">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" onclick="document.getElementById('start-run-form').style.display = document.getElementById('start-run-form').style.display === 'none' ? 'block' : 'none'">+ Start Run</button>
|
||||||
|
<div id="start-run-form" style="display: none; margin-top: 12px;">
|
||||||
|
<form action="/processes/{{ item.id }}/runs/start" method="post">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label class="form-label">Run Title *</label>
|
||||||
|
<input type="text" name="title" class="form-input" required
|
||||||
|
value="{{ item.name }} - Run">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Task Generation</label>
|
||||||
|
<select name="task_generation" class="form-select">
|
||||||
|
<option value="all_at_once">All at Once</option>
|
||||||
|
<option value="step_by_step">Step by Step</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Project</label>
|
||||||
|
<select name="project_id" class="form-select">
|
||||||
|
<option value="">None</option>
|
||||||
|
{% for p in projects %}
|
||||||
|
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Contact</label>
|
||||||
|
<select name="contact_id" class="form-select">
|
||||||
|
<option value="">None</option>
|
||||||
|
{% for c in contacts %}
|
||||||
|
<option value="{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions" style="margin-top: 8px;">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Start Run</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleEditStep(stepId) {
|
||||||
|
var el = document.getElementById('edit-step-' + stepId);
|
||||||
|
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
57
templates/processes_form.html
Normal file
57
templates/processes_form.html
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="page-title">{{ page_title }}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<form method="post" action="{{ '/processes/' ~ item.id ~ '/edit' if item else '/processes/create' }}">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label class="form-label">Name *</label>
|
||||||
|
<input type="text" name="name" class="form-input" required
|
||||||
|
value="{{ item.name if item else '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group full-width">
|
||||||
|
<label class="form-label">Description</label>
|
||||||
|
<textarea name="description" class="form-textarea" rows="3">{{ item.description if item and item.description else '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Type</label>
|
||||||
|
<select name="process_type" class="form-select">
|
||||||
|
<option value="checklist" {{ 'selected' if item and item.process_type == 'checklist' }}>Checklist</option>
|
||||||
|
<option value="workflow" {{ 'selected' if item and item.process_type == 'workflow' }}>Workflow</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Status</label>
|
||||||
|
<select name="status" class="form-select">
|
||||||
|
<option value="draft" {{ 'selected' if item and item.status == 'draft' }}>Draft</option>
|
||||||
|
<option value="active" {{ 'selected' if item and item.status == 'active' }}>Active</option>
|
||||||
|
<option value="archived" {{ 'selected' if item and item.status == 'archived' }}>Archived</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Category</label>
|
||||||
|
<input type="text" name="category" class="form-input" placeholder="e.g. Onboarding, Publishing..."
|
||||||
|
value="{{ item.category if item and item.category else '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Tags</label>
|
||||||
|
<input type="text" name="tags" class="form-input" placeholder="tag1, tag2, ..."
|
||||||
|
value="{{ item.tags|join(', ') if item and item.tags else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Create Process' }}</button>
|
||||||
|
<a href="{{ '/processes/' ~ item.id if item else '/processes' }}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -21,10 +21,10 @@
|
|||||||
<span class="detail-meta-item"><span class="priority-dot priority-{{ item.priority }}"></span> P{{ item.priority }}</span>
|
<span class="detail-meta-item"><span class="priority-dot priority-{{ item.priority }}"></span> P{{ item.priority }}</span>
|
||||||
{% if item.target_date %}<span class="detail-meta-item">Target: {{ item.target_date }}</span>{% endif %}
|
{% if item.target_date %}<span class="detail-meta-item">Target: {{ item.target_date }}</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2" style="max-width: 300px;">
|
<a href="/tasks/?project_id={{ item.id }}" class="stat-card-link" style="max-width: 300px; display: block; margin-top: 8px;">
|
||||||
<div class="progress-bar"><div class="progress-fill" style="width: {{ progress }}%"></div></div>
|
<div class="progress-bar"><div class="progress-fill" style="width: {{ progress }}%"></div></div>
|
||||||
<div class="progress-text">{{ done_count }}/{{ task_count }} tasks complete ({{ progress }}%)</div>
|
<div class="progress-text">{{ done_count }}/{{ task_count }} tasks complete ({{ progress }}%)</div>
|
||||||
</div>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if item.description %}
|
{% if item.description %}
|
||||||
@@ -34,8 +34,14 @@
|
|||||||
<!-- 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=meetings" class="tab-item {{ 'active' if tab == 'meetings' }}">Meetings{% if counts.meetings %} ({{ counts.meetings }}){% 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 +97,106 @@
|
|||||||
{% 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 == 'meetings' %}
|
||||||
|
<a href="/meetings/create" class="btn btn-ghost btn-sm mb-3">+ New Meeting</a>
|
||||||
|
<div class="card mb-4">
|
||||||
|
<form action="/projects/{{ item.id }}/meetings/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">Meeting</label>
|
||||||
|
<select name="meeting_id" class="form-select" required>
|
||||||
|
<option value="">Select meeting...</option>
|
||||||
|
{% for m in all_meetings %}
|
||||||
|
<option value="{{ m.id }}">{{ m.title }} ({{ m.meeting_date }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% for m in tab_data %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="row-title"><a href="/meetings/{{ m.id }}">{{ m.title }}</a></span>
|
||||||
|
<span class="row-meta">{{ m.meeting_date }}</span>
|
||||||
|
<span class="status-badge status-{{ m.status }}">{{ m.status }}</span>
|
||||||
|
<div class="row-actions">
|
||||||
|
<form action="/projects/{{ item.id }}/meetings/{{ m.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 meetings 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=links" class="tab-item {{ 'active' if tab == 'links' }}">Links{% if counts.links %} ({{ counts.links }}){% 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 == 'links' %}
|
||||||
|
<a href="/links/create?task_id={{ item.id }}" class="btn btn-ghost btn-sm mb-3">+ New Link</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 links 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 %}
|
||||||
|
|||||||
@@ -53,6 +53,9 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div class="list-row {{ 'completed' if item.status in ['done', 'cancelled'] }} {{ 'timer-active' if running_task_id and item.id|string == running_task_id }}">
|
<div class="list-row {{ 'completed' if item.status in ['done', 'cancelled'] }} {{ 'timer-active' if running_task_id and item.id|string == running_task_id }}">
|
||||||
|
{% with reorder_url="/tasks/reorder", item_id=item.id %}
|
||||||
|
{% include 'partials/reorder_arrows.html' %}
|
||||||
|
{% endwith %}
|
||||||
<div class="row-check">
|
<div class="row-check">
|
||||||
<form action="/tasks/{{ item.id }}/toggle" method="post" style="display:inline">
|
<form action="/tasks/{{ item.id }}/toggle" method="post" style="display:inline">
|
||||||
<input type="checkbox" id="check-{{ item.id }}" {{ 'checked' if item.status == 'done' }}
|
<input type="checkbox" id="check-{{ item.id }}" {{ 'checked' if item.status == 'done' }}
|
||||||
|
|||||||
76
templates/time_budgets.html
Normal file
76
templates/time_budgets.html
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">Time Budgets <span class="page-count">({{ count }})</span></h1>
|
||||||
|
</div>
|
||||||
|
<a href="/time-budgets/create" class="btn btn-primary">+ New Budget</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if overcommitted %}
|
||||||
|
<div class="alert alert-warning mb-3">
|
||||||
|
<strong>Overcommitted!</strong> Your budgets total {{ "%.1f"|format(total_budgeted) }} hours/week, which exceeds the 168 hours available.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if current_budgets %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">This Week's Budget vs Actual</span>
|
||||||
|
<span class="text-muted text-sm">{{ "%.1f"|format(total_budgeted) }}h budgeted total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for b in current_budgets %}
|
||||||
|
<div class="list-row" style="flex-wrap: wrap; gap: 8px;">
|
||||||
|
<span class="domain-dot" style="background: {{ b.domain_color or '#4F6EF7' }}; flex-shrink: 0;"></span>
|
||||||
|
<div class="row-title" style="min-width: 120px;">
|
||||||
|
{{ b.domain_name }}
|
||||||
|
</div>
|
||||||
|
<div style="flex: 2; min-width: 200px; display: flex; align-items: center; gap: 8px;">
|
||||||
|
<div class="progress-bar" style="flex: 1; height: 8px;">
|
||||||
|
<div class="progress-fill" style="width: {{ [b.pct, 100] | min }}%; {{ 'background: var(--red);' if b.pct > 100 }}"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="row-meta" style="min-width: 100px; text-align: right;">
|
||||||
|
<strong>{{ b.actual_hours }}h</strong> / {{ b.weekly_hours_float }}h
|
||||||
|
</span>
|
||||||
|
<span class="row-meta" style="min-width: 40px; text-align: right; {{ 'color: var(--red); font-weight: 600;' if b.pct > 100 else ('color: var(--green);' if b.pct >= 80 else '') }}">
|
||||||
|
{{ b.pct }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if all_budgets %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">All Budgets</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for b in all_budgets %}
|
||||||
|
<div class="list-row">
|
||||||
|
<span class="domain-dot" style="background: {{ b.domain_color or '#4F6EF7' }}; flex-shrink: 0;"></span>
|
||||||
|
<div class="row-title">
|
||||||
|
{{ b.domain_name }}
|
||||||
|
</div>
|
||||||
|
<span class="row-meta">{{ b.weekly_hours }}h / week</span>
|
||||||
|
<span class="row-meta">from {{ b.effective_from.strftime('%b %-d, %Y') if b.effective_from else '—' }}</span>
|
||||||
|
<div class="row-actions">
|
||||||
|
<a href="/time-budgets/{{ b.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
||||||
|
<form method="POST" action="/time-budgets/{{ b.id }}/delete" data-confirm="Delete this budget?">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red);">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">⏱</div>
|
||||||
|
<div class="empty-state-text">No time budgets defined</div>
|
||||||
|
<a href="/time-budgets/create" class="btn btn-primary">Create a Budget</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
47
templates/time_budgets_form.html
Normal file
47
templates/time_budgets_form.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="page-title">{{ 'Edit Time Budget' if budget else 'New Time Budget' }}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="max-width: 600px;">
|
||||||
|
<form method="POST" action="{{ '/time-budgets/' ~ budget.id ~ '/edit' if budget else '/time-budgets/create' }}">
|
||||||
|
<div class="form-grid" style="grid-template-columns: 1fr;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Domain *</label>
|
||||||
|
<select name="domain_id" class="form-select" required>
|
||||||
|
<option value="">Select domain...</option>
|
||||||
|
{% for d in domains %}
|
||||||
|
<option value="{{ d.id }}" {{ 'selected' if budget and budget.domain_id|string == d.id|string }}>
|
||||||
|
{{ d.name }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Weekly Hours *</label>
|
||||||
|
<input type="number" name="weekly_hours" class="form-input"
|
||||||
|
value="{{ budget.weekly_hours if budget else '' }}"
|
||||||
|
min="0" max="168" step="0.5" required
|
||||||
|
placeholder="e.g. 10">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Effective From *</label>
|
||||||
|
<input type="date" name="effective_from" class="form-input"
|
||||||
|
value="{{ budget.effective_from.strftime('%Y-%m-%d') if budget and budget.effective_from else '' }}"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if budget else 'Create Budget' }}</button>
|
||||||
|
<a href="/time-budgets" class="btn btn-secondary">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
{% for entry in entries %}
|
{% for entry in entries %}
|
||||||
{% set entry_date = entry.start_at.strftime('%A, %B %-d') if entry.start_at else 'Unknown' %}
|
{% set entry_date = entry.start_at.strftime('%A, %B %-d') if entry.start_at else 'Unknown' %}
|
||||||
{% if entry_date != current_date.value %}
|
{% if entry_date != current_date.value %}
|
||||||
<div style="padding: 10px 12px 4px; font-size: 0.78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; {% if not loop.first %}border-top: 1px solid var(--border); margin-top: 4px;{% endif %}">
|
<div style="padding: 6px 12px 4px; font-size: 0.78rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; {% if not loop.first %}border-top: 1px solid var(--border); margin-top: 4px;{% endif %}">
|
||||||
{{ entry_date }}
|
{{ entry_date }}
|
||||||
</div>
|
</div>
|
||||||
{% set current_date.value = entry_date %}
|
{% set current_date.value = entry_date %}
|
||||||
|
|||||||
@@ -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 Link' }}</button>
|
||||||
<a href="/weblinks" class="btn btn-secondary">Cancel</a>
|
<a href="{{ '/tasks/' ~ prefill_task_id ~ '?tab=links' if prefill_task_id is defined and prefill_task_id else '/weblinks' }}" class="btn btn-secondary">Cancel</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="page-title">Weblinks<span class="page-count">{{ items|length }}</span></h1>
|
<h1 class="page-title">Bookmarks<span class="page-count">{{ items|length }}</span></h1>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="/weblinks/folders/create" class="btn btn-secondary">+ New Folder</a>
|
<a href="/weblinks/folders/create" class="btn btn-secondary">+ New Folder</a>
|
||||||
<a href="/weblinks/create{{ '?folder_id=' ~ current_folder_id if current_folder_id }}" class="btn btn-primary">+ New Weblink</a>
|
<a href="/weblinks/create{{ '?folder_id=' ~ current_folder_id if current_folder_id }}" class="btn btn-primary">+ New Link</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<!-- Folder sidebar -->
|
<!-- Folder sidebar -->
|
||||||
<div class="weblinks-folders">
|
<div class="weblinks-folders">
|
||||||
<a href="/weblinks" class="weblink-folder-item {{ 'active' if not current_folder_id }}">
|
<a href="/weblinks" class="weblink-folder-item {{ 'active' if not current_folder_id }}">
|
||||||
All Weblinks
|
All Links
|
||||||
</a>
|
</a>
|
||||||
{% for folder in top_folders %}
|
{% for folder in top_folders %}
|
||||||
<a href="/weblinks?folder_id={{ folder.id }}" class="weblink-folder-item {{ 'active' if current_folder_id == folder.id|string }}">
|
<a href="/weblinks?folder_id={{ folder.id }}" class="weblink-folder-item {{ 'active' if current_folder_id == folder.id|string }}">
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Weblinks list -->
|
<!-- Links list -->
|
||||||
<div class="weblinks-content">
|
<div class="weblinks-content">
|
||||||
{% if current_folder %}
|
{% if current_folder %}
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
@@ -38,6 +38,23 @@
|
|||||||
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Delete Folder</button>
|
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Delete Folder</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{% if available_links %}
|
||||||
|
<div class="card" style="margin-bottom: 12px;">
|
||||||
|
<form action="/weblinks/folders/{{ current_folder.id }}/add-link" method="post"
|
||||||
|
style="display: flex; gap: 8px; align-items: end; padding: 12px;">
|
||||||
|
<div class="form-group" style="flex: 1; margin: 0;">
|
||||||
|
<label class="form-label">Add existing link</label>
|
||||||
|
<select name="link_id" class="form-select" required>
|
||||||
|
<option value="">Select link...</option>
|
||||||
|
{% for l in available_links %}
|
||||||
|
<option value="{{ l.id }}">{{ l.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if items %}
|
{% if items %}
|
||||||
@@ -56,8 +73,20 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
|
{% if current_folder_id %}
|
||||||
|
<form action="/weblinks/folders/{{ current_folder_id }}/reorder" method="post" style="display:inline">
|
||||||
|
<input type="hidden" name="link_id" value="{{ item.id }}">
|
||||||
|
<input type="hidden" name="direction" value="up">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-xs" title="Move up">▲</button>
|
||||||
|
</form>
|
||||||
|
<form action="/weblinks/folders/{{ current_folder_id }}/reorder" method="post" style="display:inline">
|
||||||
|
<input type="hidden" name="link_id" value="{{ item.id }}">
|
||||||
|
<input type="hidden" name="direction" value="down">
|
||||||
|
<button type="submit" class="btn btn-ghost btn-xs" title="Move down">▼</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
<a href="/weblinks/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
<a href="/weblinks/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
|
||||||
<form action="/weblinks/{{ item.id }}/delete" method="post" data-confirm="Delete this weblink?" style="display:inline">
|
<form action="/weblinks/{{ item.id }}/delete" method="post" data-confirm="Delete this link?" style="display:inline">
|
||||||
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,8 +96,8 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-state-icon">🔗</div>
|
<div class="empty-state-icon">🔗</div>
|
||||||
<div class="empty-state-text">No weblinks{{ ' in this folder' if current_folder }} yet</div>
|
<div class="empty-state-text">No links{{ ' in this folder' if current_folder }} yet</div>
|
||||||
<a href="/weblinks/create{{ '?folder_id=' ~ current_folder_id if current_folder_id }}" class="btn btn-primary">Add Weblink</a>
|
<a href="/weblinks/create{{ '?folder_id=' ~ current_folder_id if current_folder_id }}" class="btn btn-primary">Add Link</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,12 +23,16 @@ SEED_IDS = {
|
|||||||
"meeting": "a0000000-0000-0000-0000-000000000007",
|
"meeting": "a0000000-0000-0000-0000-000000000007",
|
||||||
"decision": "a0000000-0000-0000-0000-000000000008",
|
"decision": "a0000000-0000-0000-0000-000000000008",
|
||||||
"appointment": "a0000000-0000-0000-0000-000000000009",
|
"appointment": "a0000000-0000-0000-0000-000000000009",
|
||||||
"weblink_folder": "a0000000-0000-0000-0000-00000000000a",
|
"link_folder": "a0000000-0000-0000-0000-00000000000a",
|
||||||
"list": "a0000000-0000-0000-0000-00000000000b",
|
"list": "a0000000-0000-0000-0000-00000000000b",
|
||||||
"link": "a0000000-0000-0000-0000-00000000000c",
|
"link": "a0000000-0000-0000-0000-00000000000c",
|
||||||
"weblink": "a0000000-0000-0000-0000-00000000000d",
|
|
||||||
"capture": "a0000000-0000-0000-0000-00000000000e",
|
"capture": "a0000000-0000-0000-0000-00000000000e",
|
||||||
"focus": "a0000000-0000-0000-0000-00000000000f",
|
"focus": "a0000000-0000-0000-0000-00000000000f",
|
||||||
|
"process": "a0000000-0000-0000-0000-000000000010",
|
||||||
|
"process_step": "a0000000-0000-0000-0000-000000000011",
|
||||||
|
"process_run": "a0000000-0000-0000-0000-000000000012",
|
||||||
|
"time_budget": "a0000000-0000-0000-0000-000000000013",
|
||||||
|
"file": "a0000000-0000-0000-0000-000000000014",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -148,12 +152,12 @@ def all_seeds(sync_conn):
|
|||||||
ON CONFLICT (id) DO NOTHING
|
ON CONFLICT (id) DO NOTHING
|
||||||
""", (d["appointment"],))
|
""", (d["appointment"],))
|
||||||
|
|
||||||
# Weblink folder
|
# Link folder
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO weblink_folders (id, name, is_deleted, created_at, updated_at)
|
INSERT INTO link_folders (id, name, is_deleted, created_at, updated_at)
|
||||||
VALUES (%s, 'Test Folder', false, now(), now())
|
VALUES (%s, 'Test Folder', false, now(), now())
|
||||||
ON CONFLICT (id) DO NOTHING
|
ON CONFLICT (id) DO NOTHING
|
||||||
""", (d["weblink_folder"],))
|
""", (d["link_folder"],))
|
||||||
|
|
||||||
# List
|
# List
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -169,18 +173,11 @@ def all_seeds(sync_conn):
|
|||||||
ON CONFLICT (id) DO NOTHING
|
ON CONFLICT (id) DO NOTHING
|
||||||
""", (d["link"], d["domain"]))
|
""", (d["link"], d["domain"]))
|
||||||
|
|
||||||
# Weblink
|
# Link folder junction
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO weblinks (id, label, url, is_deleted, created_at, updated_at)
|
INSERT INTO folder_links (folder_id, link_id)
|
||||||
VALUES (%s, 'Test Weblink', 'https://example.com/wl', false, now(), now())
|
|
||||||
ON CONFLICT (id) DO NOTHING
|
|
||||||
""", (d["weblink"],))
|
|
||||||
|
|
||||||
# Link weblink to folder via junction table
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO folder_weblinks (folder_id, weblink_id)
|
|
||||||
VALUES (%s, %s) ON CONFLICT DO NOTHING
|
VALUES (%s, %s) ON CONFLICT DO NOTHING
|
||||||
""", (d["weblink_folder"], d["weblink"]))
|
""", (d["link_folder"], d["link"]))
|
||||||
|
|
||||||
# Capture
|
# Capture
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
@@ -196,6 +193,67 @@ 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
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO processes (id, name, process_type, status, category, is_deleted, created_at, updated_at)
|
||||||
|
VALUES (%s, 'Test Process', 'checklist', 'active', 'Testing', false, now(), now())
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""", (d["process"],))
|
||||||
|
|
||||||
|
# Process step
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO process_steps (id, process_id, title, instructions, sort_order, is_deleted, created_at, updated_at)
|
||||||
|
VALUES (%s, %s, 'Test Step', 'Do the thing', 0, false, now(), now())
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""", (d["process_step"], d["process"]))
|
||||||
|
|
||||||
|
# Process run
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO process_runs (id, process_id, title, status, process_type, task_generation, is_deleted, created_at, updated_at)
|
||||||
|
VALUES (%s, %s, 'Test Run', 'not_started', 'checklist', 'all_at_once', false, now(), now())
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""", (d["process_run"], d["process"]))
|
||||||
|
|
||||||
|
# Time budget
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO time_budgets (id, domain_id, weekly_hours, effective_from, is_deleted, created_at, updated_at)
|
||||||
|
VALUES (%s, %s, 10, CURRENT_DATE, false, now(), now())
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""", (d["time_budget"], d["domain"]))
|
||||||
|
|
||||||
|
# File (create a dummy file on disk for download/serve tests)
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
file_storage = os.getenv("FILE_STORAGE_PATH", "/opt/lifeos/files/dev")
|
||||||
|
Path(file_storage).mkdir(parents=True, exist_ok=True)
|
||||||
|
dummy_file_path = os.path.join(file_storage, "test_seed_file.txt")
|
||||||
|
with open(dummy_file_path, "w") as f:
|
||||||
|
f.write("test seed file content")
|
||||||
|
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO files (id, filename, original_filename, storage_path, mime_type, size_bytes, is_deleted, created_at, updated_at)
|
||||||
|
VALUES (%s, 'test_seed_file.txt', 'test_seed_file.txt', %s, 'text/plain', 22, false, now(), now())
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""", (d["file"], dummy_file_path))
|
||||||
|
|
||||||
sync_conn.commit()
|
sync_conn.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sync_conn.rollback()
|
sync_conn.rollback()
|
||||||
@@ -205,13 +263,24 @@ 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"],))
|
||||||
|
if os.path.exists(dummy_file_path):
|
||||||
|
os.remove(dummy_file_path)
|
||||||
|
cur.execute("DELETE FROM time_budgets WHERE id = %s", (d["time_budget"],))
|
||||||
|
cur.execute("DELETE FROM process_runs WHERE id = %s", (d["process_run"],))
|
||||||
|
cur.execute("DELETE FROM process_steps WHERE id = %s", (d["process_step"],))
|
||||||
|
cur.execute("DELETE FROM processes WHERE id = %s", (d["process"],))
|
||||||
cur.execute("DELETE FROM daily_focus WHERE id = %s", (d["focus"],))
|
cur.execute("DELETE FROM daily_focus WHERE id = %s", (d["focus"],))
|
||||||
cur.execute("DELETE FROM capture WHERE id = %s", (d["capture"],))
|
cur.execute("DELETE FROM capture WHERE id = %s", (d["capture"],))
|
||||||
cur.execute("DELETE FROM folder_weblinks WHERE weblink_id = %s", (d["weblink"],))
|
cur.execute("DELETE FROM folder_links WHERE link_id = %s", (d["link"],))
|
||||||
cur.execute("DELETE FROM weblinks WHERE id = %s", (d["weblink"],))
|
|
||||||
cur.execute("DELETE FROM links WHERE id = %s", (d["link"],))
|
cur.execute("DELETE FROM links WHERE id = %s", (d["link"],))
|
||||||
cur.execute("DELETE FROM lists WHERE id = %s", (d["list"],))
|
cur.execute("DELETE FROM lists WHERE id = %s", (d["list"],))
|
||||||
cur.execute("DELETE FROM weblink_folders WHERE id = %s", (d["weblink_folder"],))
|
cur.execute("DELETE FROM link_folders WHERE id = %s", (d["link_folder"],))
|
||||||
cur.execute("DELETE FROM appointments WHERE id = %s", (d["appointment"],))
|
cur.execute("DELETE FROM appointments WHERE id = %s", (d["appointment"],))
|
||||||
cur.execute("DELETE FROM decisions WHERE id = %s", (d["decision"],))
|
cur.execute("DELETE FROM decisions WHERE id = %s", (d["decision"],))
|
||||||
cur.execute("DELETE FROM meetings WHERE id = %s", (d["meeting"],))
|
cur.execute("DELETE FROM meetings WHERE id = %s", (d["meeting"],))
|
||||||
@@ -273,3 +342,11 @@ def seed_note(all_seeds):
|
|||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def seed_meeting(all_seeds):
|
def seed_meeting(all_seeds):
|
||||||
return {"id": all_seeds["meeting"], "title": "Test Meeting"}
|
return {"id": all_seeds["meeting"], "title": "Test Meeting"}
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def seed_list(all_seeds):
|
||||||
|
return {"id": all_seeds["list"], "name": "Test List"}
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def seed_appointment(all_seeds):
|
||||||
|
return {"id": all_seeds["appointment"], "title": "Test Appointment"}
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ FK_FIELD_MAP = {
|
|||||||
"release_id": None,
|
"release_id": None,
|
||||||
"note_id": "note",
|
"note_id": "note",
|
||||||
"list_id": "list",
|
"list_id": "list",
|
||||||
|
"process_id": "process",
|
||||||
|
"run_id": "process_run",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Field name pattern -> static test value
|
# Field name pattern -> static test value
|
||||||
@@ -76,6 +78,8 @@ NAME_PATTERNS: list[tuple[str, Any]] = [
|
|||||||
("location", "Test Location"),
|
("location", "Test Location"),
|
||||||
("tags", ""),
|
("tags", ""),
|
||||||
("notes", "Test notes field"),
|
("notes", "Test notes field"),
|
||||||
|
("weekly_hours", "10"),
|
||||||
|
("effective_from", None), # Resolved dynamically as date
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,14 +35,20 @@ PREFIX_TO_SEED = {
|
|||||||
"/lists": "list",
|
"/lists": "list",
|
||||||
"/meetings": "meeting",
|
"/meetings": "meeting",
|
||||||
"/decisions": "decision",
|
"/decisions": "decision",
|
||||||
"/weblinks": "weblink",
|
"/weblinks": "link",
|
||||||
"/weblinks/folders": "weblink_folder",
|
"/weblinks/folders": "link_folder",
|
||||||
"/appointments": "appointment",
|
"/appointments": "appointment",
|
||||||
"/focus": "focus",
|
"/focus": "focus",
|
||||||
"/capture": "capture",
|
"/capture": "capture",
|
||||||
"/time": "task",
|
"/time": "task",
|
||||||
"/files": None,
|
"/processes": "process",
|
||||||
|
"/processes/runs": "process_run",
|
||||||
|
"/time-budgets": "time_budget",
|
||||||
|
"/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):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -33,10 +33,13 @@ from tests.form_factory import build_form_data, build_edit_data
|
|||||||
_CREATE_ROUTES = [r for r in ALL_ROUTES if r.kind == RouteKind.CREATE and not r.has_file_upload]
|
_CREATE_ROUTES = [r for r in ALL_ROUTES if r.kind == RouteKind.CREATE and not r.has_file_upload]
|
||||||
_EDIT_ROUTES = [r for r in ALL_ROUTES if r.kind == RouteKind.EDIT and not r.has_file_upload]
|
_EDIT_ROUTES = [r for r in ALL_ROUTES if r.kind == RouteKind.EDIT and not r.has_file_upload]
|
||||||
_DELETE_ROUTES = [r for r in ALL_ROUTES if r.kind == RouteKind.DELETE]
|
_DELETE_ROUTES = [r for r in ALL_ROUTES if r.kind == RouteKind.DELETE]
|
||||||
_ACTION_ROUTES = [r for r in ALL_ROUTES if r.kind in (RouteKind.ACTION, RouteKind.TOGGLE)]
|
# Admin trash actions are excluded here — covered by TestAdminTrashLifecycle in test_business_logic.py
|
||||||
|
_ADMIN_TRASH_PATHS = {
|
||||||
# Destructive actions that wipe data other tests depend on
|
"/admin/trash/empty",
|
||||||
_DESTRUCTIVE_ACTIONS = {"/admin/trash/empty", "/admin/trash/{table}/{item_id}/permanent-delete"}
|
"/admin/trash/{table}/{item_id}/permanent-delete",
|
||||||
|
"/admin/trash/{table}/{item_id}/restore",
|
||||||
|
}
|
||||||
|
_ACTION_ROUTES = [r for r in ALL_ROUTES if r.kind in (RouteKind.ACTION, RouteKind.TOGGLE) and r.path not in _ADMIN_TRASH_PATHS]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -97,10 +100,6 @@ async def test_edit_redirects(client: AsyncClient, all_seeds: dict, route):
|
|||||||
)
|
)
|
||||||
async def test_action_does_not_crash(client: AsyncClient, all_seeds: dict, route):
|
async def test_action_does_not_crash(client: AsyncClient, all_seeds: dict, route):
|
||||||
"""POST action routes should not return 500."""
|
"""POST action routes should not return 500."""
|
||||||
# Skip destructive actions that would wipe seed data
|
|
||||||
if route.path in _DESTRUCTIVE_ACTIONS:
|
|
||||||
pytest.skip(f"Skipping destructive action {route.path}")
|
|
||||||
|
|
||||||
resolved = resolve_path(route.path, all_seeds)
|
resolved = resolve_path(route.path, all_seeds)
|
||||||
if "{" in resolved:
|
if "{" in resolved:
|
||||||
pytest.skip(f"No seed data mapping for {route.path}")
|
pytest.skip(f"No seed data mapping for {route.path}")
|
||||||
|
|||||||
102
tests/test_mobile_nav.py
Normal file
102
tests/test_mobile_nav.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"""Tests that the mobile navigation bar is correctly structured and positioned."""
|
||||||
|
import re
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient, ASGITransport
|
||||||
|
|
||||||
|
from tests.registry import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client():
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_nav_exists(client):
|
||||||
|
"""mob-nav element exists in page output."""
|
||||||
|
resp = await client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert 'class="mob-nav"' in resp.text, "mob-nav not found in HTML"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_nav_is_direct_body_child(client):
|
||||||
|
"""mob-nav must be a direct child of body, not nested in any container."""
|
||||||
|
resp = await client.get("/")
|
||||||
|
html = resp.text
|
||||||
|
mob_idx = html.find('id="mobNav"')
|
||||||
|
body_close = html.find('</body>')
|
||||||
|
assert mob_idx != -1, "mobNav not found"
|
||||||
|
assert body_close != -1, "</body> not found"
|
||||||
|
between = html[mob_idx:body_close]
|
||||||
|
assert between.count('</main>') == 0, "mob-nav appears to be inside <main>"
|
||||||
|
assert between.count('</div></div></div>') == 0, "mob-nav appears deeply nested"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_nav_has_five_items(client):
|
||||||
|
"""Bottom bar must have exactly 5 navigation items (4 links + 1 button)."""
|
||||||
|
resp = await client.get("/")
|
||||||
|
html = resp.text
|
||||||
|
start = html.find('id="mobNav"')
|
||||||
|
assert start != -1
|
||||||
|
# Scope to just the mob-nav element (ends at first </div> after it)
|
||||||
|
end = html.find('</div>', start)
|
||||||
|
chunk = html[start:end]
|
||||||
|
links = len(re.findall(r'<a\b', chunk))
|
||||||
|
buttons = len(re.findall(r'<button\b', chunk))
|
||||||
|
assert links == 4, f"Expected 4 link items, found {links}"
|
||||||
|
assert buttons == 1, f"Expected 1 button item, found {buttons}"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_nav_has_inline_fixed_position(client):
|
||||||
|
"""mob-nav must have position:fixed as an inline style for maximum reliability."""
|
||||||
|
resp = await client.get("/")
|
||||||
|
assert 'id="mobNav" style="position:fixed' in resp.text, \
|
||||||
|
"mob-nav missing inline position:fixed style"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_nav_css_has_fixed_position(client):
|
||||||
|
"""CSS must include position:fixed for mob-nav."""
|
||||||
|
css_resp = await client.get("/static/style.css")
|
||||||
|
css = css_resp.text
|
||||||
|
assert "position: fixed" in css or "position:fixed" in css, \
|
||||||
|
"No position:fixed found in CSS"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_nav_inline_style_in_head(client):
|
||||||
|
"""Critical mob-nav styles must be inlined in <head> as a fallback."""
|
||||||
|
resp = await client.get("/")
|
||||||
|
html = resp.text
|
||||||
|
head_end = html.find('</head>')
|
||||||
|
head = html[:head_end]
|
||||||
|
assert '.mob-nav' in head, "No inline mob-nav styles found in <head>"
|
||||||
|
assert 'position:fixed' in head, "No position:fixed in inline <head> styles"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_nav_not_inside_transformed_parent(client):
|
||||||
|
"""No ancestor of mob-nav should have transform that breaks position:fixed."""
|
||||||
|
resp = await client.get("/")
|
||||||
|
html = resp.text
|
||||||
|
mob_idx = html.find('id="mobNav"')
|
||||||
|
body_start = html.find('<body')
|
||||||
|
prefix = html[body_start:mob_idx]
|
||||||
|
opens = len(re.findall(r'<div\b[^>]*>', prefix))
|
||||||
|
closes = prefix.count('</div>')
|
||||||
|
nesting = opens - closes
|
||||||
|
assert nesting <= 1, \
|
||||||
|
f"mob-nav is nested {nesting} divs deep - must be 0 or 1 (direct body child)"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mob_nav_present_on_all_pages(client):
|
||||||
|
"""mob-nav should appear on every page, not just dashboard."""
|
||||||
|
for path in ["/", "/tasks/", "/focus/", "/capture/", "/contacts/"]:
|
||||||
|
resp = await client.get(path)
|
||||||
|
assert 'id="mobNav"' in resp.text, f"mob-nav missing on {path}"
|
||||||
BIN
webdav/Business/Hetzner VM/lifeos-webdav-setup-guide.docx
Normal file
BIN
webdav/Business/Hetzner VM/lifeos-webdav-setup-guide.docx
Normal file
Binary file not shown.
Reference in New Issue
Block a user