Refactor backend to SQLModel; reset schema; add Company OS endpoints
This commit is contained in:
Binary file not shown.
@@ -6,16 +6,18 @@ from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
from app.models import task # noqa: F401
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
# Import models to register tables in metadata
|
||||
from app import models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Use SQLAlchemy models metadata
|
||||
target_metadata = Base.metadata
|
||||
|
||||
target_metadata = SQLModel.metadata
|
||||
|
||||
|
||||
def get_url() -> str:
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""initial company os (sqlmodel)
|
||||
|
||||
Revision ID: 157587037601
|
||||
Revises:
|
||||
Create Date: 2026-02-01 23:16:42.890750
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '157587037601'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
pass
|
||||
# ### end Alembic commands ###
|
||||
BIN
Binary file not shown.
Binary file not shown.
@@ -1,43 +0,0 @@
|
||||
"""create tasks
|
||||
|
||||
Revision ID: ce4f1502f674
|
||||
Revises:
|
||||
Create Date: 2026-02-01 22:23:55.940851
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ce4f1502f674'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('tasks',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(length=200), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(length=32), nullable=False),
|
||||
sa.Column('assignee', sa.String(length=120), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tasks_id'), 'tasks', ['id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_tasks_id'), table_name='tasks')
|
||||
op.drop_table('tasks')
|
||||
# ### end Alembic commands ###
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.db.session import get_session
|
||||
from app.models.activity import Activity
|
||||
|
||||
router = APIRouter(prefix="/activities", tags=["activities"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_activities(limit: int = 50, session: Session = Depends(get_session)):
|
||||
items = session.exec(select(Activity).order_by(Activity.id.desc()).limit(max(1, min(limit, 200)))).all()
|
||||
out = []
|
||||
for a in items:
|
||||
out.append(
|
||||
{
|
||||
"id": a.id,
|
||||
"actor_employee_id": a.actor_employee_id,
|
||||
"entity_type": a.entity_type,
|
||||
"entity_id": a.entity_id,
|
||||
"verb": a.verb,
|
||||
"payload": json.loads(a.payload_json) if a.payload_json else None,
|
||||
"created_at": a.created_at,
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.utils import log_activity
|
||||
from app.db.session import get_session
|
||||
from app.models.hr import EmploymentAction, HeadcountRequest
|
||||
from app.schemas.hr import EmploymentActionCreate, HeadcountRequestCreate, HeadcountRequestUpdate
|
||||
|
||||
router = APIRouter(prefix="/hr", tags=["hr"])
|
||||
|
||||
|
||||
@router.get("/headcount", response_model=list[HeadcountRequest])
|
||||
def list_headcount_requests(session: Session = Depends(get_session)):
|
||||
return session.exec(select(HeadcountRequest).order_by(HeadcountRequest.id.desc())).all()
|
||||
|
||||
|
||||
@router.post("/headcount", response_model=HeadcountRequest)
|
||||
def create_headcount_request(payload: HeadcountRequestCreate, session: Session = Depends(get_session)):
|
||||
req = HeadcountRequest(**payload.model_dump())
|
||||
session.add(req)
|
||||
session.commit()
|
||||
session.refresh(req)
|
||||
log_activity(session, actor_employee_id=req.requested_by_manager_id, entity_type="headcount_request", entity_id=req.id, verb="submitted")
|
||||
session.commit()
|
||||
return req
|
||||
|
||||
|
||||
@router.patch("/headcount/{request_id}", response_model=HeadcountRequest)
|
||||
def update_headcount_request(request_id: int, payload: HeadcountRequestUpdate, session: Session = Depends(get_session)):
|
||||
req = session.get(HeadcountRequest, request_id)
|
||||
if not req:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for k, v in data.items():
|
||||
setattr(req, k, v)
|
||||
|
||||
session.add(req)
|
||||
session.commit()
|
||||
session.refresh(req)
|
||||
log_activity(session, actor_employee_id=req.requested_by_manager_id, entity_type="headcount_request", entity_id=req.id, verb="updated", payload=data)
|
||||
session.commit()
|
||||
return req
|
||||
|
||||
|
||||
@router.get("/actions", response_model=list[EmploymentAction])
|
||||
def list_employment_actions(session: Session = Depends(get_session)):
|
||||
return session.exec(select(EmploymentAction).order_by(EmploymentAction.id.desc())).all()
|
||||
|
||||
|
||||
@router.post("/actions", response_model=EmploymentAction)
|
||||
def create_employment_action(payload: EmploymentActionCreate, session: Session = Depends(get_session)):
|
||||
action = EmploymentAction(**payload.model_dump())
|
||||
session.add(action)
|
||||
session.commit()
|
||||
session.refresh(action)
|
||||
log_activity(session, actor_employee_id=action.issued_by_employee_id, entity_type="employment_action", entity_id=action.id, verb=action.action_type, payload={"employee_id": action.employee_id})
|
||||
session.commit()
|
||||
return action
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.utils import log_activity
|
||||
from app.db.session import get_session
|
||||
from app.models.org import Department, Employee
|
||||
from app.schemas.org import DepartmentCreate, DepartmentUpdate, EmployeeCreate, EmployeeUpdate
|
||||
|
||||
router = APIRouter(tags=["org"])
|
||||
|
||||
|
||||
@router.get("/departments", response_model=list[Department])
|
||||
def list_departments(session: Session = Depends(get_session)):
|
||||
return session.exec(select(Department).order_by(Department.name.asc())).all()
|
||||
|
||||
|
||||
@router.post("/departments", response_model=Department)
|
||||
def create_department(payload: DepartmentCreate, session: Session = Depends(get_session)):
|
||||
dept = Department(name=payload.name, head_employee_id=payload.head_employee_id)
|
||||
session.add(dept)
|
||||
session.commit()
|
||||
session.refresh(dept)
|
||||
log_activity(session, actor_employee_id=None, entity_type="department", entity_id=dept.id, verb="created", payload={"name": dept.name})
|
||||
session.commit()
|
||||
return dept
|
||||
|
||||
|
||||
@router.patch("/departments/{department_id}", response_model=Department)
|
||||
def update_department(department_id: int, payload: DepartmentUpdate, session: Session = Depends(get_session)):
|
||||
dept = session.get(Department, department_id)
|
||||
if not dept:
|
||||
raise HTTPException(status_code=404, detail="Department not found")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for k, v in data.items():
|
||||
setattr(dept, k, v)
|
||||
|
||||
session.add(dept)
|
||||
session.commit()
|
||||
session.refresh(dept)
|
||||
log_activity(session, actor_employee_id=None, entity_type="department", entity_id=dept.id, verb="updated", payload=data)
|
||||
session.commit()
|
||||
return dept
|
||||
|
||||
|
||||
@router.get("/employees", response_model=list[Employee])
|
||||
def list_employees(session: Session = Depends(get_session)):
|
||||
return session.exec(select(Employee).order_by(Employee.id.asc())).all()
|
||||
|
||||
|
||||
@router.post("/employees", response_model=Employee)
|
||||
def create_employee(payload: EmployeeCreate, session: Session = Depends(get_session)):
|
||||
emp = Employee(**payload.model_dump())
|
||||
session.add(emp)
|
||||
session.commit()
|
||||
session.refresh(emp)
|
||||
log_activity(session, actor_employee_id=None, entity_type="employee", entity_id=emp.id, verb="created", payload={"name": emp.name, "type": emp.employee_type})
|
||||
session.commit()
|
||||
return emp
|
||||
|
||||
|
||||
@router.patch("/employees/{employee_id}", response_model=Employee)
|
||||
def update_employee(employee_id: int, payload: EmployeeUpdate, session: Session = Depends(get_session)):
|
||||
emp = session.get(Employee, employee_id)
|
||||
if not emp:
|
||||
raise HTTPException(status_code=404, detail="Employee not found")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for k, v in data.items():
|
||||
setattr(emp, k, v)
|
||||
|
||||
session.add(emp)
|
||||
session.commit()
|
||||
session.refresh(emp)
|
||||
log_activity(session, actor_employee_id=None, entity_type="employee", entity_id=emp.id, verb="updated", payload=data)
|
||||
session.commit()
|
||||
return emp
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.utils import log_activity
|
||||
from app.db.session import get_session
|
||||
from app.models.projects import Project
|
||||
from app.schemas.projects import ProjectCreate, ProjectUpdate
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[Project])
|
||||
def list_projects(session: Session = Depends(get_session)):
|
||||
return session.exec(select(Project).order_by(Project.name.asc())).all()
|
||||
|
||||
|
||||
@router.post("", response_model=Project)
|
||||
def create_project(payload: ProjectCreate, session: Session = Depends(get_session)):
|
||||
proj = Project(**payload.model_dump())
|
||||
session.add(proj)
|
||||
session.commit()
|
||||
session.refresh(proj)
|
||||
log_activity(session, actor_employee_id=None, entity_type="project", entity_id=proj.id, verb="created", payload={"name": proj.name})
|
||||
session.commit()
|
||||
return proj
|
||||
|
||||
|
||||
@router.patch("/{project_id}", response_model=Project)
|
||||
def update_project(project_id: int, payload: ProjectUpdate, session: Session = Depends(get_session)):
|
||||
proj = session.get(Project, project_id)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for k, v in data.items():
|
||||
setattr(proj, k, v)
|
||||
|
||||
session.add(proj)
|
||||
session.commit()
|
||||
session.refresh(proj)
|
||||
log_activity(session, actor_employee_id=None, entity_type="project", entity_id=proj.id, verb="updated", payload=data)
|
||||
session.commit()
|
||||
return proj
|
||||
@@ -1,64 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import SessionLocal
|
||||
from app.models.task import Task
|
||||
from app.schemas.task import TaskCreate, TaskOut, TaskUpdate
|
||||
|
||||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("", response_model=list[TaskOut])
|
||||
def list_tasks(db: Session = Depends(get_db)):
|
||||
return db.query(Task).order_by(Task.id.desc()).all()
|
||||
|
||||
|
||||
@router.post("", response_model=TaskOut)
|
||||
def create_task(payload: TaskCreate, db: Session = Depends(get_db)):
|
||||
task = Task(
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
status=payload.status,
|
||||
assignee=payload.assignee,
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
@router.patch("/{task_id}", response_model=TaskOut)
|
||||
def update_task(task_id: int, payload: TaskUpdate, db: Session = Depends(get_db)):
|
||||
task = db.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for k, v in data.items():
|
||||
setattr(task, k, v)
|
||||
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
def delete_task(task_id: int, db: Session = Depends(get_db)):
|
||||
task = db.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
db.delete(task)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.models.activity import Activity
|
||||
|
||||
|
||||
def log_activity(
|
||||
session: Session,
|
||||
*,
|
||||
actor_employee_id: int | None,
|
||||
entity_type: str,
|
||||
entity_id: int | None,
|
||||
verb: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
session.add(
|
||||
Activity(
|
||||
actor_employee_id=actor_employee_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
verb=verb,
|
||||
payload_json=json.dumps(payload) if payload is not None else None,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.api.utils import log_activity
|
||||
from app.db.session import get_session
|
||||
from app.models.work import Task, TaskComment
|
||||
from app.schemas.work import TaskCommentCreate, TaskCreate, TaskUpdate
|
||||
|
||||
router = APIRouter(tags=["work"])
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=list[Task])
|
||||
def list_tasks(project_id: int | None = None, session: Session = Depends(get_session)):
|
||||
stmt = select(Task).order_by(Task.id.asc())
|
||||
if project_id is not None:
|
||||
stmt = stmt.where(Task.project_id == project_id)
|
||||
return session.exec(stmt).all()
|
||||
|
||||
|
||||
@router.post("/tasks", response_model=Task)
|
||||
def create_task(payload: TaskCreate, session: Session = Depends(get_session)):
|
||||
task = Task(**payload.model_dump())
|
||||
task.updated_at = datetime.utcnow()
|
||||
session.add(task)
|
||||
session.commit()
|
||||
session.refresh(task)
|
||||
log_activity(session, actor_employee_id=task.created_by_employee_id, entity_type="task", entity_id=task.id, verb="created", payload={"project_id": task.project_id, "title": task.title})
|
||||
session.commit()
|
||||
return task
|
||||
|
||||
|
||||
@router.patch("/tasks/{task_id}", response_model=Task)
|
||||
def update_task(task_id: int, payload: TaskUpdate, session: Session = Depends(get_session)):
|
||||
task = session.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for k, v in data.items():
|
||||
setattr(task, k, v)
|
||||
task.updated_at = datetime.utcnow()
|
||||
|
||||
session.add(task)
|
||||
session.commit()
|
||||
session.refresh(task)
|
||||
log_activity(session, actor_employee_id=None, entity_type="task", entity_id=task.id, verb="updated", payload=data)
|
||||
session.commit()
|
||||
return task
|
||||
|
||||
|
||||
@router.delete("/tasks/{task_id}")
|
||||
def delete_task(task_id: int, session: Session = Depends(get_session)):
|
||||
task = session.get(Task, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
session.delete(task)
|
||||
session.commit()
|
||||
log_activity(session, actor_employee_id=None, entity_type="task", entity_id=task_id, verb="deleted")
|
||||
session.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/task-comments", response_model=list[TaskComment])
|
||||
def list_task_comments(task_id: int, session: Session = Depends(get_session)):
|
||||
return session.exec(select(TaskComment).where(TaskComment.task_id == task_id).order_by(TaskComment.id.asc())).all()
|
||||
|
||||
|
||||
@router.post("/task-comments", response_model=TaskComment)
|
||||
def create_task_comment(payload: TaskCommentCreate, session: Session = Depends(get_session)):
|
||||
c = TaskComment(**payload.model_dump())
|
||||
session.add(c)
|
||||
session.commit()
|
||||
session.refresh(c)
|
||||
log_activity(session, actor_employee_id=c.author_employee_id, entity_type="task", entity_id=c.task_id, verb="commented")
|
||||
session.commit()
|
||||
return c
|
||||
Binary file not shown.
@@ -1,5 +0,0 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
@@ -1,9 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlmodel import Session, SQLModel, create_engine
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
engine = create_engine(settings.database_url, echo=False)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def get_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
+19
-4
@@ -3,10 +3,15 @@ from __future__ import annotations
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.tasks import router as tasks_router
|
||||
from app.api.activities import router as activities_router
|
||||
from app.api.hr import router as hr_router
|
||||
from app.api.org import router as org_router
|
||||
from app.api.projects import router as projects_router
|
||||
from app.api.work import router as work_router
|
||||
from app.core.config import settings
|
||||
from app.db.session import init_db
|
||||
|
||||
app = FastAPI(title="OpenClaw Agency API", version="0.1.0")
|
||||
app = FastAPI(title="OpenClaw Agency API", version="0.3.0")
|
||||
|
||||
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
||||
if origins:
|
||||
@@ -14,11 +19,21 @@ if origins:
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"] ,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(tasks_router)
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
init_db()
|
||||
|
||||
|
||||
app.include_router(org_router)
|
||||
app.include_router(projects_router)
|
||||
app.include_router(work_router)
|
||||
app.include_router(hr_router)
|
||||
app.include_router(activities_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -1,2 +1,17 @@
|
||||
# Import models here so Alembic can discover them
|
||||
from .task import Task # noqa: F401
|
||||
from app.models.org import Department, Employee
|
||||
from app.models.projects import Project, ProjectMember
|
||||
from app.models.work import Task, TaskComment
|
||||
from app.models.hr import HeadcountRequest, EmploymentAction
|
||||
from app.models.activity import Activity
|
||||
|
||||
__all__ = [
|
||||
"Department",
|
||||
"Employee",
|
||||
"Project",
|
||||
"ProjectMember",
|
||||
"Task",
|
||||
"TaskComment",
|
||||
"HeadcountRequest",
|
||||
"EmploymentAction",
|
||||
"Activity",
|
||||
]
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class Activity(SQLModel, table=True):
|
||||
__tablename__ = "activities"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
actor_employee_id: int | None = Field(default=None, foreign_key="employees.id")
|
||||
|
||||
entity_type: str
|
||||
entity_id: int | None = None
|
||||
verb: str
|
||||
|
||||
payload_json: str | None = None
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class HeadcountRequest(SQLModel, table=True):
|
||||
__tablename__ = "headcount_requests"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
department_id: int = Field(foreign_key="departments.id")
|
||||
requested_by_manager_id: int = Field(foreign_key="employees.id")
|
||||
|
||||
role_title: str
|
||||
employee_type: str # human | agent
|
||||
quantity: int = Field(default=1)
|
||||
|
||||
justification: str | None = None
|
||||
status: str = Field(default="submitted")
|
||||
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class EmploymentAction(SQLModel, table=True):
|
||||
__tablename__ = "employment_actions"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
employee_id: int = Field(foreign_key="employees.id")
|
||||
issued_by_employee_id: int = Field(foreign_key="employees.id")
|
||||
|
||||
action_type: str # praise|warning|pip|termination
|
||||
notes: str | None = None
|
||||
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class Department(SQLModel, table=True):
|
||||
__tablename__ = "departments"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
name: str = Field(index=True, unique=True)
|
||||
head_employee_id: int | None = Field(default=None, foreign_key="employees.id")
|
||||
|
||||
|
||||
class Employee(SQLModel, table=True):
|
||||
__tablename__ = "employees"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
name: str
|
||||
employee_type: str # human | agent
|
||||
|
||||
department_id: int | None = Field(default=None, foreign_key="departments.id")
|
||||
manager_id: int | None = Field(default=None, foreign_key="employees.id")
|
||||
|
||||
title: str | None = None
|
||||
status: str = Field(default="active")
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class Project(SQLModel, table=True):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
name: str = Field(index=True, unique=True)
|
||||
status: str = Field(default="active")
|
||||
|
||||
|
||||
class ProjectMember(SQLModel, table=True):
|
||||
__tablename__ = "project_members"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
project_id: int = Field(foreign_key="projects.id")
|
||||
employee_id: int = Field(foreign_key="employees.id")
|
||||
role: str | None = None
|
||||
@@ -1,28 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Integer, String, Text
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# kanban columns: todo | doing | done
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="todo")
|
||||
|
||||
# simple attribution (no auth)
|
||||
assignee: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
class Task(SQLModel, table=True):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
|
||||
project_id: int = Field(foreign_key="projects.id", index=True)
|
||||
title: str
|
||||
description: str | None = None
|
||||
|
||||
status: str = Field(default="backlog", index=True)
|
||||
|
||||
assignee_employee_id: int | None = Field(default=None, foreign_key="employees.id")
|
||||
reviewer_employee_id: int | None = Field(default=None, foreign_key="employees.id")
|
||||
created_by_employee_id: int | None = Field(default=None, foreign_key="employees.id")
|
||||
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
|
||||
class TaskComment(SQLModel, table=True):
|
||||
__tablename__ = "task_comments"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
task_id: int = Field(foreign_key="tasks.id", index=True)
|
||||
author_employee_id: int | None = Field(default=None, foreign_key="employees.id")
|
||||
body: str
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class HeadcountRequestCreate(SQLModel):
|
||||
department_id: int
|
||||
requested_by_manager_id: int
|
||||
role_title: str
|
||||
employee_type: str
|
||||
quantity: int = 1
|
||||
justification: str | None = None
|
||||
|
||||
|
||||
class HeadcountRequestUpdate(SQLModel):
|
||||
status: str | None = None
|
||||
justification: str | None = None
|
||||
|
||||
|
||||
class EmploymentActionCreate(SQLModel):
|
||||
employee_id: int
|
||||
issued_by_employee_id: int
|
||||
action_type: str
|
||||
notes: str | None = None
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class DepartmentCreate(SQLModel):
|
||||
name: str
|
||||
head_employee_id: int | None = None
|
||||
|
||||
|
||||
class DepartmentUpdate(SQLModel):
|
||||
name: str | None = None
|
||||
head_employee_id: int | None = None
|
||||
|
||||
|
||||
class EmployeeCreate(SQLModel):
|
||||
name: str
|
||||
employee_type: str
|
||||
department_id: int | None = None
|
||||
manager_id: int | None = None
|
||||
title: str | None = None
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class EmployeeUpdate(SQLModel):
|
||||
name: str | None = None
|
||||
employee_type: str | None = None
|
||||
department_id: int | None = None
|
||||
manager_id: int | None = None
|
||||
title: str | None = None
|
||||
status: str | None = None
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class ProjectCreate(SQLModel):
|
||||
name: str
|
||||
status: str = "active"
|
||||
|
||||
|
||||
class ProjectUpdate(SQLModel):
|
||||
name: str | None = None
|
||||
status: str | None = None
|
||||
@@ -1,35 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
TaskStatus = Literal["todo", "doing", "done"]
|
||||
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
description: str | None = None
|
||||
status: TaskStatus = "todo"
|
||||
assignee: str | None = None
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
description: str | None = None
|
||||
status: TaskStatus | None = None
|
||||
assignee: str | None = None
|
||||
|
||||
|
||||
class TaskOut(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
description: str | None
|
||||
status: TaskStatus
|
||||
assignee: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class TaskCreate(SQLModel):
|
||||
project_id: int
|
||||
title: str
|
||||
description: str | None = None
|
||||
status: str = "backlog"
|
||||
assignee_employee_id: int | None = None
|
||||
reviewer_employee_id: int | None = None
|
||||
created_by_employee_id: int | None = None
|
||||
|
||||
|
||||
class TaskUpdate(SQLModel):
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
status: str | None = None
|
||||
assignee_employee_id: int | None = None
|
||||
reviewer_employee_id: int | None = None
|
||||
|
||||
|
||||
class TaskCommentCreate(SQLModel):
|
||||
task_id: int
|
||||
author_employee_id: int | None = None
|
||||
body: str
|
||||
@@ -1,26 +1,7 @@
|
||||
alembic==1.18.3
|
||||
annotated-doc==0.0.4
|
||||
annotated-types==0.7.0
|
||||
anyio==4.12.1
|
||||
click==8.3.1
|
||||
fastapi==0.128.0
|
||||
greenlet==3.3.1
|
||||
h11==0.16.0
|
||||
httptools==0.7.1
|
||||
idna==3.11
|
||||
Mako==1.3.10
|
||||
MarkupSafe==3.0.3
|
||||
psycopg2-binary==2.9.11
|
||||
pydantic==2.12.5
|
||||
pydantic-settings==2.12.0
|
||||
pydantic_core==2.41.5
|
||||
python-dotenv==1.2.1
|
||||
PyYAML==6.0.3
|
||||
SQLAlchemy==2.0.46
|
||||
starlette==0.50.0
|
||||
typing-inspection==0.4.2
|
||||
typing_extensions==4.15.0
|
||||
uvicorn==0.40.0
|
||||
uvloop==0.22.1
|
||||
watchfiles==1.1.1
|
||||
websockets==16.0
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
sqlmodel
|
||||
alembic
|
||||
psycopg2-binary
|
||||
python-dotenv
|
||||
pydantic-settings
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
.shell{min-height:100vh;display:grid;grid-template-columns:260px 1fr;background:var(--mc-bg)}
|
||||
.sidebar{border-right:1px solid var(--mc-border);padding:20px 16px;position:sticky;top:0;height:100vh;display:flex;flex-direction:column;gap:16px;background:linear-gradient(180deg,var(--mc-surface) 0%, color-mix(in oklab,var(--mc-surface), var(--mc-bg) 40%) 100%)}
|
||||
.brand{display:flex;flex-direction:column;gap:6px}
|
||||
.brandTitle{font-family:var(--mc-font-serif);font-size:18px;letter-spacing:-0.2px}
|
||||
.brandSub{font-size:12px;color:var(--mc-muted)}
|
||||
.nav{display:flex;flex-direction:column;gap:6px}
|
||||
.nav a{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:12px;color:var(--mc-text);text-decoration:none;border:1px solid transparent}
|
||||
.nav a:hover{background:color-mix(in oklab,var(--mc-accent), transparent 92%);border-color:color-mix(in oklab,var(--mc-accent), transparent 80%)}
|
||||
.active{background:color-mix(in oklab,var(--mc-accent), transparent 88%);border-color:color-mix(in oklab,var(--mc-accent), transparent 70%)}
|
||||
.main{padding:28px 28px 48px}
|
||||
.topbar{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:18px}
|
||||
.h1{font-family:var(--mc-font-serif);font-size:30px;line-height:1.1;letter-spacing:-0.6px;margin:0}
|
||||
.p{margin:8px 0 0;color:var(--mc-muted);max-width:72ch}
|
||||
.btn{border:1px solid var(--mc-border);background:var(--mc-surface);padding:10px 12px;border-radius:12px;cursor:pointer}
|
||||
.btnPrimary{border-color:color-mix(in oklab,var(--mc-accent), black 10%);background:var(--mc-accent);color:white}
|
||||
.grid2{display:grid;grid-template-columns:1.4fr 1fr;gap:16px}
|
||||
.card{background:var(--mc-surface);border:1px solid var(--mc-border);border-radius:16px;padding:14px}
|
||||
.cardTitle{margin:0 0 10px;font-size:13px;color:var(--mc-muted);letter-spacing:0.06em;text-transform:uppercase}
|
||||
.list{display:flex;flex-direction:column;gap:10px}
|
||||
.item{border:1px solid var(--mc-border);border-radius:14px;padding:12px;background:color-mix(in oklab,var(--mc-surface), white 20%)}
|
||||
.mono{font-family:var(--mc-font-mono);font-size:12px;color:var(--mc-muted)}
|
||||
.badge{display:inline-flex;align-items:center;padding:4px 8px;border-radius:999px;font-size:12px;border:1px solid var(--mc-border);background:color-mix(in oklab,var(--mc-bg), var(--mc-surface) 40%)}
|
||||
.kbd{font-family:var(--mc-font-mono);font-size:12px;background:color-mix(in oklab,var(--mc-bg), var(--mc-surface) 40%);border:1px solid var(--mc-border);border-bottom-width:2px;padding:2px 6px;border-radius:8px}
|
||||
@media (max-width: 980px){.shell{grid-template-columns:1fr}.sidebar{position:relative;height:auto}.grid2{grid-template-columns:1fr}.main{padding:18px}}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import styles from "./Shell.module.css";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/", label: "Mission Control" },
|
||||
{ href: "/projects", label: "Projects" },
|
||||
{ href: "/departments", label: "Departments" },
|
||||
{ href: "/people", label: "People" },
|
||||
{ href: "/hr", label: "HR" },
|
||||
];
|
||||
|
||||
export function Shell({ children }: { children: React.ReactNode }) {
|
||||
const path = usePathname();
|
||||
return (
|
||||
<div className={styles.shell}>
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.brand}>
|
||||
<div className={styles.brandTitle}>OpenClaw Agency</div>
|
||||
<div className={styles.brandSub}>Company Mission Control (no-auth v1)</div>
|
||||
</div>
|
||||
<nav className={styles.nav}>
|
||||
{NAV.map((n) => (
|
||||
<Link
|
||||
key={n.href}
|
||||
href={n.href}
|
||||
className={path === n.href ? styles.active : undefined}
|
||||
>
|
||||
{n.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className={styles.mono} style={{ marginTop: "auto" }}>
|
||||
Tip: use your machine IP + ports<br />
|
||||
<span className={styles.kbd}>:3000</span> UI <span className={styles.kbd}>:8000</span> API
|
||||
</div>
|
||||
</aside>
|
||||
<div className={styles.main}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,13 @@
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
:root{
|
||||
--mc-bg:#f6f4ef;
|
||||
--mc-surface:#ffffff;
|
||||
--mc-border:rgba(15,23,42,0.10);
|
||||
--mc-text:#0f172a;
|
||||
--mc-muted:rgba(15,23,42,0.62);
|
||||
--mc-accent:#2563eb;
|
||||
--mc-font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
--mc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
html,body{height:100%}
|
||||
body{margin:0;color:var(--mc-text);background:var(--mc-bg);font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";}
|
||||
*{box-sizing:border-box}
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import { Shell } from "./_components/Shell";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "OpenClaw Agency — Mission Control",
|
||||
description: "Company OS for projects, departments, people, and HR.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,8 +14,8 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
{children}
|
||||
<body>
|
||||
<Shell>{children}</Shell>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+114
-179
@@ -1,212 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import {useEffect, useMemo, useState} from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import styles from "./_components/Shell.module.css";
|
||||
import { apiGet } from "../lib/api";
|
||||
|
||||
type TaskStatus = "todo" | "doing" | "done";
|
||||
type Activity = {
|
||||
id: number;
|
||||
actor_employee_id: number | null;
|
||||
entity_type: string;
|
||||
entity_id: number | null;
|
||||
verb: string;
|
||||
payload: any;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
type Project = { id: number; name: string; status: string };
|
||||
|
||||
type Department = { id: number; name: string; head_employee_id: number | null };
|
||||
|
||||
type Employee = {
|
||||
id: number;
|
||||
name: string;
|
||||
employee_type: string;
|
||||
department_id: number | null;
|
||||
manager_id: number | null;
|
||||
title: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type Task = {
|
||||
id: number;
|
||||
project_id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: TaskStatus;
|
||||
assignee: string | null;
|
||||
status: string;
|
||||
assignee_employee_id: number | null;
|
||||
reviewer_employee_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
const STATUSES: Array<{key: TaskStatus; label: string}> = [
|
||||
{key: "todo", label: "To do"},
|
||||
{key: "doing", label: "Doing"},
|
||||
{key: "done", label: "Done"},
|
||||
];
|
||||
|
||||
function apiUrl(path: string) {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL;
|
||||
if (!base) throw new Error("NEXT_PUBLIC_API_URL is not set");
|
||||
return `${base}${path}`;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
export default function MissionControlHome() {
|
||||
const [activities, setActivities] = useState<Activity[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [departments, setDepartments] = useState<Department[]>([]);
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [assignee, setAssignee] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
const byStatus = useMemo(() => {
|
||||
const map: Record<TaskStatus, Task[]> = {todo: [], doing: [], done: []};
|
||||
for (const t of tasks) map[t.status].push(t);
|
||||
return map;
|
||||
}, [tasks]);
|
||||
|
||||
async function refresh() {
|
||||
setLoading(true);
|
||||
async function load() {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(apiUrl("/tasks"), {cache: "no-store"});
|
||||
if (!res.ok) throw new Error(`Failed to load tasks (${res.status})`);
|
||||
setTasks(await res.json());
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : "Unknown error";
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const [a, p, d, e, t] = await Promise.all([
|
||||
apiGet<Activity[]>("/activities?limit=20"),
|
||||
apiGet<Project[]>("/projects"),
|
||||
apiGet<Department[]>("/departments"),
|
||||
apiGet<Employee[]>("/employees"),
|
||||
apiGet<Task[]>("/tasks"),
|
||||
]);
|
||||
setActivities(a);
|
||||
setProjects(p);
|
||||
setDepartments(d);
|
||||
setEmployees(e);
|
||||
setTasks(t);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
load();
|
||||
}, []);
|
||||
|
||||
async function createTask() {
|
||||
if (!title.trim()) return;
|
||||
setError(null);
|
||||
const payload = {
|
||||
title,
|
||||
description: description.trim() ? description : null,
|
||||
assignee: assignee.trim() ? assignee : null,
|
||||
status: "todo" as const,
|
||||
};
|
||||
|
||||
const res = await fetch(apiUrl("/tasks"), {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
setError(`Failed to create task (${res.status})`);
|
||||
return;
|
||||
}
|
||||
|
||||
setTitle("");
|
||||
setAssignee("");
|
||||
setDescription("");
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function move(task: Task, status: TaskStatus) {
|
||||
const res = await fetch(apiUrl(`/tasks/${task.id}`), {
|
||||
method: "PATCH",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({status}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(`Failed to update task (${res.status})`);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function remove(task: Task) {
|
||||
const res = await fetch(apiUrl(`/tasks/${task.id}`), {method: "DELETE"});
|
||||
if (!res.ok) {
|
||||
setError(`Failed to delete task (${res.status})`);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
}
|
||||
const activeProjects = projects.filter((x) => x.status === "active").length;
|
||||
const activeEmployees = employees.filter((x) => x.status === "active").length;
|
||||
const blockedTasks = tasks.filter((t) => t.status === "blocked").length;
|
||||
const reviewQueue = tasks.filter((t) => t.status === "review").length;
|
||||
|
||||
return (
|
||||
<main style={{padding: 24, fontFamily: "ui-sans-serif, system-ui"}}>
|
||||
<header style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 16}}>
|
||||
<main>
|
||||
<div className={styles.topbar}>
|
||||
<div>
|
||||
<h1 style={{fontSize: 28, fontWeight: 700, margin: 0}}>OpenClaw Agency Board</h1>
|
||||
<p style={{marginTop: 8, color: "#555"}}>
|
||||
Simple Kanban (no auth). Everyone can see who owns what.
|
||||
<h1 className={styles.h1}>Mission Control</h1>
|
||||
<p className={styles.p}>
|
||||
Company dashboard: departments, employees/agents, projects, and work — designed to run like a real org.
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={refresh} disabled={loading} style={btn()}>Refresh</button>
|
||||
</header>
|
||||
<button className={styles.btn} onClick={load}>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section style={{marginTop: 18, padding: 16, border: "1px solid #eee", borderRadius: 12}}>
|
||||
<h2 style={{margin: 0, fontSize: 16}}>Create task</h2>
|
||||
<div style={{display: "grid", gridTemplateColumns: "2fr 1fr", gap: 12, marginTop: 12}}>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Task title"
|
||||
style={input()}
|
||||
/>
|
||||
<input
|
||||
value={assignee}
|
||||
onChange={(e) => setAssignee(e.target.value)}
|
||||
placeholder="Assignee (e.g. Head: Design)"
|
||||
style={input()}
|
||||
/>
|
||||
{error ? (
|
||||
<div className={styles.card} style={{ borderColor: "rgba(176,0,32,0.25)" }}>
|
||||
<div className={styles.cardTitle}>Error</div>
|
||||
<div style={{ color: "#b00020" }}>{error}</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Description (optional)"
|
||||
style={{...input(), marginTop: 12, minHeight: 80}}
|
||||
/>
|
||||
<div style={{display: "flex", gap: 12, marginTop: 12, alignItems: "center"}}>
|
||||
<button onClick={createTask} style={btn("primary")}>Add</button>
|
||||
{error ? <span style={{color: "#b00020"}}>{error}</span> : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section style={{marginTop: 18}}>
|
||||
<div style={{display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 14}}>
|
||||
{STATUSES.map((s) => (
|
||||
<div key={s.key} style={{border: "1px solid #eee", borderRadius: 12, padding: 12, background: "#fafafa"}}>
|
||||
<h3 style={{marginTop: 0}}>{s.label} ({byStatus[s.key].length})</h3>
|
||||
<div style={{display: "flex", flexDirection: "column", gap: 10}}>
|
||||
{byStatus[s.key].map((t) => (
|
||||
<div key={t.id} style={{border: "1px solid #e5e5e5", background: "white", borderRadius: 12, padding: 12}}>
|
||||
<div style={{display: "flex", justifyContent: "space-between", gap: 12}}>
|
||||
<div>
|
||||
<div style={{fontWeight: 650}}>{t.title}</div>
|
||||
<div style={{fontSize: 13, color: "#666", marginTop: 6}}>
|
||||
{t.assignee ? <>Owner: <strong>{t.assignee}</strong></> : "Unassigned"}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => remove(t)} style={btn("danger")}>Delete</button>
|
||||
</div>
|
||||
|
||||
{t.description ? <p style={{marginTop: 10, color: "#333"}}>{t.description}</p> : null}
|
||||
|
||||
<div style={{display: "flex", gap: 8, marginTop: 10, flexWrap: "wrap"}}>
|
||||
{STATUSES.filter((x) => x.key !== t.status).map((x) => (
|
||||
<button key={x.key} onClick={() => move(t, x.key)} style={btn()}>
|
||||
Move → {x.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{byStatus[s.key].length === 0 ? <div style={{color: "#777", fontSize: 13}}>No tasks</div> : null}
|
||||
<div className={styles.grid2} style={{ marginTop: 16 }}>
|
||||
<section className={styles.card}>
|
||||
<div className={styles.cardTitle}>Company Snapshot</div>
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
|
||||
<span className={styles.badge}>Projects: {activeProjects}</span>
|
||||
<span className={styles.badge}>Departments: {departments.length}</span>
|
||||
<span className={styles.badge}>Active people: {activeEmployees}</span>
|
||||
<span className={styles.badge}>In review: {reviewQueue}</span>
|
||||
<span className={styles.badge}>Blocked: {blockedTasks}</span>
|
||||
</div>
|
||||
<div className={styles.list} style={{ marginTop: 12 }}>
|
||||
{projects.slice(0, 6).map((p) => (
|
||||
<div key={p.id} className={styles.item}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
|
||||
<div style={{ fontWeight: 650 }}>{p.name}</div>
|
||||
<span className={styles.badge}>{p.status}</span>
|
||||
</div>
|
||||
<div className={styles.mono} style={{ marginTop: 6 }}>
|
||||
Project ID: {p.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{projects.length === 0 ? <div className={styles.mono}>No projects yet. Create one in Projects.</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.card}>
|
||||
<div className={styles.cardTitle}>Activity Feed</div>
|
||||
<div className={styles.list}>
|
||||
{activities.map((a) => (
|
||||
<div key={a.id} className={styles.item}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
|
||||
<div>
|
||||
<span style={{ fontWeight: 650 }}>{a.entity_type}</span> · {a.verb}
|
||||
{a.entity_id != null ? ` #${a.entity_id}` : ""}
|
||||
</div>
|
||||
<span className={styles.mono}>{new Date(a.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
{a.payload ? <div className={styles.mono} style={{ marginTop: 6 }}>{JSON.stringify(a.payload)}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
{activities.length === 0 ? <div className={styles.mono}>No activity yet.</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function input(): React.CSSProperties {
|
||||
return {
|
||||
width: "100%",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid #ddd",
|
||||
outline: "none",
|
||||
};
|
||||
}
|
||||
|
||||
function btn(kind: "primary" | "danger" | "default" = "default"): React.CSSProperties {
|
||||
const base: React.CSSProperties = {
|
||||
padding: "9px 12px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid #ddd",
|
||||
background: "white",
|
||||
cursor: "pointer",
|
||||
};
|
||||
if (kind === "primary") return {...base, background: "#111", color: "white", borderColor: "#111"};
|
||||
if (kind === "danger") return {...base, background: "#fff", borderColor: "#f2b8b5", color: "#b00020"};
|
||||
return base;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export function apiUrl(path: string) {
|
||||
const base = process.env.NEXT_PUBLIC_API_URL;
|
||||
if (!base) throw new Error("NEXT_PUBLIC_API_URL is not set");
|
||||
return `${base}${path}`;
|
||||
}
|
||||
|
||||
export async function apiGet<T>(path: string): Promise<T> {
|
||||
const res = await fetch(apiUrl(path), { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`GET ${path} failed (${res.status})`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export async function apiSend<T>(
|
||||
path: string,
|
||||
opts: { method: "POST" | "PATCH" | "DELETE"; body?: unknown }
|
||||
): Promise<T> {
|
||||
const res = await fetch(apiUrl(path), {
|
||||
method: opts.method,
|
||||
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
if (!res.ok) throw new Error(`${opts.method} ${path} failed (${res.status})`);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
Reference in New Issue
Block a user