various enhancements for new tabs and bug fixes

This commit is contained in:
2026-03-02 17:35:00 +00:00
parent 9dedf6dbf2
commit cf84d6d2dd
32 changed files with 4501 additions and 296 deletions

View File

@@ -56,6 +56,7 @@ async def list_decisions(
async def create_form(
request: Request,
meeting_id: Optional[str] = None,
task_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
@@ -72,6 +73,7 @@ async def create_form(
"page_title": "New Decision", "active_nav": "decisions",
"item": None,
"prefill_meeting_id": meeting_id or "",
"prefill_task_id": task_id or "",
})
@@ -84,6 +86,7 @@ async def create_decision(
impact: str = Form("medium"),
decided_at: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
@@ -96,10 +99,14 @@ async def create_decision(
data["decided_at"] = decided_at
if meeting_id and meeting_id.strip():
data["meeting_id"] = meeting_id
if task_id and task_id.strip():
data["task_id"] = task_id
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
decision = await repo.create(data)
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=decisions", status_code=303)
return RedirectResponse(url=f"/decisions/{decision['id']}", status_code=303)

View File

@@ -4,8 +4,10 @@ from fastapi import APIRouter, Request, Depends
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional
from core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/eisenhower", tags=["eisenhower"])
@@ -15,11 +17,36 @@ templates = Jinja2Templates(directory="templates")
@router.get("/")
async def eisenhower_matrix(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
status: Optional[str] = None,
context: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
result = await db.execute(text("""
where_clauses = [
"t.is_deleted = false",
"t.status IN ('open', 'in_progress', 'blocked')",
]
params = {}
if domain_id:
where_clauses.append("t.domain_id = :domain_id")
params["domain_id"] = domain_id
if project_id:
where_clauses.append("t.project_id = :project_id")
params["project_id"] = project_id
if status:
where_clauses.append("t.status = :status")
params["status"] = status
if context:
where_clauses.append("t.context = :context")
params["context"] = context
where_sql = " AND ".join(where_clauses)
result = await db.execute(text(f"""
SELECT t.id, t.title, t.priority, t.status, t.due_date,
t.context, t.estimated_minutes,
p.name as project_name,
@@ -27,10 +54,9 @@ async def eisenhower_matrix(
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN domains d ON t.domain_id = d.id
WHERE t.is_deleted = false
AND t.status IN ('open', 'in_progress', 'blocked')
WHERE {where_sql}
ORDER BY t.priority, t.due_date NULLS LAST, t.title
"""))
"""), params)
tasks = [dict(r._mapping) for r in result]
# Classify into quadrants
@@ -39,10 +65,10 @@ async def eisenhower_matrix(
urgent_cutoff = today + timedelta(days=7)
quadrants = {
"do_first": [], # Urgent + Important
"schedule": [], # Not Urgent + Important
"delegate": [], # Urgent + Not Important
"eliminate": [], # Not Urgent + Not Important
"do_first": [],
"schedule": [],
"delegate": [],
"eliminate": [],
}
for t in tasks:
@@ -64,6 +90,17 @@ async def eisenhower_matrix(
counts = {k: len(v) for k, v in quadrants.items()}
total = sum(counts.values())
# Filter options
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
result = await db.execute(text(
"SELECT value, label FROM context_types WHERE is_deleted = false ORDER BY sort_order"
))
context_types = [dict(r._mapping) for r in result]
return templates.TemplateResponse("eisenhower.html", {
"request": request,
"sidebar": sidebar,
@@ -71,6 +108,13 @@ async def eisenhower_matrix(
"counts": counts,
"total": total,
"today": today,
"domains": domains,
"projects": projects,
"context_types": context_types,
"current_domain_id": domain_id or "",
"current_project_id": project_id or "",
"current_status": status or "",
"current_context": context or "",
"page_title": "Eisenhower Matrix",
"active_nav": "eisenhower",
})

View File

@@ -17,7 +17,14 @@ templates = Jinja2Templates(directory="templates")
@router.get("/")
async def focus_view(request: Request, focus_date: Optional[str] = None, db: AsyncSession = Depends(get_db)):
async def focus_view(
request: Request,
focus_date: Optional[str] = None,
domain_id: Optional[str] = None,
area_id: Optional[str] = None,
project_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
target_date = date.fromisoformat(focus_date) if focus_date else date.today()
@@ -36,30 +43,57 @@ async def focus_view(request: Request, focus_date: Optional[str] = None, db: Asy
items = [dict(r._mapping) for r in result]
# Available tasks to add (open, not already in today's focus)
result = await db.execute(text("""
avail_where = [
"t.is_deleted = false",
"t.status NOT IN ('done', 'cancelled')",
"t.id NOT IN (SELECT task_id FROM daily_focus WHERE focus_date = :target_date AND is_deleted = false)",
]
avail_params = {"target_date": target_date}
if domain_id:
avail_where.append("t.domain_id = :domain_id")
avail_params["domain_id"] = domain_id
if area_id:
avail_where.append("t.area_id = :area_id")
avail_params["area_id"] = area_id
if project_id:
avail_where.append("t.project_id = :project_id")
avail_params["project_id"] = project_id
avail_sql = " AND ".join(avail_where)
result = await db.execute(text(f"""
SELECT t.id, t.title, t.priority, t.due_date,
p.name as project_name, d.name as domain_name
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
LEFT JOIN domains d ON t.domain_id = d.id
WHERE t.is_deleted = false AND t.status NOT IN ('done', 'cancelled')
AND t.id NOT IN (
SELECT task_id FROM daily_focus
WHERE focus_date = :target_date AND is_deleted = false
)
WHERE {avail_sql}
ORDER BY t.priority ASC, t.due_date ASC NULLS LAST
LIMIT 50
"""), {"target_date": target_date})
"""), avail_params)
available_tasks = [dict(r._mapping) for r in result]
# Estimated total minutes
total_est = sum(i.get("estimated_minutes") or 0 for i in items)
# Filter options
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
areas_repo = BaseRepository("areas", db)
areas = await areas_repo.list()
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
return templates.TemplateResponse("focus.html", {
"request": request, "sidebar": sidebar,
"items": items, "available_tasks": available_tasks,
"focus_date": target_date,
"total_estimated": total_est,
"domains": domains, "areas": areas, "projects": projects,
"current_domain_id": domain_id or "",
"current_area_id": area_id or "",
"current_project_id": project_id or "",
"page_title": "Daily Focus", "active_nav": "focus",
})

91
routers/history.py Normal file
View File

@@ -0,0 +1,91 @@
"""Change History: reverse-chronological feed of recently modified items."""
from fastapi import APIRouter, Request, Depends
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional
from datetime import datetime, timezone
from core.database import get_db
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/history", tags=["history"])
templates = Jinja2Templates(directory="templates")
# Entity configs: (table, label_column, type_label, url_prefix)
HISTORY_ENTITIES = [
("domains", "name", "Domain", "/domains"),
("areas", "name", "Area", "/areas"),
("projects", "name", "Project", "/projects"),
("tasks", "title", "Task", "/tasks"),
("notes", "title", "Note", "/notes"),
("contacts", "first_name", "Contact", "/contacts"),
("meetings", "title", "Meeting", "/meetings"),
("decisions", "title", "Decision", "/decisions"),
("lists", "name", "List", "/lists"),
("weblinks", "label", "Weblink", "/weblinks"),
("appointments", "title", "Appointment", "/appointments"),
("links", "label", "Link", "/links"),
("files", "original_filename", "File", "/files"),
("capture", "content", "Capture", "/capture"),
]
@router.get("/")
async def history_view(
request: Request,
entity_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
all_items = []
for table, label_col, type_label, url_prefix in HISTORY_ENTITIES:
if entity_type and entity_type != table:
continue
try:
result = await db.execute(text(f"""
SELECT id, {label_col} as label, updated_at, created_at
FROM {table}
WHERE is_deleted = false
ORDER BY updated_at DESC
LIMIT 20
"""))
for r in result:
row = dict(r._mapping)
# Determine action
action = "created"
if row["updated_at"] and row["created_at"]:
diff = abs((row["updated_at"] - row["created_at"]).total_seconds())
if diff > 1:
action = "modified"
all_items.append({
"type": table,
"type_label": type_label,
"id": str(row["id"]),
"label": str(row["label"] or "Untitled")[:80],
"url": f"{url_prefix}/{row['id']}",
"updated_at": row["updated_at"],
"action": action,
})
except Exception:
continue
# Sort by updated_at descending, take top 50
all_items.sort(key=lambda x: x["updated_at"] or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
all_items = all_items[:50]
# Build entity type options for filter
type_options = [{"value": t[0], "label": t[2]} for t in HISTORY_ENTITIES]
return templates.TemplateResponse("history.html", {
"request": request, "sidebar": sidebar,
"items": all_items,
"type_options": type_options,
"current_type": entity_type or "",
"page_title": "Change History", "active_nav": "history",
})

View File

@@ -70,6 +70,8 @@ async def create_form(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
task_id: Optional[str] = None,
meeting_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
@@ -87,6 +89,8 @@ async def create_form(
"item": None,
"prefill_domain_id": domain_id or "",
"prefill_project_id": project_id or "",
"prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "",
})
@@ -97,6 +101,8 @@ async def create_list(
domain_id: str = Form(...),
area_id: Optional[str] = Form(None),
project_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None),
list_type: str = Form("checklist"),
description: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
@@ -112,10 +118,18 @@ async def create_list(
data["area_id"] = area_id
if project_id and project_id.strip():
data["project_id"] = project_id
if task_id and task_id.strip():
data["task_id"] = task_id
if meeting_id and meeting_id.strip():
data["meeting_id"] = meeting_id
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
new_list = await repo.create(data)
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=lists", status_code=303)
if meeting_id and meeting_id.strip():
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=lists", status_code=303)
return RedirectResponse(url=f"/lists/{new_list['id']}", status_code=303)
@@ -156,10 +170,28 @@ async def list_detail(list_id: str, request: Request, db: AsyncSession = Depends
if pid:
child_map.setdefault(str(pid), []).append(i)
# Contacts linked to this list
result = await db.execute(text("""
SELECT c.*, cl.role, cl.created_at as linked_at
FROM contacts c
JOIN contact_lists cl ON cl.contact_id = c.id
WHERE cl.list_id = :lid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"lid": list_id})
contacts = [dict(r._mapping) for r in result]
# All contacts for add dropdown
result = await db.execute(text("""
SELECT id, first_name, last_name FROM contacts
WHERE is_deleted = false ORDER BY first_name
"""))
all_contacts = [dict(r._mapping) for r in result]
return templates.TemplateResponse("list_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project,
"list_items": top_items, "child_map": child_map,
"contacts": contacts, "all_contacts": all_contacts,
"page_title": item["name"], "active_nav": "lists",
})
@@ -273,3 +305,30 @@ async def edit_item(
repo = BaseRepository("list_items", db)
await repo.update(item_id, {"content": content})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
# ---- Contact linking ----
@router.post("/{list_id}/contacts/add")
async def add_contact(
list_id: str,
contact_id: str = Form(...),
role: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO contact_lists (contact_id, list_id, role)
VALUES (:cid, :lid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "lid": list_id, "role": role if role and role.strip() else None})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)
@router.post("/{list_id}/contacts/{contact_id}/remove")
async def remove_contact(
list_id: str, contact_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM contact_lists WHERE contact_id = :cid AND list_id = :lid"
), {"cid": contact_id, "lid": list_id})
return RedirectResponse(url=f"/lists/{list_id}", status_code=303)

View File

@@ -107,61 +107,118 @@ async def create_meeting(
@router.get("/{meeting_id}")
async def meeting_detail(meeting_id: str, request: Request, db: AsyncSession = Depends(get_db)):
async def meeting_detail(
meeting_id: str, request: Request,
tab: str = "overview",
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("meetings", db)
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]
# Overview data (always needed for overview tab)
action_items = []
decisions = []
domains = []
tab_data = []
all_contacts = []
# 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]
if tab == "overview":
# Action items
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]
# 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]
# 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()
# Domains for action item creation
domains_repo = BaseRepository("domains", db)
domains = await domains_repo.list()
elif tab == "notes":
result = await db.execute(text("""
SELECT * FROM notes
WHERE meeting_id = :mid AND is_deleted = false
ORDER BY updated_at DESC
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "weblinks":
result = await db.execute(text("""
SELECT * FROM weblinks
WHERE meeting_id = :mid AND is_deleted = false
ORDER BY sort_order, label
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "files":
result = await db.execute(text("""
SELECT f.* FROM files f
JOIN file_mappings fm ON fm.file_id = f.id
WHERE fm.context_type = 'meeting' AND fm.context_id = :mid AND f.is_deleted = false
ORDER BY f.created_at DESC
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "lists":
result = await db.execute(text("""
SELECT l.*,
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count
FROM lists l
WHERE l.meeting_id = :mid AND l.is_deleted = false
ORDER BY l.sort_order, l.created_at DESC
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "contacts":
result = await db.execute(text("""
SELECT c.*, cm.role, cm.created_at as linked_at
FROM contacts c
JOIN contact_meetings cm ON cm.contact_id = c.id
WHERE cm.meeting_id = :mid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"mid": meeting_id})
tab_data = [dict(r._mapping) for r in result]
result = await db.execute(text("""
SELECT id, first_name, last_name FROM contacts
WHERE is_deleted = false ORDER BY first_name
"""))
all_contacts = [dict(r._mapping) for r in result]
# Tab counts
counts = {}
for count_tab, count_sql in [
("notes", "SELECT count(*) FROM notes WHERE meeting_id = :mid AND is_deleted = false"),
("weblinks", "SELECT count(*) FROM weblinks WHERE meeting_id = :mid AND is_deleted = false"),
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'meeting' AND fm.context_id = :mid AND f.is_deleted = false"),
("lists", "SELECT count(*) FROM lists WHERE meeting_id = :mid AND is_deleted = false"),
("contacts", "SELECT count(*) FROM contacts c JOIN contact_meetings cm ON cm.contact_id = c.id WHERE cm.meeting_id = :mid AND c.is_deleted = false"),
]:
result = await db.execute(text(count_sql), {"mid": meeting_id})
counts[count_tab] = result.scalar() or 0
return templates.TemplateResponse("meeting_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"action_items": action_items, "meeting_notes": meeting_notes,
"decisions": decisions, "attendees": attendees,
"domains": domains,
"action_items": action_items, "decisions": decisions,
"domains": domains, "tab": tab, "tab_data": tab_data,
"all_contacts": all_contacts, "counts": counts,
"page_title": item["title"], "active_nav": "meetings",
})
@@ -266,3 +323,30 @@ async def create_action_item(
"""), {"mid": meeting_id, "tid": task["id"]})
return RedirectResponse(url=f"/meetings/{meeting_id}", status_code=303)
# ---- Contact linking ----
@router.post("/{meeting_id}/contacts/add")
async def add_contact(
meeting_id: str,
contact_id: str = Form(...),
role: str = Form("attendee"),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO contact_meetings (contact_id, meeting_id, role)
VALUES (:cid, :mid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "mid": meeting_id, "role": role if role and role.strip() else "attendee"})
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303)
@router.post("/{meeting_id}/contacts/{contact_id}/remove")
async def remove_contact(
meeting_id: str, contact_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM contact_meetings WHERE contact_id = :cid AND meeting_id = :mid"
), {"cid": contact_id, "mid": meeting_id})
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=contacts", status_code=303)

View File

@@ -61,6 +61,8 @@ async def create_form(
request: Request,
domain_id: Optional[str] = None,
project_id: Optional[str] = None,
task_id: Optional[str] = None,
meeting_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
@@ -75,6 +77,8 @@ async def create_form(
"item": None,
"prefill_domain_id": domain_id or "",
"prefill_project_id": project_id or "",
"prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "",
})
@@ -84,6 +88,8 @@ async def create_note(
title: str = Form(...),
domain_id: str = Form(...),
project_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None),
body: Optional[str] = Form(None),
content_format: str = Form("rich"),
tags: Optional[str] = Form(None),
@@ -96,9 +102,17 @@ async def create_note(
}
if project_id and project_id.strip():
data["project_id"] = project_id
if task_id and task_id.strip():
data["task_id"] = task_id
if meeting_id and meeting_id.strip():
data["meeting_id"] = meeting_id
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
note = await repo.create(data)
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=notes", status_code=303)
if meeting_id and meeting_id.strip():
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=notes", status_code=303)
return RedirectResponse(url=f"/notes/{note['id']}", status_code=303)

View File

@@ -2,7 +2,7 @@
from fastapi import APIRouter, Request, Form, Depends
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from fastapi.responses import RedirectResponse, JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional
@@ -15,6 +15,27 @@ router = APIRouter(prefix="/projects", tags=["projects"])
templates = Jinja2Templates(directory="templates")
@router.get("/api/by-domain")
async def api_projects_by_domain(
domain_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
"""JSON API: return projects filtered by domain_id for dynamic dropdowns."""
if domain_id:
result = await db.execute(text("""
SELECT id, name FROM projects
WHERE is_deleted = false AND (domain_id = :did OR domain_id IS NULL)
ORDER BY name
"""), {"did": domain_id})
else:
result = await db.execute(text("""
SELECT id, name FROM projects
WHERE is_deleted = false ORDER BY name
"""))
projects = [{"id": str(r.id), "name": r.name} for r in result]
return JSONResponse(content=projects)
@router.get("/")
async def list_projects(
request: Request,
@@ -151,7 +172,7 @@ async def project_detail(
row = result.first()
area = dict(row._mapping) if row else None
# Tasks for this project
# Tasks for this project (always needed for progress bar)
result = await db.execute(text("""
SELECT t.*, d.name as domain_name, d.color as domain_color
FROM tasks t
@@ -161,29 +182,92 @@ async def project_detail(
"""), {"pid": project_id})
tasks = [dict(r._mapping) for r in result]
# Notes
result = await db.execute(text("""
SELECT * FROM notes WHERE project_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at DESC
"""), {"pid": project_id})
notes = [dict(r._mapping) for r in result]
# Links
result = await db.execute(text("""
SELECT * FROM links WHERE project_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"pid": project_id})
links = [dict(r._mapping) for r in result]
# Progress
total = len(tasks)
done = len([t for t in tasks if t["status"] == "done"])
progress = round((done / total * 100) if total > 0 else 0)
# Tab-specific data
notes = []
links = []
tab_data = []
all_contacts = []
if tab == "notes":
result = await db.execute(text("""
SELECT * FROM notes WHERE project_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at DESC
"""), {"pid": project_id})
notes = [dict(r._mapping) for r in result]
elif tab == "links":
result = await db.execute(text("""
SELECT * FROM links WHERE project_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"pid": project_id})
links = [dict(r._mapping) for r in result]
elif tab == "files":
result = await db.execute(text("""
SELECT f.* FROM files f
JOIN file_mappings fm ON fm.file_id = f.id
WHERE fm.context_type = 'project' AND fm.context_id = :pid AND f.is_deleted = false
ORDER BY f.created_at DESC
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "lists":
result = await db.execute(text("""
SELECT l.*,
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count
FROM lists l
WHERE l.project_id = :pid AND l.is_deleted = false
ORDER BY l.sort_order, l.created_at DESC
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "decisions":
result = await db.execute(text("""
SELECT d.* FROM decisions d
JOIN decision_projects dp ON dp.decision_id = d.id
WHERE dp.project_id = :pid AND d.is_deleted = false
ORDER BY d.created_at DESC
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "contacts":
result = await db.execute(text("""
SELECT c.*, cp.role, cp.created_at as linked_at
FROM contacts c
JOIN contact_projects cp ON cp.contact_id = c.id
WHERE cp.project_id = :pid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"pid": project_id})
tab_data = [dict(r._mapping) for r in result]
result = await db.execute(text("""
SELECT id, first_name, last_name FROM contacts
WHERE is_deleted = false ORDER BY first_name
"""))
all_contacts = [dict(r._mapping) for r in result]
# Tab counts
counts = {}
for count_tab, count_sql in [
("notes", "SELECT count(*) FROM notes WHERE project_id = :pid AND is_deleted = false"),
("links", "SELECT count(*) FROM links WHERE project_id = :pid AND is_deleted = false"),
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'project' AND fm.context_id = :pid AND f.is_deleted = false"),
("lists", "SELECT count(*) FROM lists WHERE project_id = :pid AND is_deleted = false"),
("decisions", "SELECT count(*) FROM decisions d JOIN decision_projects dp ON dp.decision_id = d.id WHERE dp.project_id = :pid AND d.is_deleted = false"),
("contacts", "SELECT count(*) FROM contacts c JOIN contact_projects cp ON cp.contact_id = c.id WHERE cp.project_id = :pid AND c.is_deleted = false"),
]:
result = await db.execute(text(count_sql), {"pid": project_id})
counts[count_tab] = result.scalar() or 0
return templates.TemplateResponse("project_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "area": area,
"tasks": tasks, "notes": notes, "links": links,
"tab_data": tab_data, "all_contacts": all_contacts, "counts": counts,
"progress": progress, "task_count": total, "done_count": done,
"tab": tab,
"page_title": item["name"], "active_nav": "projects",
@@ -246,3 +330,30 @@ async def delete_project(project_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("projects", db)
await repo.soft_delete(project_id)
return RedirectResponse(url="/projects", status_code=303)
# ---- Contact linking ----
@router.post("/{project_id}/contacts/add")
async def add_contact(
project_id: str,
contact_id: str = Form(...),
role: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO contact_projects (contact_id, project_id, role)
VALUES (:cid, :pid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "pid": project_id, "role": role if role and role.strip() else None})
return RedirectResponse(url=f"/projects/{project_id}?tab=contacts", status_code=303)
@router.post("/{project_id}/contacts/{contact_id}/remove")
async def remove_contact(
project_id: str, contact_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM contact_projects WHERE contact_id = :cid AND project_id = :pid"
), {"cid": contact_id, "pid": project_id})
return RedirectResponse(url=f"/projects/{project_id}?tab=contacts", status_code=303)

View File

@@ -1,5 +1,6 @@
"""Global search: Cmd/K modal, tsvector full-text search across all entities."""
import re
from fastapi import APIRouter, Request, Depends, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import JSONResponse
@@ -14,174 +15,162 @@ router = APIRouter(prefix="/search", tags=["search"])
templates = Jinja2Templates(directory="templates")
# Entity search configs: (table, title_col, subtitle_query, url_pattern, icon)
def build_prefix_tsquery(query: str) -> Optional[str]:
"""Build a prefix-matching tsquery string from user input.
'Sys Admin' -> 'Sys:* & Admin:*'
Returns None if no valid terms.
"""
terms = query.strip().split()
# Keep only alphanumeric terms >= 2 chars
clean = [re.sub(r'[^\w]', '', t) for t in terms]
clean = [t for t in clean if len(t) >= 2]
if not clean:
return None
return " & ".join(f"{t}:*" for t in clean)
# Entity search configs
# Each has: type, label, table, name_col, joins, extra_cols, url, icon
SEARCH_ENTITIES = [
{
"type": "tasks",
"label": "Tasks",
"query": """
SELECT t.id, t.title as name, t.status,
d.name as domain_name, p.name as project_name,
ts_rank(t.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM tasks t
LEFT JOIN domains d ON t.domain_id = d.id
LEFT JOIN projects p ON t.project_id = p.id
WHERE t.is_deleted = false AND t.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/tasks/{id}",
"icon": "task",
"type": "tasks", "label": "Tasks", "table": "tasks", "alias": "t",
"name_col": "t.title", "status_col": "t.status",
"joins": "LEFT JOIN domains d ON t.domain_id = d.id LEFT JOIN projects p ON t.project_id = p.id",
"domain_col": "d.name", "project_col": "p.name",
"url": "/tasks/{id}", "icon": "task",
},
{
"type": "projects",
"label": "Projects",
"query": """
SELECT p.id, p.name, p.status,
d.name as domain_name, NULL as project_name,
ts_rank(p.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM projects p
LEFT JOIN domains d ON p.domain_id = d.id
WHERE p.is_deleted = false AND p.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/projects/{id}",
"icon": "project",
"type": "projects", "label": "Projects", "table": "projects", "alias": "p",
"name_col": "p.name", "status_col": "p.status",
"joins": "LEFT JOIN domains d ON p.domain_id = d.id",
"domain_col": "d.name", "project_col": "NULL",
"url": "/projects/{id}", "icon": "project",
},
{
"type": "notes",
"label": "Notes",
"query": """
SELECT n.id, n.title as name, NULL as status,
d.name as domain_name, p.name as project_name,
ts_rank(n.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM notes n
LEFT JOIN domains d ON n.domain_id = d.id
LEFT JOIN projects p ON n.project_id = p.id
WHERE n.is_deleted = false AND n.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/notes/{id}",
"icon": "note",
"type": "notes", "label": "Notes", "table": "notes", "alias": "n",
"name_col": "n.title", "status_col": "NULL",
"joins": "LEFT JOIN domains d ON n.domain_id = d.id LEFT JOIN projects p ON n.project_id = p.id",
"domain_col": "d.name", "project_col": "p.name",
"url": "/notes/{id}", "icon": "note",
},
{
"type": "contacts",
"label": "Contacts",
"query": """
SELECT c.id, (c.first_name || ' ' || coalesce(c.last_name, '')) as name,
NULL as status, c.company as domain_name, NULL as project_name,
ts_rank(c.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM contacts c
WHERE c.is_deleted = false AND c.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/contacts/{id}",
"icon": "contact",
"type": "contacts", "label": "Contacts", "table": "contacts", "alias": "c",
"name_col": "(c.first_name || ' ' || coalesce(c.last_name, ''))", "status_col": "NULL",
"joins": "",
"domain_col": "c.company", "project_col": "NULL",
"url": "/contacts/{id}", "icon": "contact",
},
{
"type": "links",
"label": "Links",
"query": """
SELECT l.id, l.label as name, NULL as status,
d.name as domain_name, p.name as project_name,
ts_rank(l.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM links l
LEFT JOIN domains d ON l.domain_id = d.id
LEFT JOIN projects p ON l.project_id = p.id
WHERE l.is_deleted = false AND l.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/links",
"icon": "link",
"type": "links", "label": "Links", "table": "links", "alias": "l",
"name_col": "l.label", "status_col": "NULL",
"joins": "LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id",
"domain_col": "d.name", "project_col": "p.name",
"url": "/links", "icon": "link",
},
{
"type": "lists",
"label": "Lists",
"query": """
SELECT l.id, l.name, NULL as status,
d.name as domain_name, p.name as project_name,
ts_rank(l.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM lists l
LEFT JOIN domains d ON l.domain_id = d.id
LEFT JOIN projects p ON l.project_id = p.id
WHERE l.is_deleted = false AND l.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/lists/{id}",
"icon": "list",
"type": "lists", "label": "Lists", "table": "lists", "alias": "l",
"name_col": "l.name", "status_col": "NULL",
"joins": "LEFT JOIN domains d ON l.domain_id = d.id LEFT JOIN projects p ON l.project_id = p.id",
"domain_col": "d.name", "project_col": "p.name",
"url": "/lists/{id}", "icon": "list",
},
{
"type": "meetings",
"label": "Meetings",
"query": """
SELECT m.id, m.title as name, m.status,
NULL as domain_name, NULL as project_name,
ts_rank(m.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM meetings m
WHERE m.is_deleted = false AND m.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/meetings/{id}",
"icon": "meeting",
"type": "meetings", "label": "Meetings", "table": "meetings", "alias": "m",
"name_col": "m.title", "status_col": "m.status",
"joins": "",
"domain_col": "NULL", "project_col": "NULL",
"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",
"type": "decisions", "label": "Decisions", "table": "decisions", "alias": "d",
"name_col": "d.title", "status_col": "d.status",
"joins": "",
"domain_col": "NULL", "project_col": "NULL",
"url": "/decisions/{id}", "icon": "decision",
},
{
"type": "weblinks",
"label": "Weblinks",
"query": """
SELECT w.id, w.label as name, NULL as status,
NULL as domain_name, NULL as project_name,
ts_rank(w.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM weblinks w
WHERE w.is_deleted = false AND w.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/weblinks",
"icon": "weblink",
"type": "weblinks", "label": "Weblinks", "table": "weblinks", "alias": "w",
"name_col": "w.label", "status_col": "NULL",
"joins": "",
"domain_col": "NULL", "project_col": "NULL",
"url": "/weblinks", "icon": "weblink",
},
{
"type": "processes",
"label": "Processes",
"query": """
SELECT p.id, p.name, p.status,
p.category as domain_name, NULL as project_name,
ts_rank(p.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM processes p
WHERE p.is_deleted = false AND p.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/processes/{id}",
"icon": "process",
"type": "processes", "label": "Processes", "table": "processes", "alias": "p",
"name_col": "p.name", "status_col": "p.status",
"joins": "",
"domain_col": "p.category", "project_col": "NULL",
"url": "/processes/{id}", "icon": "process",
},
{
"type": "appointments",
"label": "Appointments",
"query": """
SELECT a.id, a.title as name, NULL as status,
a.location as domain_name, NULL as project_name,
ts_rank(a.search_vector, websearch_to_tsquery('english', :q)) as rank
FROM appointments a
WHERE a.is_deleted = false AND a.search_vector @@ websearch_to_tsquery('english', :q)
ORDER BY rank DESC LIMIT :lim
""",
"url": "/appointments/{id}",
"icon": "appointment",
"type": "appointments", "label": "Appointments", "table": "appointments", "alias": "a",
"name_col": "a.title", "status_col": "NULL",
"joins": "",
"domain_col": "a.location", "project_col": "NULL",
"url": "/appointments/{id}", "icon": "appointment",
},
]
async def _search_entity(entity: dict, q: str, tsquery_str: str, limit: int, db: AsyncSession) -> list[dict]:
"""Search a single entity using prefix tsquery, with ILIKE fallback."""
a = entity["alias"]
results = []
seen_ids = set()
# 1. tsvector prefix search
if tsquery_str:
sql = f"""
SELECT {a}.id, {entity['name_col']} as name, {entity['status_col']} as status,
{entity['domain_col']} as domain_name, {entity['project_col']} as project_name,
ts_rank({a}.search_vector, to_tsquery('english', :tsq)) as rank
FROM {entity['table']} {a}
{entity['joins']}
WHERE {a}.is_deleted = false AND {a}.search_vector @@ to_tsquery('english', :tsq)
ORDER BY rank DESC LIMIT :lim
"""
try:
result = await db.execute(text(sql), {"tsq": tsquery_str, "lim": limit})
for r in result:
row = dict(r._mapping)
results.append(row)
seen_ids.add(str(row["id"]))
except Exception:
pass
# 2. ILIKE fallback if < 3 tsvector results
if len(results) < 3:
ilike_param = f"%{q.strip()}%"
remaining = limit - len(results)
if remaining > 0:
# Build exclusion for already-found IDs
exclude_sql = ""
params = {"ilike_q": ilike_param, "lim2": remaining}
if seen_ids:
id_placeholders = ", ".join(f":ex_{i}" for i in range(len(seen_ids)))
exclude_sql = f"AND {a}.id NOT IN ({id_placeholders})"
for i, sid in enumerate(seen_ids):
params[f"ex_{i}"] = sid
sql2 = f"""
SELECT {a}.id, {entity['name_col']} as name, {entity['status_col']} as status,
{entity['domain_col']} as domain_name, {entity['project_col']} as project_name,
0.0 as rank
FROM {entity['table']} {a}
{entity['joins']}
WHERE {a}.is_deleted = false AND {entity['name_col']} ILIKE :ilike_q {exclude_sql}
ORDER BY {entity['name_col']} LIMIT :lim2
"""
try:
result = await db.execute(text(sql2), params)
for r in result:
results.append(dict(r._mapping))
except Exception:
pass
return results
@router.get("/api")
async def search_api(
q: str = Query("", min_length=1),
@@ -190,33 +179,30 @@ async def search_api(
db: AsyncSession = Depends(get_db),
):
"""JSON search endpoint for the Cmd/K modal."""
if not q or not q.strip():
if not q or not q.strip() or len(q.strip()) < 2:
return JSONResponse({"results": [], "query": q})
tsquery_str = build_prefix_tsquery(q)
results = []
entities = SEARCH_ENTITIES
if entity_type:
entities = [e for e in entities if e["type"] == entity_type]
for entity in entities:
try:
result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": limit})
rows = [dict(r._mapping) for r in result]
for row in rows:
results.append({
"type": entity["type"],
"type_label": entity["label"],
"id": str(row["id"]),
"name": row["name"],
"status": row.get("status"),
"context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])),
"url": entity["url"].format(id=row["id"]),
"rank": float(row.get("rank", 0)),
"icon": entity["icon"],
})
except Exception:
# Table might not have search_vector yet, skip silently
continue
rows = await _search_entity(entity, q, tsquery_str, limit, db)
for row in rows:
results.append({
"type": entity["type"],
"type_label": entity["label"],
"id": str(row["id"]),
"name": row["name"],
"status": row.get("status"),
"context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])),
"url": entity["url"].format(id=row["id"]),
"rank": float(row.get("rank", 0)),
"icon": entity["icon"],
})
# Sort all results by rank descending
results.sort(key=lambda r: r["rank"], reverse=True)
@@ -234,24 +220,22 @@ async def search_page(
sidebar = await get_sidebar_data(db)
results = []
if q and q.strip():
if q and q.strip() and len(q.strip()) >= 2:
tsquery_str = build_prefix_tsquery(q)
for entity in SEARCH_ENTITIES:
try:
result = await db.execute(text(entity["query"]), {"q": q.strip(), "lim": 10})
rows = [dict(r._mapping) for r in result]
for row in rows:
results.append({
"type": entity["type"],
"type_label": entity["label"],
"id": str(row["id"]),
"name": row["name"],
"status": row.get("status"),
"context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])),
"url": entity["url"].format(id=row["id"]),
"icon": entity["icon"],
})
except Exception:
continue
rows = await _search_entity(entity, q, tsquery_str, 10, db)
for row in rows:
results.append({
"type": entity["type"],
"type_label": entity["label"],
"id": str(row["id"]),
"name": row["name"],
"status": row.get("status"),
"context": " / ".join(filter(None, [row.get("domain_name"), row.get("project_name")])),
"url": entity["url"].format(id=row["id"]),
"icon": entity["icon"],
})
return templates.TemplateResponse("search.html", {
"request": request, "sidebar": sidebar,

View File

@@ -186,7 +186,11 @@ async def create_task(
@router.get("/{task_id}")
async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends(get_db)):
async def task_detail(
task_id: str, request: Request,
tab: str = "overview",
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("tasks", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(task_id)
@@ -212,19 +216,101 @@ async def task_detail(task_id: str, request: Request, db: AsyncSession = Depends
row = result.first()
parent = dict(row._mapping) if row else None
# Subtasks
result = await db.execute(text("""
SELECT * FROM tasks WHERE parent_id = :tid AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"tid": task_id})
subtasks = [dict(r._mapping) for r in result]
# Subtasks (always needed for overview tab)
subtasks = []
if tab == "overview":
result = await db.execute(text("""
SELECT * FROM tasks WHERE parent_id = :tid AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"tid": task_id})
subtasks = [dict(r._mapping) for r in result]
running_task_id = await get_running_task_id(db)
# Tab-specific data
tab_data = []
all_contacts = []
if tab == "notes":
result = await db.execute(text("""
SELECT * FROM notes WHERE task_id = :tid AND is_deleted = false
ORDER BY updated_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "weblinks":
result = await db.execute(text("""
SELECT * FROM weblinks WHERE task_id = :tid AND is_deleted = false
ORDER BY sort_order, label
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "files":
result = await db.execute(text("""
SELECT f.* FROM files f
JOIN file_mappings fm ON fm.file_id = f.id
WHERE fm.context_type = 'task' AND fm.context_id = :tid AND f.is_deleted = false
ORDER BY f.created_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "lists":
result = await db.execute(text("""
SELECT l.*,
(SELECT count(*) FROM list_items li WHERE li.list_id = l.id AND li.is_deleted = false) as item_count
FROM lists l
WHERE l.task_id = :tid AND l.is_deleted = false
ORDER BY l.sort_order, l.created_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "decisions":
result = await db.execute(text("""
SELECT * FROM decisions WHERE task_id = :tid AND is_deleted = false
ORDER BY created_at DESC
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
elif tab == "contacts":
result = await db.execute(text("""
SELECT c.*, ct.role, ct.created_at as linked_at
FROM contacts c
JOIN contact_tasks ct ON ct.contact_id = c.id
WHERE ct.task_id = :tid AND c.is_deleted = false
ORDER BY c.first_name
"""), {"tid": task_id})
tab_data = [dict(r._mapping) for r in result]
# All contacts for add dropdown
result = await db.execute(text("""
SELECT id, first_name, last_name FROM contacts
WHERE is_deleted = false ORDER BY first_name
"""))
all_contacts = [dict(r._mapping) for r in result]
# Tab counts for badges
counts = {}
for count_tab, count_sql in [
("notes", "SELECT count(*) FROM notes WHERE task_id = :tid AND is_deleted = false"),
("weblinks", "SELECT count(*) FROM weblinks WHERE task_id = :tid AND is_deleted = false"),
("files", "SELECT count(*) FROM files f JOIN file_mappings fm ON fm.file_id = f.id WHERE fm.context_type = 'task' AND fm.context_id = :tid AND f.is_deleted = false"),
("lists", "SELECT count(*) FROM lists WHERE task_id = :tid AND is_deleted = false"),
("decisions", "SELECT count(*) FROM decisions WHERE task_id = :tid AND is_deleted = false"),
("contacts", "SELECT count(*) FROM contacts c JOIN contact_tasks ct ON ct.contact_id = c.id WHERE ct.task_id = :tid AND c.is_deleted = false"),
]:
result = await db.execute(text(count_sql), {"tid": task_id})
counts[count_tab] = result.scalar() or 0
# Subtask count for overview badge
result = await db.execute(text(
"SELECT count(*) FROM tasks WHERE parent_id = :tid AND is_deleted = false"
), {"tid": task_id})
counts["overview"] = result.scalar() or 0
return templates.TemplateResponse("task_detail.html", {
"request": request, "sidebar": sidebar, "item": item,
"domain": domain, "project": project, "parent": parent,
"subtasks": subtasks,
"subtasks": subtasks, "tab": tab, "tab_data": tab_data,
"all_contacts": all_contacts, "counts": counts,
"running_task_id": running_task_id,
"page_title": item["title"], "active_nav": "tasks",
})
@@ -368,3 +454,30 @@ async def quick_add(
await repo.create(data)
referer = request.headers.get("referer", "/tasks")
return RedirectResponse(url=referer, status_code=303)
# ---- Contact linking ----
@router.post("/{task_id}/contacts/add")
async def add_contact(
task_id: str,
contact_id: str = Form(...),
role: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
await db.execute(text("""
INSERT INTO contact_tasks (contact_id, task_id, role)
VALUES (:cid, :tid, :role) ON CONFLICT DO NOTHING
"""), {"cid": contact_id, "tid": task_id, "role": role if role and role.strip() else None})
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)
@router.post("/{task_id}/contacts/{contact_id}/remove")
async def remove_contact(
task_id: str, contact_id: str,
db: AsyncSession = Depends(get_db),
):
await db.execute(text(
"DELETE FROM contact_tasks WHERE contact_id = :cid AND task_id = :tid"
), {"cid": contact_id, "tid": task_id})
return RedirectResponse(url=f"/tasks/{task_id}?tab=contacts", status_code=303)

View File

@@ -78,6 +78,8 @@ async def list_weblinks(
async def create_form(
request: Request,
folder_id: Optional[str] = None,
task_id: Optional[str] = None,
meeting_id: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
@@ -92,6 +94,8 @@ async def create_form(
"page_title": "New Weblink", "active_nav": "weblinks",
"item": None,
"prefill_folder_id": folder_id or "",
"prefill_task_id": task_id or "",
"prefill_meeting_id": meeting_id or "",
})
@@ -102,11 +106,17 @@ async def create_weblink(
url: str = Form(...),
description: Optional[str] = Form(None),
folder_id: Optional[str] = Form(None),
task_id: Optional[str] = Form(None),
meeting_id: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("weblinks", db)
data = {"label": label, "url": url, "description": description}
if task_id and task_id.strip():
data["task_id"] = task_id
if meeting_id and meeting_id.strip():
data["meeting_id"] = meeting_id
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
@@ -119,6 +129,10 @@ async def create_weblink(
VALUES (:fid, :wid) ON CONFLICT DO NOTHING
"""), {"fid": folder_id, "wid": weblink["id"]})
if task_id and task_id.strip():
return RedirectResponse(url=f"/tasks/{task_id}?tab=weblinks", status_code=303)
if meeting_id and meeting_id.strip():
return RedirectResponse(url=f"/meetings/{meeting_id}?tab=weblinks", status_code=303)
redirect_url = f"/weblinks?folder_id={folder_id}" if folder_id and folder_id.strip() else "/weblinks"
return RedirectResponse(url=redirect_url, status_code=303)