Session 2: files upload/preview, meetings with action items, decisions

This commit is contained in:
2026-02-28 04:00:55 +00:00
parent 5773808ae4
commit 82d03ce23a
18 changed files with 1317 additions and 0 deletions

View File

@@ -125,6 +125,9 @@ class BaseRepository:
"parent_id", "parent_item_id", "release_id", "due_date", "deadline", "tags", "parent_id", "parent_item_id", "release_id", "due_date", "deadline", "tags",
"context", "folder_id", "meeting_id", "completed_at", "context", "folder_id", "meeting_id", "completed_at",
"waiting_for_contact_id", "waiting_since", "color", "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 = {} clean_data = {}
for k, v in data.items(): for k, v in data.items():

View File

@@ -33,6 +33,9 @@ from routers import (
search as search_router, search as search_router,
admin as admin_router, admin as admin_router,
lists as lists_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(search_router.router)
app.include_router(admin_router.router) app.include_router(admin_router.router)
app.include_router(lists_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)

View File

@@ -24,6 +24,9 @@ TRASH_ENTITIES = [
{"table": "domains", "label": "Domains", "name_col": "name", "url": "/domains"}, {"table": "domains", "label": "Domains", "name_col": "name", "url": "/domains"},
{"table": "areas", "label": "Areas", "name_col": "name", "url": "/areas"}, {"table": "areas", "label": "Areas", "name_col": "name", "url": "/areas"},
{"table": "lists", "label": "Lists", "name_col": "name", "url": "/lists/{id}"}, {"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": "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"},
] ]

211
routers/decisions.py Normal file
View File

@@ -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)

193
routers/files.py Normal file
View File

@@ -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)

268
routers/meetings.py Normal file
View File

@@ -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)

View File

@@ -109,6 +109,34 @@ SEARCH_ENTITIES = [
"url": "/lists/{id}", "url": "/lists/{id}",
"icon": "list", "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",
},
] ]

View File

@@ -966,6 +966,14 @@ a:hover { color: var(--accent-hover); }
.search-type-domains { background: var(--green-soft); color: var(--green); } .search-type-domains { background: var(--green-soft); color: var(--green); }
.search-type-areas { background: var(--surface2); color: var(--text-secondary); } .search-type-areas { background: var(--surface2); color: var(--text-secondary); }
.search-type-daily_focus { background: var(--amber-soft); color: var(--amber); } .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 ---- */
.search-page-form { .search-page-form {

View File

@@ -48,6 +48,18 @@
<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="/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>
Meetings
</a>
<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>
Decisions
</a>
<a href="/files" class="nav-item {{ 'active' if active_nav == 'files' }}">
<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
</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

View File

@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/decisions">Decisions</a>
<span class="sep">/</span>
<span>{{ item.title }}</span>
</div>
<div class="detail-header">
<h1 class="detail-title">{{ item.title }}</h1>
<div class="flex gap-2">
<a href="/decisions/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
<form action="/decisions/{{ item.id }}/delete" method="post" data-confirm="Delete this decision?" 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 impact-{{ item.impact }}">{{ item.impact }} impact</span>
{% if item.decided_at %}<span class="detail-meta-item">Decided: {{ item.decided_at }}</span>{% endif %}
{% if meeting %}<span class="detail-meta-item">Meeting: <a href="/meetings/{{ meeting.id }}">{{ meeting.title }}</a></span>{% endif %}
{% if item.tags %}
<div class="mt-1">{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}</div>
{% endif %}
</div>
{% if superseded_by %}
<div class="card mt-3" style="border-left: 3px solid var(--amber);">
<div style="padding: 12px 16px;">
<strong style="color: var(--amber);">Superseded by:</strong>
<a href="/decisions/{{ superseded_by.id }}">{{ superseded_by.title }}</a>
</div>
</div>
{% endif %}
{% if item.rationale %}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Rationale</h3></div>
<div class="detail-body" style="padding: 12px 16px;">{{ item.rationale }}</div>
</div>
{% endif %}
{% if supersedes %}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Supersedes</h3></div>
{% for dec in supersedes %}
<div class="list-row">
<span class="row-title"><a href="/decisions/{{ dec.id }}">{{ dec.title }}</a></span>
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ page_title }}</h1>
</div>
<div class="card">
<form method="post" action="{{ '/decisions/' ~ item.id ~ '/edit' if item else '/decisions/create' }}">
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Title *</label>
<input type="text" name="title" class="form-input" required
value="{{ item.title if item else '' }}" placeholder="Summary of the decision...">
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="proposed" {{ 'selected' if (item and item.status == 'proposed') or not item }}>Proposed</option>
<option value="accepted" {{ 'selected' if item and item.status == 'accepted' }}>Accepted</option>
<option value="rejected" {{ 'selected' if item and item.status == 'rejected' }}>Rejected</option>
<option value="superseded" {{ 'selected' if item and item.status == 'superseded' }}>Superseded</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Impact</label>
<select name="impact" class="form-select">
<option value="low" {{ 'selected' if item and item.impact == 'low' }}>Low</option>
<option value="medium" {{ 'selected' if (item and item.impact == 'medium') or not item }}>Medium</option>
<option value="high" {{ 'selected' if item and item.impact == 'high' }}>High</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Decision Date</label>
<input type="date" name="decided_at" class="form-input"
value="{{ item.decided_at if item and item.decided_at else '' }}">
</div>
<div class="form-group">
<label class="form-label">Meeting</label>
<select name="meeting_id" class="form-select">
<option value="">None</option>
{% for m in meetings %}
<option value="{{ m.id }}"
{{ 'selected' if (item and item.meeting_id and item.meeting_id|string == m.id|string) or (not item and prefill_meeting_id == m.id|string) }}>
{{ m.title }} ({{ m.meeting_date }})
</option>
{% endfor %}
</select>
</div>
{% if item and other_decisions is defined %}
<div class="form-group">
<label class="form-label">Superseded By</label>
<select name="superseded_by_id" class="form-select">
<option value="">None</option>
{% for d in other_decisions %}
<option value="{{ d.id }}" {{ 'selected' if item.superseded_by_id and item.superseded_by_id|string == d.id|string }}>
{{ d.title }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="form-group full-width">
<label class="form-label">Rationale</label>
<textarea name="rationale" class="form-textarea" rows="6" placeholder="Why was this decided? What alternatives were considered?">{{ item.rationale if item and item.rationale else '' }}</textarea>
</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 'Record Decision' }}</button>
<a href="{{ '/decisions/' ~ item.id if item else '/decisions' }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

53
templates/decisions.html Normal file
View File

@@ -0,0 +1,53 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Decisions<span class="page-count">{{ items|length }}</span></h1>
<a href="/decisions/create" class="btn btn-primary">+ New Decision</a>
</div>
<form class="filters-bar" method="get" action="/decisions">
<select name="status" class="filter-select" onchange="this.form.submit()">
<option value="">All Statuses</option>
<option value="proposed" {{ 'selected' if current_status == 'proposed' }}>Proposed</option>
<option value="accepted" {{ 'selected' if current_status == 'accepted' }}>Accepted</option>
<option value="rejected" {{ 'selected' if current_status == 'rejected' }}>Rejected</option>
<option value="superseded" {{ 'selected' if current_status == 'superseded' }}>Superseded</option>
</select>
<select name="impact" class="filter-select" onchange="this.form.submit()">
<option value="">All Impact</option>
<option value="high" {{ 'selected' if current_impact == 'high' }}>High</option>
<option value="medium" {{ 'selected' if current_impact == 'medium' }}>Medium</option>
<option value="low" {{ 'selected' if current_impact == 'low' }}>Low</option>
</select>
</form>
{% if items %}
<div class="card mt-3">
{% for item in items %}
<div class="list-row">
<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="row-tag impact-{{ item.impact }}">{{ item.impact }}</span>
{% if item.decided_at %}
<span class="row-meta">{{ item.decided_at }}</span>
{% endif %}
{% if item.meeting_title %}
<span class="row-meta">{{ item.meeting_title }}</span>
{% endif %}
<div class="row-actions">
<a href="/decisions/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/decisions/{{ item.id }}/delete" method="post" data-confirm="Delete this decision?" 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">&#9878;</div>
<div class="empty-state-text">No decisions recorded yet</div>
<a href="/decisions/create" class="btn btn-primary">Record First Decision</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/files">Files</a>
<span class="sep">/</span>
<span>{{ item.original_filename }}</span>
</div>
<div class="detail-header">
<h1 class="detail-title">{{ item.original_filename }}</h1>
<div class="flex gap-2">
<a href="/files/{{ item.id }}/download" class="btn btn-primary btn-sm">Download</a>
<form action="/files/{{ item.id }}/delete" method="post" data-confirm="Delete this file?" style="display:inline">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</div>
<div class="detail-meta mt-2">
{% 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.description %}<span class="detail-meta-item">{{ item.description }}</span>{% endif %}
{% if item.tags %}
<div class="mt-1">
{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}
</div>
{% endif %}
</div>
{% if can_preview %}
<div class="card mt-3" style="padding: 16px;">
{% if item.mime_type and item.mime_type.startswith('image/') %}
<img src="/files/{{ item.id }}/serve" alt="{{ item.original_filename }}" style="max-width: 100%; height: auto; border-radius: var(--radius);">
{% elif item.mime_type == 'application/pdf' %}
<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/') %}
<iframe src="/files/{{ item.id }}/serve" style="width: 100%; height: 400px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface2);"></iframe>
{% endif %}
</div>
{% else %}
<div class="empty-state mt-3">
<div class="empty-state-icon">&#128196;</div>
<div class="empty-state-text">Preview not available for this file type</div>
<a href="/files/{{ item.id }}/download" class="btn btn-primary">Download Instead</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Upload File</h1>
</div>
<div class="card">
<form method="post" action="/files/upload" enctype="multipart/form-data">
{% if context_type %}
<input type="hidden" name="context_type" value="{{ context_type }}">
<input type="hidden" name="context_id" value="{{ context_id }}">
{% endif %}
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">File *</label>
<input type="file" name="file" class="form-input" required>
</div>
<div class="form-group full-width">
<label class="form-label">Description</label>
<input type="text" name="description" class="form-input" placeholder="Optional description...">
</div>
<div class="form-group">
<label class="form-label">Tags</label>
<input type="text" name="tags" class="form-input" placeholder="tag1, tag2, ...">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Upload</button>
<a href="/files" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

40
templates/files.html Normal file
View File

@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Files<span class="page-count">{{ items|length }}</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>
{% if items %}
<div class="card">
{% for item in items %}
<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>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">&#128193;</div>
<div class="empty-state-text">No files uploaded yet</div>
<a href="/files/upload" class="btn btn-primary">Upload First File</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,119 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/meetings">Meetings</a>
<span class="sep">/</span>
<span>{{ item.title }}</span>
</div>
<div class="detail-header">
<h1 class="detail-title">{{ item.title }}</h1>
<div class="flex gap-2">
<a href="/meetings/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
<form action="/meetings/{{ item.id }}/delete" method="post" data-confirm="Delete this meeting?" 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="detail-meta-item">{{ item.meeting_date }}</span>
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
{% if item.location %}<span class="detail-meta-item">{{ item.location }}</span>{% endif %}
{% 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>
{% endif %}
{% if item.tags %}
<div class="mt-1">{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}</div>
{% endif %}
</div>
<!-- Agenda -->
{% if item.agenda %}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Agenda</h3></div>
<div class="detail-body" style="padding: 12px 16px;">{{ item.agenda }}</div>
</div>
{% endif %}
<!-- Meeting Notes -->
{% if item.notes_body %}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Notes</h3></div>
<div class="detail-body" style="padding: 12px 16px;">{{ item.notes_body }}</div>
</div>
{% endif %}
<!-- Transcript -->
{% if item.transcript %}
<div class="card mt-3">
<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>
{% endif %}
<!-- Action Items -->
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Action Items<span class="page-count">{{ action_items|length }}</span></h3>
</div>
<!-- Quick add action item -->
<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>
<select name="domain_id" class="filter-select" required style="width: auto;">
{% for d in domains %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
{% for task in action_items %}
<div class="list-row {{ 'completed' if task.status == 'done' }}">
<div class="row-check">
<form action="/tasks/{{ task.id }}/toggle" method="post" style="display:inline">
<input type="checkbox" id="mt-{{ task.id }}" {{ 'checked' if task.status == 'done' }}
onchange="this.form.submit()">
<label for="mt-{{ task.id }}"></label>
</form>
</div>
<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 %}
{% if not action_items %}
<div style="padding: 16px; color: var(--muted); font-size: 0.85rem;">No action items yet</div>
{% endif %}
</div>
<!-- Decisions -->
{% if decisions %}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Decisions<span class="page-count">{{ decisions|length }}</span></h3></div>
{% for dec in decisions %}
<div class="list-row">
<span class="row-title"><a href="/decisions/{{ dec.id }}">{{ dec.title }}</a></span>
<span class="status-badge status-{{ dec.status }}">{{ dec.status }}</span>
<span class="row-tag">{{ dec.impact }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Attendees -->
{% if attendees %}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Attendees<span class="page-count">{{ attendees|length }}</span></h3></div>
{% for att in attendees %}
<div class="list-row">
<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>
{% endif %}
{% endblock %}

102
templates/meeting_form.html Normal file
View File

@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ page_title }}</h1>
</div>
<div class="card">
<form method="post" action="{{ '/meetings/' ~ item.id ~ '/edit' if item else '/meetings/create' }}">
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Title *</label>
<input type="text" name="title" class="form-input" required
value="{{ item.title if item else '' }}">
</div>
<div class="form-group">
<label class="form-label">Date *</label>
<input type="date" name="meeting_date" class="form-input" required
value="{{ item.meeting_date if item else '' }}">
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="scheduled" {{ 'selected' if item and item.status == 'scheduled' }}>Scheduled</option>
<option value="completed" {{ 'selected' if item and item.status == 'completed' }}>Completed</option>
<option value="cancelled" {{ 'selected' if item and item.status == 'cancelled' }}>Cancelled</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Start Time</label>
<input type="datetime-local" name="start_at" class="form-input"
value="{{ item.start_at.strftime('%Y-%m-%dT%H:%M') if item and item.start_at else '' }}">
</div>
<div class="form-group">
<label class="form-label">End Time</label>
<input type="datetime-local" name="end_at" class="form-input"
value="{{ item.end_at.strftime('%Y-%m-%dT%H:%M') if item and item.end_at else '' }}">
</div>
<div class="form-group">
<label class="form-label">Location</label>
<input type="text" name="location" class="form-input" placeholder="Zoom, Google Meet, Room..."
value="{{ item.location if item and item.location else '' }}">
</div>
<div class="form-group">
<label class="form-label">Priority</label>
<select name="priority" class="form-select">
<option value="">None</option>
<option value="1" {{ 'selected' if item and item.priority == 1 }}>Critical</option>
<option value="2" {{ 'selected' if item and item.priority == 2 }}>High</option>
<option value="3" {{ 'selected' if item and item.priority == 3 }}>Normal</option>
<option value="4" {{ 'selected' if item and item.priority == 4 }}>Low</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Series (Parent Meeting)</label>
<select name="parent_id" class="form-select">
<option value="">None</option>
{% for m in parent_meetings %}
<option value="{{ m.id }}" {{ 'selected' if item and item.parent_id and item.parent_id|string == m.id|string }}>
{{ m.title }} ({{ m.meeting_date }})
</option>
{% endfor %}
</select>
</div>
<div class="form-group full-width">
<label class="form-label">Agenda</label>
<textarea name="agenda" class="form-textarea" rows="4">{{ item.agenda if item and item.agenda else '' }}</textarea>
</div>
{% if item %}
<div class="form-group full-width">
<label class="form-label">Transcript</label>
<textarea name="transcript" class="form-textarea" rows="6">{{ item.transcript if item and item.transcript else '' }}</textarea>
</div>
<div class="form-group full-width">
<label class="form-label">Meeting Notes</label>
<textarea name="notes_body" class="form-textarea" rows="6">{{ item.notes_body if item and item.notes_body else '' }}</textarea>
</div>
{% endif %}
<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 Meeting' }}</button>
<a href="{{ '/meetings/' ~ item.id if item else '/meetings' }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

46
templates/meetings.html Normal file
View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Meetings<span class="page-count">{{ items|length }}</span></h1>
<a href="/meetings/create" class="btn btn-primary">+ New Meeting</a>
</div>
<form class="filters-bar" method="get" action="/meetings">
<select name="status" class="filter-select" onchange="this.form.submit()">
<option value="">All Statuses</option>
<option value="scheduled" {{ 'selected' if current_status == 'scheduled' }}>Scheduled</option>
<option value="completed" {{ 'selected' if current_status == 'completed' }}>Completed</option>
<option value="cancelled" {{ 'selected' if current_status == 'cancelled' }}>Cancelled</option>
</select>
</form>
{% if items %}
<div class="card mt-3">
{% for item in items %}
<div class="list-row">
<span class="row-title"><a href="/meetings/{{ item.id }}">{{ item.title }}</a></span>
<span class="row-meta">{{ item.meeting_date }}</span>
{% if item.location %}
<span class="row-tag">{{ item.location }}</span>
{% endif %}
{% if item.action_count %}
<span class="row-meta">{{ item.action_count }} action{{ 's' if item.action_count != 1 }}</span>
{% endif %}
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
<div class="row-actions">
<a href="/meetings/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/meetings/{{ item.id }}/delete" method="post" data-confirm="Delete this meeting?" 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">&#128197;</div>
<div class="empty-state-text">No meetings yet</div>
<a href="/meetings/create" class="btn btn-primary">Schedule First Meeting</a>
</div>
{% endif %}
{% endblock %}