diff --git a/core/base_repository.py b/core/base_repository.py index a13d987..3f6be32 100644 --- a/core/base_repository.py +++ b/core/base_repository.py @@ -125,6 +125,9 @@ class BaseRepository: "parent_id", "parent_item_id", "release_id", "due_date", "deadline", "tags", "context", "folder_id", "meeting_id", "completed_at", "waiting_for_contact_id", "waiting_since", "color", + "rationale", "decided_at", "superseded_by_id", + "start_at", "end_at", "location", "agenda", "transcript", "notes_body", + "priority", "recurrence", "mime_type", } clean_data = {} for k, v in data.items(): diff --git a/main.py b/main.py index d518def..9d8f3ec 100644 --- a/main.py +++ b/main.py @@ -33,6 +33,9 @@ from routers import ( search as search_router, admin as admin_router, lists as lists_router, + files as files_router, + meetings as meetings_router, + decisions as decisions_router, ) @@ -173,3 +176,6 @@ app.include_router(contacts_router.router) app.include_router(search_router.router) app.include_router(admin_router.router) app.include_router(lists_router.router) +app.include_router(files_router.router) +app.include_router(meetings_router.router) +app.include_router(decisions_router.router) diff --git a/routers/admin.py b/routers/admin.py index 17105df..d4d8a51 100644 --- a/routers/admin.py +++ b/routers/admin.py @@ -24,6 +24,9 @@ TRASH_ENTITIES = [ {"table": "domains", "label": "Domains", "name_col": "name", "url": "/domains"}, {"table": "areas", "label": "Areas", "name_col": "name", "url": "/areas"}, {"table": "lists", "label": "Lists", "name_col": "name", "url": "/lists/{id}"}, + {"table": "meetings", "label": "Meetings", "name_col": "title", "url": "/meetings/{id}"}, + {"table": "decisions", "label": "Decisions", "name_col": "title", "url": "/decisions/{id}"}, + {"table": "files", "label": "Files", "name_col": "original_filename", "url": "/files"}, {"table": "capture", "label": "Capture", "name_col": "raw_text", "url": "/capture"}, {"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"}, ] diff --git a/routers/decisions.py b/routers/decisions.py new file mode 100644 index 0000000..5447b65 --- /dev/null +++ b/routers/decisions.py @@ -0,0 +1,211 @@ +"""Decisions: knowledge base of decisions with rationale, status, and supersession.""" + +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 core.database import get_db +from core.base_repository import BaseRepository +from core.sidebar import get_sidebar_data + +router = APIRouter(prefix="/decisions", tags=["decisions"]) +templates = Jinja2Templates(directory="templates") + + +@router.get("/") +async def list_decisions( + request: Request, + status: Optional[str] = None, + impact: Optional[str] = None, + db: AsyncSession = Depends(get_db), +): + sidebar = await get_sidebar_data(db) + + where_clauses = ["d.is_deleted = false"] + params = {} + if status: + where_clauses.append("d.status = :status") + params["status"] = status + if impact: + where_clauses.append("d.impact = :impact") + params["impact"] = impact + + where_sql = " AND ".join(where_clauses) + + result = await db.execute(text(f""" + SELECT d.*, m.title as meeting_title + FROM decisions d + LEFT JOIN meetings m ON d.meeting_id = m.id + WHERE {where_sql} + ORDER BY d.created_at DESC + """), params) + items = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("decisions.html", { + "request": request, "sidebar": sidebar, "items": items, + "current_status": status or "", + "current_impact": impact or "", + "page_title": "Decisions", "active_nav": "decisions", + }) + + +@router.get("/create") +async def create_form( + request: Request, + meeting_id: Optional[str] = None, + db: AsyncSession = Depends(get_db), +): + sidebar = await get_sidebar_data(db) + # Meetings for linking + result = await db.execute(text(""" + SELECT id, title, meeting_date FROM meetings + WHERE is_deleted = false ORDER BY meeting_date DESC LIMIT 50 + """)) + meetings = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("decision_form.html", { + "request": request, "sidebar": sidebar, + "meetings": meetings, + "page_title": "New Decision", "active_nav": "decisions", + "item": None, + "prefill_meeting_id": meeting_id or "", + }) + + +@router.post("/create") +async def create_decision( + request: Request, + title: str = Form(...), + rationale: Optional[str] = Form(None), + status: str = Form("proposed"), + impact: str = Form("medium"), + decided_at: Optional[str] = Form(None), + meeting_id: Optional[str] = Form(None), + tags: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + repo = BaseRepository("decisions", db) + data = { + "title": title, "status": status, "impact": impact, + "rationale": rationale, + } + if decided_at and decided_at.strip(): + data["decided_at"] = decided_at + 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()] + + decision = await repo.create(data) + return RedirectResponse(url=f"/decisions/{decision['id']}", status_code=303) + + +@router.get("/{decision_id}") +async def decision_detail(decision_id: str, request: Request, db: AsyncSession = Depends(get_db)): + repo = BaseRepository("decisions", db) + sidebar = await get_sidebar_data(db) + item = await repo.get(decision_id) + if not item: + return RedirectResponse(url="/decisions", status_code=303) + + # Meeting info + meeting = None + if item.get("meeting_id"): + result = await db.execute(text( + "SELECT id, title, meeting_date FROM meetings WHERE id = :id" + ), {"id": str(item["meeting_id"])}) + row = result.first() + meeting = dict(row._mapping) if row else None + + # Superseded by + superseded_by = None + if item.get("superseded_by_id"): + result = await db.execute(text( + "SELECT id, title FROM decisions WHERE id = :id" + ), {"id": str(item["superseded_by_id"])}) + row = result.first() + superseded_by = dict(row._mapping) if row else None + + # Decisions that this one supersedes + result = await db.execute(text(""" + SELECT id, title FROM decisions + WHERE superseded_by_id = :did AND is_deleted = false + """), {"did": decision_id}) + supersedes = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("decision_detail.html", { + "request": request, "sidebar": sidebar, "item": item, + "meeting": meeting, "superseded_by": superseded_by, + "supersedes": supersedes, + "page_title": item["title"], "active_nav": "decisions", + }) + + +@router.get("/{decision_id}/edit") +async def edit_form(decision_id: str, request: Request, db: AsyncSession = Depends(get_db)): + repo = BaseRepository("decisions", db) + sidebar = await get_sidebar_data(db) + item = await repo.get(decision_id) + if not item: + return RedirectResponse(url="/decisions", status_code=303) + + result = await db.execute(text(""" + SELECT id, title, meeting_date FROM meetings + WHERE is_deleted = false ORDER BY meeting_date DESC LIMIT 50 + """)) + meetings = [dict(r._mapping) for r in result] + + # Other decisions for supersession + result = await db.execute(text(""" + SELECT id, title FROM decisions + WHERE is_deleted = false AND id != :did ORDER BY created_at DESC LIMIT 50 + """), {"did": decision_id}) + other_decisions = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("decision_form.html", { + "request": request, "sidebar": sidebar, + "meetings": meetings, "other_decisions": other_decisions, + "page_title": "Edit Decision", "active_nav": "decisions", + "item": item, + "prefill_meeting_id": "", + }) + + +@router.post("/{decision_id}/edit") +async def update_decision( + decision_id: str, + title: str = Form(...), + rationale: Optional[str] = Form(None), + status: str = Form("proposed"), + impact: str = Form("medium"), + decided_at: Optional[str] = Form(None), + meeting_id: Optional[str] = Form(None), + superseded_by_id: Optional[str] = Form(None), + tags: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + repo = BaseRepository("decisions", db) + data = { + "title": title, "status": status, "impact": impact, + "rationale": rationale if rationale and rationale.strip() else None, + "decided_at": decided_at if decided_at and decided_at.strip() else None, + "meeting_id": meeting_id if meeting_id and meeting_id.strip() else None, + "superseded_by_id": superseded_by_id if superseded_by_id and superseded_by_id.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(decision_id, data) + return RedirectResponse(url=f"/decisions/{decision_id}", status_code=303) + + +@router.post("/{decision_id}/delete") +async def delete_decision(decision_id: str, request: Request, db: AsyncSession = Depends(get_db)): + repo = BaseRepository("decisions", db) + await repo.soft_delete(decision_id) + return RedirectResponse(url="/decisions", status_code=303) diff --git a/routers/files.py b/routers/files.py new file mode 100644 index 0000000..1a0c76d --- /dev/null +++ b/routers/files.py @@ -0,0 +1,193 @@ +"""Files: upload, download, list, preview, and polymorphic entity attachment.""" + +import os +import uuid +import shutil +from pathlib import Path +from fastapi import APIRouter, Request, Form, Depends, UploadFile, File as FastAPIFile +from fastapi.templating import Jinja2Templates +from fastapi.responses import RedirectResponse, FileResponse +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="/files", tags=["files"]) +templates = Jinja2Templates(directory="templates") + +FILE_STORAGE_PATH = os.getenv("FILE_STORAGE_PATH", "/opt/lifeos/files/dev") + +# Ensure storage dir exists +Path(FILE_STORAGE_PATH).mkdir(parents=True, exist_ok=True) + +# MIME types that can be previewed inline +PREVIEWABLE = { + "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", + "application/pdf", "text/plain", "text/html", "text/csv", +} + + +@router.get("/") +async def list_files( + request: Request, + context_type: Optional[str] = None, + context_id: Optional[str] = None, + db: AsyncSession = Depends(get_db), +): + sidebar = await get_sidebar_data(db) + + if context_type and context_id: + # Files attached to a specific entity + result = await db.execute(text(""" + SELECT f.*, fm.context_type, fm.context_id + FROM files f + 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 + ORDER BY f.created_at DESC + """), {"ct": context_type, "cid": context_id}) + else: + # All files + result = await db.execute(text(""" + SELECT f.* FROM files f + WHERE f.is_deleted = false + ORDER BY f.created_at DESC + LIMIT 100 + """)) + + items = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("files.html", { + "request": request, "sidebar": sidebar, "items": items, + "context_type": context_type or "", + "context_id": context_id or "", + "page_title": "Files", "active_nav": "files", + }) + + +@router.get("/upload") +async def upload_form( + request: Request, + context_type: Optional[str] = None, + context_id: Optional[str] = None, + db: AsyncSession = Depends(get_db), +): + sidebar = await get_sidebar_data(db) + return templates.TemplateResponse("file_upload.html", { + "request": request, "sidebar": sidebar, + "context_type": context_type or "", + "context_id": context_id or "", + "page_title": "Upload File", "active_nav": "files", + }) + + +@router.post("/upload") +async def upload_file( + request: Request, + file: UploadFile = FastAPIFile(...), + description: Optional[str] = Form(None), + tags: Optional[str] = Form(None), + context_type: Optional[str] = Form(None), + context_id: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + # Generate storage filename + file_uuid = str(uuid.uuid4()) + original = file.filename or "unknown" + safe_name = original.replace("/", "_").replace("\\", "_") + storage_name = f"{file_uuid}_{safe_name}" + storage_path = os.path.join(FILE_STORAGE_PATH, storage_name) + + # Save to disk + with open(storage_path, "wb") as f: + content = await file.read() + f.write(content) + + size_bytes = len(content) + + # Insert file record + repo = BaseRepository("files", db) + data = { + "filename": storage_name, + "original_filename": original, + "storage_path": storage_path, + "mime_type": file.content_type, + "size_bytes": size_bytes, + "description": description, + } + if tags and tags.strip(): + data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] + + new_file = await repo.create(data) + + # Create file mapping if context provided + if context_type and context_type.strip() and context_id and context_id.strip(): + await db.execute(text(""" + INSERT INTO file_mappings (file_id, context_type, context_id) + VALUES (:fid, :ct, :cid) + ON CONFLICT DO NOTHING + """), {"fid": new_file["id"], "ct": context_type, "cid": context_id}) + + # Redirect back to context or file list + if context_type and context_id: + return RedirectResponse( + url=f"/files?context_type={context_type}&context_id={context_id}", + status_code=303, + ) + return RedirectResponse(url="/files", status_code=303) + + +@router.get("/{file_id}/download") +async def download_file(file_id: str, db: AsyncSession = Depends(get_db)): + repo = BaseRepository("files", db) + item = await repo.get(file_id) + if not item or not os.path.exists(item["storage_path"]): + return RedirectResponse(url="/files", status_code=303) + + return FileResponse( + path=item["storage_path"], + filename=item["original_filename"], + media_type=item.get("mime_type") or "application/octet-stream", + ) + + +@router.get("/{file_id}/preview") +async def preview_file(file_id: str, request: Request, db: AsyncSession = Depends(get_db)): + """Inline preview for images and PDFs.""" + repo = BaseRepository("files", db) + sidebar = await get_sidebar_data(db) + item = await repo.get(file_id) + if not item: + return RedirectResponse(url="/files", status_code=303) + + can_preview = item.get("mime_type", "") in PREVIEWABLE + + return templates.TemplateResponse("file_preview.html", { + "request": request, "sidebar": sidebar, "item": item, + "can_preview": can_preview, + "page_title": item["original_filename"], "active_nav": "files", + }) + + +@router.get("/{file_id}/serve") +async def serve_file(file_id: str, db: AsyncSession = Depends(get_db)): + """Serve file inline (for img src, iframe, etc).""" + repo = BaseRepository("files", db) + item = await repo.get(file_id) + if not item or not os.path.exists(item["storage_path"]): + return RedirectResponse(url="/files", status_code=303) + + return FileResponse( + path=item["storage_path"], + media_type=item.get("mime_type") or "application/octet-stream", + ) + + +@router.post("/{file_id}/delete") +async def delete_file(file_id: str, request: Request, db: AsyncSession = Depends(get_db)): + repo = BaseRepository("files", db) + await repo.soft_delete(file_id) + referer = request.headers.get("referer", "/files") + return RedirectResponse(url=referer, status_code=303) diff --git a/routers/meetings.py b/routers/meetings.py new file mode 100644 index 0000000..2e907cc --- /dev/null +++ b/routers/meetings.py @@ -0,0 +1,268 @@ +"""Meetings: CRUD with agenda, transcript, notes, and action item -> task conversion.""" + +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="/meetings", tags=["meetings"]) +templates = Jinja2Templates(directory="templates") + + +@router.get("/") +async def list_meetings( + request: Request, + status: Optional[str] = None, + db: AsyncSession = Depends(get_db), +): + sidebar = await get_sidebar_data(db) + + where_clauses = ["m.is_deleted = false"] + params = {} + if status: + where_clauses.append("m.status = :status") + params["status"] = status + + where_sql = " AND ".join(where_clauses) + + result = await db.execute(text(f""" + SELECT m.*, + (SELECT count(*) FROM meeting_tasks mt + JOIN tasks t ON mt.task_id = t.id + WHERE mt.meeting_id = m.id AND t.is_deleted = false) as action_count + FROM meetings m + WHERE {where_sql} + ORDER BY m.meeting_date DESC, m.start_at DESC NULLS LAST + """), params) + items = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("meetings.html", { + "request": request, "sidebar": sidebar, "items": items, + "current_status": status or "", + "page_title": "Meetings", "active_nav": "meetings", + }) + + +@router.get("/create") +async def create_form(request: Request, db: AsyncSession = Depends(get_db)): + sidebar = await get_sidebar_data(db) + # Get contacts for attendee selection + contacts_repo = BaseRepository("contacts", db) + contacts = await contacts_repo.list() + # Parent meetings for series + result = await db.execute(text(""" + SELECT id, title, meeting_date FROM meetings + WHERE is_deleted = false ORDER BY meeting_date DESC LIMIT 50 + """)) + parent_meetings = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("meeting_form.html", { + "request": request, "sidebar": sidebar, + "contacts": contacts, "parent_meetings": parent_meetings, + "page_title": "New Meeting", "active_nav": "meetings", + "item": None, + }) + + +@router.post("/create") +async def create_meeting( + request: Request, + title: str = Form(...), + meeting_date: str = Form(...), + start_at: Optional[str] = Form(None), + end_at: Optional[str] = Form(None), + location: Optional[str] = Form(None), + status: str = Form("scheduled"), + priority: Optional[str] = Form(None), + parent_id: Optional[str] = Form(None), + agenda: Optional[str] = Form(None), + tags: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + repo = BaseRepository("meetings", db) + data = { + "title": title, "meeting_date": meeting_date, + "status": status, "location": location, "agenda": agenda, + } + if start_at and start_at.strip(): + data["start_at"] = start_at + if end_at and end_at.strip(): + data["end_at"] = end_at + if priority and priority.strip(): + data["priority"] = int(priority) + if parent_id and parent_id.strip(): + data["parent_id"] = parent_id + if tags and tags.strip(): + data["tags"] = [t.strip() for t in tags.split(",") if t.strip()] + + meeting = await repo.create(data) + return RedirectResponse(url=f"/meetings/{meeting['id']}", status_code=303) + + +@router.get("/{meeting_id}") +async def meeting_detail(meeting_id: str, request: Request, db: AsyncSession = Depends(get_db)): + repo = BaseRepository("meetings", db) + sidebar = await get_sidebar_data(db) + item = await repo.get(meeting_id) + if not item: + return RedirectResponse(url="/meetings", status_code=303) + + # Action items (tasks linked to this meeting) + result = await db.execute(text(""" + SELECT t.*, mt.source, + d.name as domain_name, d.color as domain_color, + p.name as project_name + 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] + + # Notes linked to this meeting + result = await db.execute(text(""" + SELECT * FROM notes + WHERE meeting_id = :mid AND is_deleted = false + ORDER BY created_at + """), {"mid": meeting_id}) + meeting_notes = [dict(r._mapping) for r in result] + + # Decisions from this meeting + result = await db.execute(text(""" + SELECT * FROM decisions + WHERE meeting_id = :mid AND is_deleted = false + ORDER BY created_at + """), {"mid": meeting_id}) + decisions = [dict(r._mapping) for r in result] + + # Attendees + result = await db.execute(text(""" + SELECT c.*, cm.role FROM contact_meetings cm + JOIN contacts c ON cm.contact_id = c.id + WHERE cm.meeting_id = :mid AND c.is_deleted = false + ORDER BY c.first_name + """), {"mid": meeting_id}) + attendees = [dict(r._mapping) for r in result] + + # Domains for action item creation + domains_repo = BaseRepository("domains", db) + domains = await domains_repo.list() + + return templates.TemplateResponse("meeting_detail.html", { + "request": request, "sidebar": sidebar, "item": item, + "action_items": action_items, "meeting_notes": meeting_notes, + "decisions": decisions, "attendees": attendees, + "domains": domains, + "page_title": item["title"], "active_nav": "meetings", + }) + + +@router.get("/{meeting_id}/edit") +async def edit_form(meeting_id: str, request: Request, db: AsyncSession = Depends(get_db)): + repo = BaseRepository("meetings", db) + sidebar = await get_sidebar_data(db) + item = await repo.get(meeting_id) + if not item: + return RedirectResponse(url="/meetings", status_code=303) + + contacts_repo = BaseRepository("contacts", db) + contacts = await contacts_repo.list() + result = await db.execute(text(""" + SELECT id, title, meeting_date FROM meetings + WHERE is_deleted = false AND id != :mid ORDER BY meeting_date DESC LIMIT 50 + """), {"mid": meeting_id}) + parent_meetings = [dict(r._mapping) for r in result] + + return templates.TemplateResponse("meeting_form.html", { + "request": request, "sidebar": sidebar, + "contacts": contacts, "parent_meetings": parent_meetings, + "page_title": "Edit Meeting", "active_nav": "meetings", + "item": item, + }) + + +@router.post("/{meeting_id}/edit") +async def update_meeting( + meeting_id: str, + title: str = Form(...), + meeting_date: str = Form(...), + start_at: Optional[str] = Form(None), + end_at: Optional[str] = Form(None), + location: Optional[str] = Form(None), + status: str = Form("scheduled"), + priority: Optional[str] = Form(None), + parent_id: Optional[str] = Form(None), + agenda: Optional[str] = Form(None), + transcript: Optional[str] = Form(None), + notes_body: Optional[str] = Form(None), + tags: Optional[str] = Form(None), + db: AsyncSession = Depends(get_db), +): + repo = BaseRepository("meetings", db) + data = { + "title": title, "meeting_date": meeting_date, + "status": status, + "location": location if location and location.strip() else None, + "agenda": agenda if agenda and agenda.strip() else None, + "transcript": transcript if transcript and transcript.strip() else None, + "notes_body": notes_body if notes_body and notes_body.strip() else None, + "start_at": start_at if start_at and start_at.strip() else None, + "end_at": end_at if end_at and end_at.strip() else None, + "parent_id": parent_id if parent_id and parent_id.strip() else None, + } + if priority and priority.strip(): + data["priority"] = int(priority) + else: + data["priority"] = 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(meeting_id, data) + return RedirectResponse(url=f"/meetings/{meeting_id}", status_code=303) + + +@router.post("/{meeting_id}/delete") +async def delete_meeting(meeting_id: str, request: Request, db: AsyncSession = Depends(get_db)): + repo = BaseRepository("meetings", db) + await repo.soft_delete(meeting_id) + return RedirectResponse(url="/meetings", status_code=303) + + +# ---- Action Items ---- + +@router.post("/{meeting_id}/action-item") +async def create_action_item( + meeting_id: str, + request: Request, + title: str = Form(...), + domain_id: str = Form(...), + db: AsyncSession = Depends(get_db), +): + """Create a task and link it to this meeting as an action item.""" + task_repo = BaseRepository("tasks", db) + task = await task_repo.create({ + "title": title, + "domain_id": domain_id, + "status": "open", + "priority": 2, + }) + + # Link via meeting_tasks junction + await db.execute(text(""" + INSERT INTO meeting_tasks (meeting_id, task_id, source) + VALUES (:mid, :tid, 'action_item') + ON CONFLICT DO NOTHING + """), {"mid": meeting_id, "tid": task["id"]}) + + return RedirectResponse(url=f"/meetings/{meeting_id}", status_code=303) diff --git a/routers/search.py b/routers/search.py index db39d55..b1fb92b 100644 --- a/routers/search.py +++ b/routers/search.py @@ -109,6 +109,34 @@ SEARCH_ENTITIES = [ "url": "/lists/{id}", "icon": "list", }, + { + "type": "meetings", + "label": "Meetings", + "query": """ + SELECT m.id, m.title as name, m.status, + NULL as domain_name, NULL as project_name, + 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", + "label": "Decisions", + "query": """ + SELECT d.id, d.title as name, d.status, + NULL as domain_name, NULL as project_name, + 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", + }, ] diff --git a/static/style.css b/static/style.css index bcb1b91..4695c8f 100644 --- a/static/style.css +++ b/static/style.css @@ -966,6 +966,14 @@ a:hover { color: var(--accent-hover); } .search-type-domains { background: var(--green-soft); color: var(--green); } .search-type-areas { background: var(--surface2); color: var(--text-secondary); } .search-type-daily_focus { background: var(--amber-soft); color: var(--amber); } +.search-type-meetings { background: var(--purple-soft); color: var(--purple); } +.search-type-decisions { background: var(--green-soft); color: var(--green); } +.search-type-files { background: var(--surface2); color: var(--text-secondary); } + +/* Impact badges */ +.impact-high { background: var(--red-soft); color: var(--red); } +.impact-medium { background: var(--amber-soft); color: var(--amber); } +.impact-low { background: var(--surface2); color: var(--muted); } /* ---- Search Page Form ---- */ .search-page-form { diff --git a/templates/base.html b/templates/base.html index b68ec40..76cedf0 100644 --- a/templates/base.html +++ b/templates/base.html @@ -48,6 +48,18 @@ Lists + + + Meetings + + + + Decisions + + + + Files + Capture diff --git a/templates/decision_detail.html b/templates/decision_detail.html new file mode 100644 index 0000000..75f3d8f --- /dev/null +++ b/templates/decision_detail.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block content %} + + +
+

{{ item.title }}

+
+ Edit +
+ +
+
+
+ +
+ {{ item.status }} + {{ item.impact }} impact + {% if item.decided_at %}Decided: {{ item.decided_at }}{% endif %} + {% if meeting %}Meeting: {{ meeting.title }}{% endif %} + {% if item.tags %} +
{% for tag in item.tags %}{{ tag }}{% endfor %}
+ {% endif %} +
+ +{% if superseded_by %} +
+
+ Superseded by: + {{ superseded_by.title }} +
+
+{% endif %} + +{% if item.rationale %} +
+

Rationale

+
{{ item.rationale }}
+
+{% endif %} + +{% if supersedes %} +
+

Supersedes

+ {% for dec in supersedes %} +
+ {{ dec.title }} +
+ {% endfor %} +
+{% endif %} +{% endblock %} diff --git a/templates/decision_form.html b/templates/decision_form.html new file mode 100644 index 0000000..2e9d117 --- /dev/null +++ b/templates/decision_form.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} +{% block content %} + + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {% if item and other_decisions is defined %} +
+ + +
+ {% endif %} + +
+ + +
+ +
+ + +
+
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/templates/decisions.html b/templates/decisions.html new file mode 100644 index 0000000..2cae093 --- /dev/null +++ b/templates/decisions.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block content %} + + +
+ + +
+ +{% if items %} +
+ {% for item in items %} +
+ {{ item.title }} + {{ item.status }} + {{ item.impact }} + {% if item.decided_at %} + {{ item.decided_at }} + {% endif %} + {% if item.meeting_title %} + {{ item.meeting_title }} + {% endif %} +
+ Edit +
+ +
+
+
+ {% endfor %} +
+{% else %} +
+
+
No decisions recorded yet
+ Record First Decision +
+{% endif %} +{% endblock %} diff --git a/templates/file_preview.html b/templates/file_preview.html new file mode 100644 index 0000000..066fa8d --- /dev/null +++ b/templates/file_preview.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block content %} + + +
+

{{ item.original_filename }}

+
+ Download +
+ +
+
+
+ +
+ {% if item.mime_type %}Type: {{ item.mime_type }}{% endif %} + {% if item.size_bytes %}Size: {{ "%.1f"|format(item.size_bytes / 1024) }} KB{% endif %} + {% if item.description %}{{ item.description }}{% endif %} + {% if item.tags %} +
+ {% for tag in item.tags %}{{ tag }}{% endfor %} +
+ {% endif %} +
+ +{% if can_preview %} +
+ {% if item.mime_type and item.mime_type.startswith('image/') %} + {{ item.original_filename }} + {% elif item.mime_type == 'application/pdf' %} + + {% elif item.mime_type and item.mime_type.startswith('text/') %} + + {% endif %} +
+{% else %} +
+
📄
+
Preview not available for this file type
+ Download Instead +
+{% endif %} +{% endblock %} diff --git a/templates/file_upload.html b/templates/file_upload.html new file mode 100644 index 0000000..64f3fac --- /dev/null +++ b/templates/file_upload.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block content %} + + +
+
+ {% if context_type %} + + + {% endif %} + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/templates/files.html b/templates/files.html new file mode 100644 index 0000000..57351f7 --- /dev/null +++ b/templates/files.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block content %} + + +{% if items %} +
+ {% for item in items %} +
+ + {{ item.original_filename }} + + {% if item.mime_type %} + {{ item.mime_type.split('/')|last }} + {% endif %} + {% if item.size_bytes %} + {{ "%.1f"|format(item.size_bytes / 1024) }} KB + {% endif %} + {% if item.description %} + {{ item.description[:50] }} + {% endif %} +
+ Download +
+ +
+
+
+ {% endfor %} +
+{% else %} +
+
📁
+
No files uploaded yet
+ Upload First File +
+{% endif %} +{% endblock %} diff --git a/templates/meeting_detail.html b/templates/meeting_detail.html new file mode 100644 index 0000000..1a13da6 --- /dev/null +++ b/templates/meeting_detail.html @@ -0,0 +1,119 @@ +{% extends "base.html" %} +{% block content %} + + +
+

{{ item.title }}

+
+ Edit +
+ +
+
+
+ +
+ {{ item.meeting_date }} + {{ item.status }} + {% if item.location %}{{ item.location }}{% endif %} + {% if item.start_at and item.end_at %} + {{ item.start_at.strftime('%H:%M') }} - {{ item.end_at.strftime('%H:%M') }} + {% endif %} + {% if item.tags %} +
{% for tag in item.tags %}{{ tag }}{% endfor %}
+ {% endif %} +
+ + +{% if item.agenda %} +
+

Agenda

+
{{ item.agenda }}
+
+{% endif %} + + +{% if item.notes_body %} +
+

Notes

+
{{ item.notes_body }}
+
+{% endif %} + + +{% if item.transcript %} +
+

Transcript

+
{{ item.transcript }}
+
+{% endif %} + + +
+
+

Action Items{{ action_items|length }}

+
+ + +
+ + + +
+ + {% for task in action_items %} +
+
+
+ + +
+
+ + {{ task.title }} + {% if task.project_name %}{{ task.project_name }}{% endif %} + {{ task.status|replace('_', ' ') }} +
+ {% endfor %} + + {% if not action_items %} +
No action items yet
+ {% endif %} +
+ + +{% if decisions %} +
+

Decisions{{ decisions|length }}

+ {% for dec in decisions %} +
+ {{ dec.title }} + {{ dec.status }} + {{ dec.impact }} +
+ {% endfor %} +
+{% endif %} + + +{% if attendees %} +
+

Attendees{{ attendees|length }}

+ {% for att in attendees %} +
+ {{ att.first_name }} {{ att.last_name or '' }} + {% if att.role %}{{ att.role }}{% endif %} +
+ {% endfor %} +
+{% endif %} +{% endblock %} diff --git a/templates/meeting_form.html b/templates/meeting_form.html new file mode 100644 index 0000000..183b7ec --- /dev/null +++ b/templates/meeting_form.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} +{% block content %} + + +
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + {% if item %} +
+ + +
+ +
+ + +
+ {% endif %} + +
+ + +
+
+ +
+ + Cancel +
+
+
+{% endblock %} diff --git a/templates/meetings.html b/templates/meetings.html new file mode 100644 index 0000000..27839af --- /dev/null +++ b/templates/meetings.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block content %} + + +
+ +
+ +{% if items %} +
+ {% for item in items %} +
+ {{ item.title }} + {{ item.meeting_date }} + {% if item.location %} + {{ item.location }} + {% endif %} + {% if item.action_count %} + {{ item.action_count }} action{{ 's' if item.action_count != 1 }} + {% endif %} + {{ item.status }} +
+ Edit +
+ +
+
+
+ {% endfor %} +
+{% else %} +
+
📅
+
No meetings yet
+ Schedule First Meeting +
+{% endif %} +{% endblock %}