Session 2: files upload/preview, meetings with action items, decisions
This commit is contained in:
@@ -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():
|
||||||
|
|||||||
6
main.py
6
main.py
@@ -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)
|
||||||
|
|||||||
@@ -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
211
routers/decisions.py
Normal 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
193
routers/files.py
Normal 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
268
routers/meetings.py
Normal 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)
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
55
templates/decision_detail.html
Normal file
55
templates/decision_detail.html
Normal 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 %}
|
||||||
86
templates/decision_form.html
Normal file
86
templates/decision_form.html
Normal 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
53
templates/decisions.html
Normal 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">⚖</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 %}
|
||||||
47
templates/file_preview.html
Normal file
47
templates/file_preview.html
Normal 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">📄</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 %}
|
||||||
37
templates/file_upload.html
Normal file
37
templates/file_upload.html
Normal 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
40
templates/files.html
Normal 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">📁</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 %}
|
||||||
119
templates/meeting_detail.html
Normal file
119
templates/meeting_detail.html
Normal 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
102
templates/meeting_form.html
Normal 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
46
templates/meetings.html
Normal 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">📅</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 %}
|
||||||
Reference in New Issue
Block a user