feat: processes and process runs CRUD

This commit is contained in:
2026-03-01 22:04:24 +00:00
parent dbd40485ba
commit 21bbb169f9
14 changed files with 1095 additions and 0 deletions

View File

@@ -158,6 +158,8 @@ class BaseRepository:
"rationale", "decided_at", "superseded_by_id",
"start_at", "end_at", "location", "agenda", "transcript", "notes_body",
"priority", "recurrence", "mime_type",
"category", "instructions", "expected_output", "estimated_days",
"contact_id", "started_at",
}
clean_data = {}
for k, v in data.items():

View File

@@ -39,6 +39,7 @@ from routers import (
weblinks as weblinks_router,
appointments as appointments_router,
time_tracking as time_tracking_router,
processes as processes_router,
)
@@ -193,3 +194,4 @@ app.include_router(decisions_router.router)
app.include_router(weblinks_router.router)
app.include_router(appointments_router.router)
app.include_router(time_tracking_router.router)
app.include_router(processes_router.router)

View File

@@ -32,6 +32,8 @@ TRASH_ENTITIES = [
{"table": "appointments", "label": "Appointments", "name_col": "title", "url": "/appointments/{id}"},
{"table": "capture", "label": "Capture", "name_col": "raw_text", "url": "/capture"},
{"table": "daily_focus", "label": "Focus Items", "name_col": "id", "url": "/focus"},
{"table": "processes", "label": "Processes", "name_col": "name", "url": "/processes/{id}"},
{"table": "process_runs", "label": "Process Runs", "name_col": "title", "url": "/processes/runs/{id}"},
]

569
routers/processes.py Normal file
View File

@@ -0,0 +1,569 @@
"""Processes: reusable workflows/checklists with runs and step tracking."""
from fastapi import APIRouter, Request, Form, Depends
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from typing import Optional
from datetime import datetime, timezone
from core.database import get_db
from core.base_repository import BaseRepository
from core.sidebar import get_sidebar_data
router = APIRouter(prefix="/processes", tags=["processes"])
templates = Jinja2Templates(directory="templates")
# ── Process Template CRUD ─────────────────────────────────────
@router.get("/")
async def list_processes(
request: Request,
status: Optional[str] = None,
process_type: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
sidebar = await get_sidebar_data(db)
filters = {}
if status:
filters["status"] = status
if process_type:
filters["process_type"] = process_type
repo = BaseRepository("processes", db)
items = await repo.list(filters=filters, sort="sort_order")
# Get step counts per process
result = await db.execute(text("""
SELECT process_id, count(*) as step_count
FROM process_steps WHERE is_deleted = false
GROUP BY process_id
"""))
step_counts = {str(r.process_id): r.step_count for r in result}
for item in items:
item["step_count"] = step_counts.get(str(item["id"]), 0)
return templates.TemplateResponse("processes.html", {
"request": request, "sidebar": sidebar, "items": items,
"current_status": status or "",
"current_type": process_type or "",
"page_title": "Processes", "active_nav": "processes",
})
@router.get("/create")
async def create_form(request: Request, db: AsyncSession = Depends(get_db)):
sidebar = await get_sidebar_data(db)
return templates.TemplateResponse("processes_form.html", {
"request": request, "sidebar": sidebar, "item": None,
"page_title": "New Process", "active_nav": "processes",
})
@router.post("/create")
async def create_process(
request: Request,
name: str = Form(...),
description: Optional[str] = Form(None),
process_type: str = Form("checklist"),
status: str = Form("draft"),
category: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("processes", db)
data = {
"name": name,
"description": description,
"process_type": process_type,
"status": status,
}
if category and category.strip():
data["category"] = category
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
item = await repo.create(data)
return RedirectResponse(url=f"/processes/{item['id']}", status_code=303)
@router.get("/runs")
async def list_all_runs(
request: Request,
status: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
"""List all runs across all processes."""
sidebar = await get_sidebar_data(db)
where = "pr.is_deleted = false"
params = {}
if status:
where += " AND pr.status = :status"
params["status"] = status
result = await db.execute(text(f"""
SELECT pr.*, p.name as process_name,
proj.name as project_name,
(SELECT count(*) FROM process_run_steps WHERE run_id = pr.id AND is_deleted = false) as total_steps,
(SELECT count(*) FROM process_run_steps WHERE run_id = pr.id AND is_deleted = false AND status = 'completed') as completed_steps
FROM process_runs pr
JOIN processes p ON pr.process_id = p.id
LEFT JOIN projects proj ON pr.project_id = proj.id
WHERE {where}
ORDER BY pr.created_at DESC
"""), params)
items = [dict(r._mapping) for r in result]
return templates.TemplateResponse("process_runs.html", {
"request": request, "sidebar": sidebar, "items": items,
"current_status": status or "",
"page_title": "All Process Runs", "active_nav": "processes",
})
@router.get("/runs/{run_id}")
async def run_detail(run_id: str, request: Request, db: AsyncSession = Depends(get_db)):
"""View a specific process run with step checklist."""
sidebar = await get_sidebar_data(db)
# Get the run with process info
result = await db.execute(text("""
SELECT pr.*, p.name as process_name, p.id as process_id_ref,
proj.name as project_name,
c.first_name as contact_first, c.last_name as contact_last
FROM process_runs pr
JOIN processes p ON pr.process_id = p.id
LEFT JOIN projects proj ON pr.project_id = proj.id
LEFT JOIN contacts c ON pr.contact_id = c.id
WHERE pr.id = :id
"""), {"id": run_id})
run = result.first()
if not run:
return RedirectResponse(url="/processes/runs", status_code=303)
run = dict(run._mapping)
# Get run steps
result = await db.execute(text("""
SELECT * FROM process_run_steps
WHERE run_id = :run_id AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"run_id": run_id})
steps = [dict(r._mapping) for r in result]
total = len(steps)
completed = sum(1 for s in steps if s["status"] == "completed")
# Get linked tasks via junction table
result = await db.execute(text("""
SELECT t.id, t.title, t.status, t.priority,
prt.run_step_id,
p.name as project_name
FROM process_run_tasks prt
JOIN tasks t ON prt.task_id = t.id
LEFT JOIN projects p ON t.project_id = p.id
WHERE prt.run_step_id IN (
SELECT id FROM process_run_steps WHERE run_id = :run_id
)
ORDER BY t.created_at
"""), {"run_id": run_id})
tasks = [dict(r._mapping) for r in result]
# Map tasks to their steps
step_tasks = {}
for task in tasks:
sid = str(task["run_step_id"])
step_tasks.setdefault(sid, []).append(task)
return templates.TemplateResponse("process_run_detail.html", {
"request": request, "sidebar": sidebar,
"run": run, "steps": steps, "tasks": tasks,
"step_tasks": step_tasks,
"total_steps": total, "completed_steps": completed,
"page_title": run["title"], "active_nav": "processes",
})
@router.get("/{process_id}")
async def process_detail(process_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("processes", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(process_id)
if not item:
return RedirectResponse(url="/processes", status_code=303)
# Get steps
result = await db.execute(text("""
SELECT * FROM process_steps
WHERE process_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"pid": process_id})
steps = [dict(r._mapping) for r in result]
# Get runs
result = await db.execute(text("""
SELECT pr.*,
proj.name as project_name,
(SELECT count(*) FROM process_run_steps WHERE run_id = pr.id AND is_deleted = false) as total_steps,
(SELECT count(*) FROM process_run_steps WHERE run_id = pr.id AND is_deleted = false AND status = 'completed') as completed_steps
FROM process_runs pr
LEFT JOIN projects proj ON pr.project_id = proj.id
WHERE pr.process_id = :pid AND pr.is_deleted = false
ORDER BY pr.created_at DESC
"""), {"pid": process_id})
runs = [dict(r._mapping) for r in result]
# Load projects and contacts for "Start Run" form
projects_repo = BaseRepository("projects", db)
projects = await projects_repo.list()
contacts_repo = BaseRepository("contacts", db)
contacts = await contacts_repo.list()
return templates.TemplateResponse("processes_detail.html", {
"request": request, "sidebar": sidebar,
"item": item, "steps": steps, "runs": runs,
"projects": projects, "contacts": contacts,
"page_title": item["name"], "active_nav": "processes",
})
@router.get("/{process_id}/edit")
async def edit_form(process_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("processes", db)
sidebar = await get_sidebar_data(db)
item = await repo.get(process_id)
if not item:
return RedirectResponse(url="/processes", status_code=303)
return templates.TemplateResponse("processes_form.html", {
"request": request, "sidebar": sidebar, "item": item,
"page_title": "Edit Process", "active_nav": "processes",
})
@router.post("/{process_id}/edit")
async def update_process(
process_id: str,
name: str = Form(...),
description: Optional[str] = Form(None),
process_type: str = Form("checklist"),
status: str = Form("draft"),
category: Optional[str] = Form(None),
tags: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("processes", db)
data = {
"name": name,
"description": description,
"process_type": process_type,
"status": status,
"category": category if category and category.strip() else None,
}
if tags and tags.strip():
data["tags"] = [t.strip() for t in tags.split(",") if t.strip()]
else:
data["tags"] = None
await repo.update(process_id, data)
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
@router.post("/{process_id}/delete")
async def delete_process(process_id: str, request: Request, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("processes", db)
await repo.soft_delete(process_id)
return RedirectResponse(url="/processes", status_code=303)
# ── Process Steps ─────────────────────────────────────────────
@router.post("/{process_id}/steps/add")
async def add_step(
process_id: str,
title: str = Form(...),
instructions: Optional[str] = Form(None),
expected_output: Optional[str] = Form(None),
estimated_days: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
# Get current max sort_order
result = await db.execute(text("""
SELECT coalesce(max(sort_order), -1) + 1 as next_order
FROM process_steps WHERE process_id = :pid AND is_deleted = false
"""), {"pid": process_id})
next_order = result.scalar()
repo = BaseRepository("process_steps", db)
data = {
"process_id": process_id,
"title": title,
"sort_order": next_order,
}
if instructions and instructions.strip():
data["instructions"] = instructions
if expected_output and expected_output.strip():
data["expected_output"] = expected_output
if estimated_days and estimated_days.strip():
data["estimated_days"] = int(estimated_days)
await repo.create(data)
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
@router.post("/{process_id}/steps/{step_id}/edit")
async def edit_step(
process_id: str,
step_id: str,
title: str = Form(...),
instructions: Optional[str] = Form(None),
expected_output: Optional[str] = Form(None),
estimated_days: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
repo = BaseRepository("process_steps", db)
data = {
"title": title,
"instructions": instructions if instructions and instructions.strip() else None,
"expected_output": expected_output if expected_output and expected_output.strip() else None,
"estimated_days": int(estimated_days) if estimated_days and estimated_days.strip() else None,
}
await repo.update(step_id, data)
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
@router.post("/{process_id}/steps/{step_id}/delete")
async def delete_step(process_id: str, step_id: str, db: AsyncSession = Depends(get_db)):
repo = BaseRepository("process_steps", db)
await repo.soft_delete(step_id)
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
@router.post("/{process_id}/steps/reorder")
async def reorder_steps(
process_id: str,
request: Request,
db: AsyncSession = Depends(get_db),
):
form = await request.form()
ids = form.getlist("step_ids")
if ids:
repo = BaseRepository("process_steps", db)
await repo.reorder(ids)
return RedirectResponse(url=f"/processes/{process_id}", status_code=303)
# ── Process Runs ──────────────────────────────────────────────
@router.post("/{process_id}/runs/start")
async def start_run(
process_id: str,
title: str = Form(...),
task_generation: str = Form("all_at_once"),
project_id: Optional[str] = Form(None),
contact_id: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
"""Start a new process run: snapshot steps, optionally generate tasks."""
# Get process
proc_repo = BaseRepository("processes", db)
process = await proc_repo.get(process_id)
if not process:
return RedirectResponse(url="/processes", status_code=303)
# Create the run
run_repo = BaseRepository("process_runs", db)
run_data = {
"process_id": process_id,
"title": title,
"status": "in_progress",
"process_type": process["process_type"],
"task_generation": task_generation,
"started_at": datetime.now(timezone.utc),
}
if project_id and project_id.strip():
run_data["project_id"] = project_id
if contact_id and contact_id.strip():
run_data["contact_id"] = contact_id
run = await run_repo.create(run_data)
# Snapshot steps from the process template
result = await db.execute(text("""
SELECT * FROM process_steps
WHERE process_id = :pid AND is_deleted = false
ORDER BY sort_order, created_at
"""), {"pid": process_id})
template_steps = [dict(r._mapping) for r in result]
step_repo = BaseRepository("process_run_steps", db)
run_steps = []
for step in template_steps:
rs = await step_repo.create({
"run_id": str(run["id"]),
"title": step["title"],
"instructions": step.get("instructions"),
"status": "pending",
"sort_order": step["sort_order"],
})
run_steps.append(rs)
# Task generation
if run_steps:
await _generate_tasks(db, run, run_steps, task_generation)
return RedirectResponse(url=f"/processes/runs/{run['id']}", status_code=303)
async def _generate_tasks(db, run, run_steps, mode):
"""Generate tasks for run steps based on mode."""
task_repo = BaseRepository("tasks", db)
# Get a default domain for tasks
result = await db.execute(text(
"SELECT id FROM domains WHERE is_deleted = false ORDER BY sort_order LIMIT 1"
))
row = result.first()
default_domain_id = str(row[0]) if row else None
if not default_domain_id:
return
if mode == "all_at_once":
steps_to_generate = run_steps
else: # step_by_step
steps_to_generate = [run_steps[0]]
for step in steps_to_generate:
task_data = {
"title": step["title"],
"description": step.get("instructions") or "",
"status": "open",
"priority": 3,
"domain_id": default_domain_id,
}
if run.get("project_id"):
task_data["project_id"] = str(run["project_id"])
task = await task_repo.create(task_data)
# Link via junction table
await db.execute(text("""
INSERT INTO process_run_tasks (run_step_id, task_id)
VALUES (:rsid, :tid)
ON CONFLICT DO NOTHING
"""), {"rsid": str(step["id"]), "tid": str(task["id"])})
@router.post("/runs/{run_id}/steps/{step_id}/complete")
async def complete_step(
run_id: str,
step_id: str,
notes: Optional[str] = Form(None),
db: AsyncSession = Depends(get_db),
):
"""Mark a run step as completed."""
now = datetime.now(timezone.utc)
step_repo = BaseRepository("process_run_steps", db)
await step_repo.update(step_id, {
"status": "completed",
"completed_at": now,
"notes": notes if notes and notes.strip() else None,
})
# If step_by_step mode, generate task for next pending step
result = await db.execute(text("""
SELECT pr.task_generation FROM process_runs pr WHERE pr.id = :rid
"""), {"rid": run_id})
run_row = result.first()
if run_row and run_row.task_generation == "step_by_step":
# Find next pending step
result = await db.execute(text("""
SELECT * FROM process_run_steps
WHERE run_id = :rid AND is_deleted = false AND status = 'pending'
ORDER BY sort_order LIMIT 1
"""), {"rid": run_id})
next_step = result.first()
if next_step:
next_step = dict(next_step._mapping)
# Get the full run for project_id
run_repo = BaseRepository("process_runs", db)
run = await run_repo.get(run_id)
await _generate_tasks(db, run, [next_step], "all_at_once")
# Auto-complete run if all steps done
result = await db.execute(text("""
SELECT count(*) FILTER (WHERE status != 'completed') as pending
FROM process_run_steps
WHERE run_id = :rid AND is_deleted = false
"""), {"rid": run_id})
pending = result.scalar()
if pending == 0:
run_repo = BaseRepository("process_runs", db)
await run_repo.update(run_id, {
"status": "completed",
"completed_at": now,
})
return RedirectResponse(url=f"/processes/runs/{run_id}", status_code=303)
@router.post("/runs/{run_id}/steps/{step_id}/uncomplete")
async def uncomplete_step(
run_id: str,
step_id: str,
db: AsyncSession = Depends(get_db),
):
"""Undo step completion."""
step_repo = BaseRepository("process_run_steps", db)
await step_repo.update(step_id, {
"status": "pending",
"completed_at": None,
})
# If run was completed, reopen it
run_repo = BaseRepository("process_runs", db)
run = await run_repo.get(run_id)
if run and run["status"] == "completed":
await run_repo.update(run_id, {
"status": "in_progress",
"completed_at": None,
})
return RedirectResponse(url=f"/processes/runs/{run_id}", status_code=303)
@router.post("/runs/{run_id}/complete")
async def complete_run(run_id: str, db: AsyncSession = Depends(get_db)):
"""Mark entire run as complete."""
now = datetime.now(timezone.utc)
run_repo = BaseRepository("process_runs", db)
await run_repo.update(run_id, {
"status": "completed",
"completed_at": now,
})
# Mark all pending steps as completed too
await db.execute(text("""
UPDATE process_run_steps
SET status = 'completed', completed_at = :now, updated_at = :now
WHERE run_id = :rid AND status != 'completed' AND is_deleted = false
"""), {"rid": run_id, "now": now})
return RedirectResponse(url=f"/processes/runs/{run_id}", status_code=303)
@router.post("/runs/{run_id}/delete")
async def delete_run(run_id: str, db: AsyncSession = Depends(get_db)):
# Get process_id before deleting for redirect
run_repo = BaseRepository("process_runs", db)
run = await run_repo.get(run_id)
await run_repo.soft_delete(run_id)
if run:
return RedirectResponse(url=f"/processes/{run['process_id']}", status_code=303)
return RedirectResponse(url="/processes/runs", status_code=303)

View File

@@ -151,6 +151,20 @@ SEARCH_ENTITIES = [
"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": "appointments",
"label": "Appointments",

View File

@@ -48,6 +48,10 @@
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
Lists
</a>
<a href="/processes" class="nav-item {{ 'active' if active_nav == 'processes' }}">
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Processes
</a>
<a href="/meetings" class="nav-item {{ 'active' if active_nav == 'meetings' }}">
<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

View File

@@ -0,0 +1,133 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/processes">Processes</a>
<span class="sep">/</span>
<a href="/processes/{{ run.process_id_ref }}">{{ run.process_name }}</a>
<span class="sep">/</span>
<span>{{ run.title }}</span>
</div>
<div class="detail-header">
<h1 class="detail-title">{{ run.title }}</h1>
<div class="flex gap-2">
{% if run.status != 'completed' %}
<form action="/processes/runs/{{ run.id }}/complete" method="post" data-confirm="Mark this run as complete?" style="display:inline">
<button type="submit" class="btn btn-primary btn-sm">Mark Complete</button>
</form>
{% endif %}
<form action="/processes/runs/{{ run.id }}/delete" method="post" data-confirm="Delete this run?" style="display:inline">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</div>
<div class="detail-meta mt-2">
<span class="status-badge status-{{ run.status }}">{{ run.status|replace('_', ' ') }}</span>
<span class="row-tag">{{ run.process_type }}</span>
<span class="row-tag">{{ run.task_generation|replace('_', ' ') }}</span>
{% if run.project_name %}<span class="detail-meta-item">{{ run.project_name }}</span>{% endif %}
{% if run.contact_first %}<span class="detail-meta-item">{{ run.contact_first }} {{ run.contact_last or '' }}</span>{% endif %}
{% if run.started_at %}<span class="detail-meta-item">Started {{ run.started_at.strftime('%Y-%m-%d') }}</span>{% endif %}
{% if run.completed_at %}<span class="detail-meta-item">Completed {{ run.completed_at.strftime('%Y-%m-%d') }}</span>{% endif %}
</div>
<!-- Progress Bar -->
<div class="card mt-3" style="padding: 16px;">
<div style="display: flex; align-items: center; gap: 12px;">
<span style="font-weight: 600; font-size: 0.9rem;">Progress</span>
<div style="flex: 1; height: 8px; background: var(--border); border-radius: 4px; overflow: hidden;">
<div style="width: {{ (completed_steps / total_steps * 100)|int if total_steps > 0 else 0 }}%; height: 100%; background: var(--green); border-radius: 4px; transition: width 0.3s;"></div>
</div>
<span style="font-weight: 600; font-size: 0.9rem;">{{ completed_steps }}/{{ total_steps }}</span>
</div>
</div>
<!-- Steps Checklist -->
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Steps</h3>
</div>
{% for step in steps %}
<div class="list-row {{ 'completed' if step.status == 'completed' }}" style="align-items: flex-start;">
<div class="row-check">
{% if step.status == 'completed' %}
<form action="/processes/runs/{{ run.id }}/steps/{{ step.id }}/uncomplete" method="post" style="display:inline">
<input type="checkbox" id="step-{{ step.id }}" checked onchange="this.form.submit()">
<label for="step-{{ step.id }}"></label>
</form>
{% else %}
<form action="/processes/runs/{{ run.id }}/steps/{{ step.id }}/complete" method="post" style="display:inline" id="complete-form-{{ step.id }}">
<input type="checkbox" id="step-{{ step.id }}" onchange="this.form.submit()">
<label for="step-{{ step.id }}"></label>
</form>
{% endif %}
</div>
<div style="flex: 1;">
<div style="display: flex; align-items: center; gap: 8px;">
<span class="row-meta" style="min-width: 20px; font-weight: 600;">{{ loop.index }}</span>
<span class="row-title" style="{{ 'text-decoration: line-through; opacity: 0.6;' if step.status == 'completed' }}">{{ step.title }}</span>
</div>
{% if step.instructions %}
<div style="color: var(--muted); font-size: 0.82rem; margin: 4px 0 0 28px;">{{ step.instructions }}</div>
{% endif %}
{% if step.completed_at %}
<div style="color: var(--green); font-size: 0.78rem; margin: 4px 0 0 28px;">
Completed {{ step.completed_at.strftime('%Y-%m-%d %H:%M') }}
</div>
{% endif %}
{% if step.notes %}
<div style="color: var(--muted); font-size: 0.82rem; margin: 2px 0 0 28px; font-style: italic;">{{ step.notes }}</div>
{% endif %}
{% if step.status != 'completed' %}
<div style="margin: 6px 0 0 28px;">
<button type="button" class="btn btn-ghost btn-xs" onclick="toggleNotes('{{ step.id }}')">Add Notes</button>
<div id="notes-{{ step.id }}" style="display: none; margin-top: 4px;">
<form action="/processes/runs/{{ run.id }}/steps/{{ step.id }}/complete" method="post" style="display: flex; gap: 6px; align-items: flex-end;">
<input type="text" name="notes" class="form-input" placeholder="Completion notes..." style="flex: 1; height: 32px; font-size: 0.82rem;">
<button type="submit" class="btn btn-primary btn-sm">Complete with Notes</button>
</form>
</div>
</div>
{% endif %}
<!-- Show linked tasks for this step -->
{% if step_tasks.get(step.id|string) %}
<div style="margin: 6px 0 0 28px;">
{% for task in step_tasks[step.id|string] %}
<div style="display: inline-flex; align-items: center; gap: 4px; margin-right: 8px;">
<span class="status-badge status-{{ task.status }}" style="font-size: 0.72rem;">{{ task.status }}</span>
<a href="/tasks/{{ task.id }}" style="font-size: 0.82rem;">{{ task.title }}</a>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- All Generated Tasks -->
{% if tasks %}
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Generated Tasks<span class="page-count">{{ tasks|length }}</span></h3>
</div>
{% for task in tasks %}
<div class="list-row {{ 'completed' if task.status == 'done' }}">
<span class="priority-dot priority-{{ task.priority }}"></span>
<span class="row-title"><a href="/tasks/{{ task.id }}">{{ task.title }}</a></span>
{% if task.project_name %}<span class="row-tag">{{ task.project_name }}</span>{% endif %}
<span class="status-badge status-{{ task.status }}">{{ task.status|replace('_', ' ') }}</span>
</div>
{% endfor %}
</div>
{% endif %}
<script>
function toggleNotes(stepId) {
var el = document.getElementById('notes-' + stepId);
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
</script>
{% endblock %}

View File

@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">All Process Runs<span class="page-count">{{ items|length }}</span></h1>
<a href="/processes" class="btn btn-secondary">Back to Processes</a>
</div>
<form class="filters-bar" method="get" action="/processes/runs">
<select name="status" class="filter-select" onchange="this.form.submit()">
<option value="">All Statuses</option>
<option value="not_started" {{ 'selected' if current_status == 'not_started' }}>Not Started</option>
<option value="in_progress" {{ 'selected' if current_status == 'in_progress' }}>In Progress</option>
<option value="completed" {{ 'selected' if current_status == 'completed' }}>Completed</option>
</select>
</form>
{% if items %}
<div class="card mt-3">
{% for item in items %}
<div class="list-row">
<span class="row-title"><a href="/processes/runs/{{ item.id }}">{{ item.title }}</a></span>
<span class="row-tag">{{ item.process_name }}</span>
<span class="status-badge status-{{ item.status }}">{{ item.status|replace('_', ' ') }}</span>
{% if item.total_steps > 0 %}
<div class="row-meta" style="display: flex; align-items: center; gap: 6px;">
<div style="width: 60px; height: 4px; background: var(--border); border-radius: 2px; overflow: hidden;">
<div style="width: {{ (item.completed_steps / item.total_steps * 100)|int }}%; height: 100%; background: var(--green); border-radius: 2px;"></div>
</div>
<span>{{ item.completed_steps }}/{{ item.total_steps }}</span>
</div>
{% endif %}
{% if item.project_name %}
<span class="row-tag">{{ item.project_name }}</span>
{% endif %}
<span class="row-meta">{{ item.created_at.strftime('%Y-%m-%d') }}</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state mt-3">
<div class="empty-state-icon">&#9654;</div>
<div class="empty-state-text">No process runs yet</div>
<a href="/processes" class="btn btn-primary">Go to Processes</a>
</div>
{% endif %}
{% endblock %}

52
templates/processes.html Normal file
View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">Processes<span class="page-count">{{ items|length }}</span></h1>
<div class="flex gap-2">
<a href="/processes/runs" class="btn btn-secondary">All Runs</a>
<a href="/processes/create" class="btn btn-primary">+ New Process</a>
</div>
</div>
<form class="filters-bar" method="get" action="/processes">
<select name="status" class="filter-select" onchange="this.form.submit()">
<option value="">All Statuses</option>
<option value="draft" {{ 'selected' if current_status == 'draft' }}>Draft</option>
<option value="active" {{ 'selected' if current_status == 'active' }}>Active</option>
<option value="archived" {{ 'selected' if current_status == 'archived' }}>Archived</option>
</select>
<select name="process_type" class="filter-select" onchange="this.form.submit()">
<option value="">All Types</option>
<option value="workflow" {{ 'selected' if current_type == 'workflow' }}>Workflow</option>
<option value="checklist" {{ 'selected' if current_type == 'checklist' }}>Checklist</option>
</select>
</form>
{% if items %}
<div class="card mt-3">
{% for item in items %}
<div class="list-row">
<span class="row-title"><a href="/processes/{{ item.id }}">{{ item.name }}</a></span>
<span class="row-tag">{{ item.process_type }}</span>
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
<span class="row-meta">{{ item.step_count }} step{{ 's' if item.step_count != 1 }}</span>
{% if item.category %}
<span class="row-tag">{{ item.category }}</span>
{% endif %}
<div class="row-actions">
<a href="/processes/{{ item.id }}/edit" class="btn btn-ghost btn-xs">Edit</a>
<form action="/processes/{{ item.id }}/delete" method="post" data-confirm="Delete this process?" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state mt-3">
<div class="empty-state-icon">&#9881;</div>
<div class="empty-state-text">No processes yet</div>
<a href="/processes/create" class="btn btn-primary">Create First Process</a>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,183 @@
{% extends "base.html" %}
{% block content %}
<div class="breadcrumb">
<a href="/processes">Processes</a>
<span class="sep">/</span>
<span>{{ item.name }}</span>
</div>
<div class="detail-header">
<h1 class="detail-title">{{ item.name }}</h1>
<div class="flex gap-2">
<a href="/processes/{{ item.id }}/edit" class="btn btn-secondary btn-sm">Edit</a>
<form action="/processes/{{ item.id }}/delete" method="post" data-confirm="Delete this process?" style="display:inline">
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
</form>
</div>
</div>
<div class="detail-meta mt-2">
<span class="status-badge status-{{ item.status }}">{{ item.status }}</span>
<span class="row-tag">{{ item.process_type }}</span>
{% if item.category %}<span class="detail-meta-item">{{ item.category }}</span>{% endif %}
<span class="detail-meta-item">{{ steps|length }} step{{ 's' if steps|length != 1 }}</span>
<span class="detail-meta-item">Created {{ item.created_at.strftime('%Y-%m-%d') }}</span>
{% if item.tags %}
<div class="mt-1">{% for tag in item.tags %}<span class="row-tag">{{ tag }}</span>{% endfor %}</div>
{% endif %}
</div>
{% if item.description %}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Description</h3></div>
<div class="detail-body" style="padding: 12px 16px;">{{ item.description }}</div>
</div>
{% endif %}
<!-- Steps -->
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Steps<span class="page-count">{{ steps|length }}</span></h3>
</div>
{% for step in steps %}
<div class="list-row" style="align-items: flex-start;">
<span class="row-meta" style="min-width: 28px; text-align: center; font-weight: 600;">{{ loop.index }}</span>
<div style="flex: 1;">
<span class="row-title">{{ step.title }}</span>
{% if step.instructions %}
<div style="color: var(--muted); font-size: 0.82rem; margin-top: 2px;">{{ step.instructions[:120] }}{{ '...' if step.instructions|length > 120 }}</div>
{% endif %}
{% if step.expected_output %}
<div style="color: var(--muted); font-size: 0.82rem; margin-top: 2px;">Output: {{ step.expected_output[:80] }}</div>
{% endif %}
</div>
{% if step.estimated_days %}
<span class="row-meta">{{ step.estimated_days }}d</span>
{% endif %}
<div class="row-actions">
<button type="button" class="btn btn-ghost btn-xs" onclick="toggleEditStep('{{ step.id }}')">Edit</button>
<form action="/processes/{{ item.id }}/steps/{{ step.id }}/delete" method="post" data-confirm="Delete this step?" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
</form>
</div>
</div>
<!-- Inline edit form (hidden by default) -->
<div id="edit-step-{{ step.id }}" style="display: none; border-bottom: 1px solid var(--border); padding: 12px 16px; background: var(--surface2);">
<form action="/processes/{{ item.id }}/steps/{{ step.id }}/edit" method="post">
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Title *</label>
<input type="text" name="title" class="form-input" value="{{ step.title }}" required>
</div>
<div class="form-group full-width">
<label class="form-label">Instructions</label>
<textarea name="instructions" class="form-textarea" rows="2">{{ step.instructions or '' }}</textarea>
</div>
<div class="form-group">
<label class="form-label">Expected Output</label>
<input type="text" name="expected_output" class="form-input" value="{{ step.expected_output or '' }}">
</div>
<div class="form-group">
<label class="form-label">Estimated Days</label>
<input type="number" name="estimated_days" class="form-input" min="0" value="{{ step.estimated_days or '' }}">
</div>
</div>
<div class="form-actions" style="margin-top: 8px;">
<button type="submit" class="btn btn-primary btn-sm">Save Step</button>
<button type="button" class="btn btn-secondary btn-sm" onclick="toggleEditStep('{{ step.id }}')">Cancel</button>
</div>
</form>
</div>
{% endfor %}
<!-- Quick add step -->
<form class="quick-add" action="/processes/{{ item.id }}/steps/add" method="post" style="border-top: 1px solid var(--border);">
<input type="text" name="title" placeholder="Add a step..." required>
<button type="submit" class="btn btn-primary btn-sm">Add Step</button>
</form>
</div>
<!-- Runs -->
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Runs<span class="page-count">{{ runs|length }}</span></h3>
</div>
{% for run in runs %}
<div class="list-row">
<span class="row-title"><a href="/processes/runs/{{ run.id }}">{{ run.title }}</a></span>
<span class="status-badge status-{{ run.status }}">{{ run.status|replace('_', ' ') }}</span>
{% if run.total_steps > 0 %}
<span class="row-meta">{{ run.completed_steps }}/{{ run.total_steps }} steps</span>
{% endif %}
{% if run.project_name %}
<span class="row-tag">{{ run.project_name }}</span>
{% endif %}
<span class="row-meta">{{ run.created_at.strftime('%Y-%m-%d') }}</span>
<div class="row-actions">
<form action="/processes/runs/{{ run.id }}/delete" method="post" data-confirm="Delete this run?" style="display:inline">
<button type="submit" class="btn btn-ghost btn-xs" style="color: var(--red)">Del</button>
</form>
</div>
</div>
{% endfor %}
{% if not runs %}
<div style="padding: 16px; color: var(--muted); font-size: 0.85rem;">No runs yet</div>
{% endif %}
<!-- Start Run Form -->
{% if steps %}
<div style="border-top: 1px solid var(--border); padding: 12px 16px;">
<button type="button" class="btn btn-primary btn-sm" onclick="document.getElementById('start-run-form').style.display = document.getElementById('start-run-form').style.display === 'none' ? 'block' : 'none'">+ Start Run</button>
<div id="start-run-form" style="display: none; margin-top: 12px;">
<form action="/processes/{{ item.id }}/runs/start" method="post">
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Run Title *</label>
<input type="text" name="title" class="form-input" required
value="{{ item.name }} - Run">
</div>
<div class="form-group">
<label class="form-label">Task Generation</label>
<select name="task_generation" class="form-select">
<option value="all_at_once">All at Once</option>
<option value="step_by_step">Step by Step</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Project</label>
<select name="project_id" class="form-select">
<option value="">None</option>
{% for p in projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label class="form-label">Contact</label>
<select name="contact_id" class="form-select">
<option value="">None</option>
{% for c in contacts %}
<option value="{{ c.id }}">{{ c.first_name }} {{ c.last_name or '' }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-actions" style="margin-top: 8px;">
<button type="submit" class="btn btn-primary btn-sm">Start Run</button>
</div>
</form>
</div>
</div>
{% endif %}
</div>
<script>
function toggleEditStep(stepId) {
var el = document.getElementById('edit-step-' + stepId);
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
</script>
{% endblock %}

View File

@@ -0,0 +1,57 @@
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1 class="page-title">{{ page_title }}</h1>
</div>
<div class="card">
<form method="post" action="{{ '/processes/' ~ item.id ~ '/edit' if item else '/processes/create' }}">
<div class="form-grid">
<div class="form-group full-width">
<label class="form-label">Name *</label>
<input type="text" name="name" class="form-input" required
value="{{ item.name if item else '' }}">
</div>
<div class="form-group full-width">
<label class="form-label">Description</label>
<textarea name="description" class="form-textarea" rows="3">{{ item.description if item and item.description else '' }}</textarea>
</div>
<div class="form-group">
<label class="form-label">Type</label>
<select name="process_type" class="form-select">
<option value="checklist" {{ 'selected' if item and item.process_type == 'checklist' }}>Checklist</option>
<option value="workflow" {{ 'selected' if item and item.process_type == 'workflow' }}>Workflow</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Status</label>
<select name="status" class="form-select">
<option value="draft" {{ 'selected' if item and item.status == 'draft' }}>Draft</option>
<option value="active" {{ 'selected' if item and item.status == 'active' }}>Active</option>
<option value="archived" {{ 'selected' if item and item.status == 'archived' }}>Archived</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Category</label>
<input type="text" name="category" class="form-input" placeholder="e.g. Onboarding, Publishing..."
value="{{ item.category if item and item.category else '' }}">
</div>
<div class="form-group">
<label class="form-label">Tags</label>
<input type="text" name="tags" class="form-input" placeholder="tag1, tag2, ..."
value="{{ item.tags|join(', ') if item and item.tags else '' }}">
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'Save Changes' if item else 'Create Process' }}</button>
<a href="{{ '/processes/' ~ item.id if item else '/processes' }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
{% endblock %}

View File

@@ -29,6 +29,9 @@ SEED_IDS = {
"weblink": "a0000000-0000-0000-0000-00000000000d",
"capture": "a0000000-0000-0000-0000-00000000000e",
"focus": "a0000000-0000-0000-0000-00000000000f",
"process": "a0000000-0000-0000-0000-000000000010",
"process_step": "a0000000-0000-0000-0000-000000000011",
"process_run": "a0000000-0000-0000-0000-000000000012",
}
@@ -196,6 +199,27 @@ def all_seeds(sync_conn):
ON CONFLICT (id) DO NOTHING
""", (d["focus"], d["task"]))
# Process
cur.execute("""
INSERT INTO processes (id, name, process_type, status, category, is_deleted, created_at, updated_at)
VALUES (%s, 'Test Process', 'checklist', 'active', 'Testing', false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["process"],))
# Process step
cur.execute("""
INSERT INTO process_steps (id, process_id, title, instructions, sort_order, is_deleted, created_at, updated_at)
VALUES (%s, %s, 'Test Step', 'Do the thing', 0, false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["process_step"], d["process"]))
# Process run
cur.execute("""
INSERT INTO process_runs (id, process_id, title, status, process_type, task_generation, is_deleted, created_at, updated_at)
VALUES (%s, %s, 'Test Run', 'not_started', 'checklist', 'all_at_once', false, now(), now())
ON CONFLICT (id) DO NOTHING
""", (d["process_run"], d["process"]))
sync_conn.commit()
except Exception as e:
sync_conn.rollback()
@@ -205,6 +229,9 @@ def all_seeds(sync_conn):
# Cleanup: delete all seed data (reverse dependency order)
try:
cur.execute("DELETE FROM process_runs WHERE id = %s", (d["process_run"],))
cur.execute("DELETE FROM process_steps WHERE id = %s", (d["process_step"],))
cur.execute("DELETE FROM processes WHERE id = %s", (d["process"],))
cur.execute("DELETE FROM daily_focus WHERE id = %s", (d["focus"],))
cur.execute("DELETE FROM capture WHERE id = %s", (d["capture"],))
cur.execute("DELETE FROM folder_weblinks WHERE weblink_id = %s", (d["weblink"],))

View File

@@ -34,6 +34,8 @@ FK_FIELD_MAP = {
"release_id": None,
"note_id": "note",
"list_id": "list",
"process_id": "process",
"run_id": "process_run",
}
# Field name pattern -> static test value

View File

@@ -41,6 +41,8 @@ PREFIX_TO_SEED = {
"/focus": "focus",
"/capture": "capture",
"/time": "task",
"/processes": "process",
"/processes/runs": "process_run",
"/files": None,
"/admin/trash": None,
}