feat: processes and process runs CRUD
This commit is contained in:
@@ -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():
|
||||
|
||||
2
main.py
2
main.py
@@ -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)
|
||||
|
||||
@@ -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
569
routers/processes.py
Normal 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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
133
templates/process_run_detail.html
Normal file
133
templates/process_run_detail.html
Normal 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 %}
|
||||
46
templates/process_runs.html
Normal file
46
templates/process_runs.html
Normal 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">▶</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
52
templates/processes.html
Normal 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">⚙</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 %}
|
||||
183
templates/processes_detail.html
Normal file
183
templates/processes_detail.html
Normal 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 %}
|
||||
57
templates/processes_form.html
Normal file
57
templates/processes_form.html
Normal 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 %}
|
||||
@@ -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"],))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,6 +41,8 @@ PREFIX_TO_SEED = {
|
||||
"/focus": "focus",
|
||||
"/capture": "capture",
|
||||
"/time": "task",
|
||||
"/processes": "process",
|
||||
"/processes/runs": "process_run",
|
||||
"/files": None,
|
||||
"/admin/trash": None,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user