OpenClaw Agency Board
-- Simple Kanban (no auth). Everyone can see who owns what. +
Mission Control
++ Company dashboard: departments, employees/agents, projects, and work โ designed to run like a real org.
diff --git a/backend/alembic/__pycache__/env.cpython-312.pyc b/backend/alembic/__pycache__/env.cpython-312.pyc index 5563bc5..ddf714a 100644 Binary files a/backend/alembic/__pycache__/env.cpython-312.pyc and b/backend/alembic/__pycache__/env.cpython-312.pyc differ diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 503b27f..3a5cd09 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -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: diff --git a/backend/alembic/versions/157587037601_initial_company_os_sqlmodel.py b/backend/alembic/versions/157587037601_initial_company_os_sqlmodel.py new file mode 100644 index 0000000..bdf86a7 --- /dev/null +++ b/backend/alembic/versions/157587037601_initial_company_os_sqlmodel.py @@ -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 ### diff --git a/backend/alembic/versions/__pycache__/157587037601_initial_company_os_sqlmodel.cpython-312.pyc b/backend/alembic/versions/__pycache__/157587037601_initial_company_os_sqlmodel.cpython-312.pyc new file mode 100644 index 0000000..2f3445e Binary files /dev/null and b/backend/alembic/versions/__pycache__/157587037601_initial_company_os_sqlmodel.cpython-312.pyc differ diff --git a/backend/alembic/versions/__pycache__/5c0c9c1e3c2a_company_os_schema.cpython-312.pyc b/backend/alembic/versions/__pycache__/5c0c9c1e3c2a_company_os_schema.cpython-312.pyc new file mode 100644 index 0000000..5dd817a Binary files /dev/null and b/backend/alembic/versions/__pycache__/5c0c9c1e3c2a_company_os_schema.cpython-312.pyc differ diff --git a/backend/alembic/versions/ce4f1502f674_create_tasks.py b/backend/alembic/versions/ce4f1502f674_create_tasks.py deleted file mode 100644 index e505b1c..0000000 --- a/backend/alembic/versions/ce4f1502f674_create_tasks.py +++ /dev/null @@ -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 ### diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index acfc3e2..49a249c 100644 Binary files a/backend/app/__pycache__/main.cpython-312.pyc and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/activities.cpython-312.pyc b/backend/app/api/__pycache__/activities.cpython-312.pyc new file mode 100644 index 0000000..5bdda4e Binary files /dev/null and b/backend/app/api/__pycache__/activities.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/hr.cpython-312.pyc b/backend/app/api/__pycache__/hr.cpython-312.pyc new file mode 100644 index 0000000..7d8b6a8 Binary files /dev/null and b/backend/app/api/__pycache__/hr.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/org.cpython-312.pyc b/backend/app/api/__pycache__/org.cpython-312.pyc new file mode 100644 index 0000000..f6ebdac Binary files /dev/null and b/backend/app/api/__pycache__/org.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/projects.cpython-312.pyc b/backend/app/api/__pycache__/projects.cpython-312.pyc new file mode 100644 index 0000000..6b231b8 Binary files /dev/null and b/backend/app/api/__pycache__/projects.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/tasks.cpython-312.pyc b/backend/app/api/__pycache__/tasks.cpython-312.pyc deleted file mode 100644 index d868344..0000000 Binary files a/backend/app/api/__pycache__/tasks.cpython-312.pyc and /dev/null differ diff --git a/backend/app/api/__pycache__/utils.cpython-312.pyc b/backend/app/api/__pycache__/utils.cpython-312.pyc new file mode 100644 index 0000000..8880b64 Binary files /dev/null and b/backend/app/api/__pycache__/utils.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/work.cpython-312.pyc b/backend/app/api/__pycache__/work.cpython-312.pyc new file mode 100644 index 0000000..2eba431 Binary files /dev/null and b/backend/app/api/__pycache__/work.cpython-312.pyc differ diff --git a/backend/app/api/activities.py b/backend/app/api/activities.py new file mode 100644 index 0000000..d5daadf --- /dev/null +++ b/backend/app/api/activities.py @@ -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 diff --git a/backend/app/api/hr.py b/backend/app/api/hr.py new file mode 100644 index 0000000..ca03c6b --- /dev/null +++ b/backend/app/api/hr.py @@ -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 diff --git a/backend/app/api/org.py b/backend/app/api/org.py new file mode 100644 index 0000000..e1060b6 --- /dev/null +++ b/backend/app/api/org.py @@ -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 diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py new file mode 100644 index 0000000..16ad2c8 --- /dev/null +++ b/backend/app/api/projects.py @@ -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 diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py deleted file mode 100644 index afe8150..0000000 --- a/backend/app/api/tasks.py +++ /dev/null @@ -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} diff --git a/backend/app/api/utils.py b/backend/app/api/utils.py new file mode 100644 index 0000000..4ce1e6c --- /dev/null +++ b/backend/app/api/utils.py @@ -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, + ) + ) diff --git a/backend/app/api/work.py b/backend/app/api/work.py new file mode 100644 index 0000000..ae17b2d --- /dev/null +++ b/backend/app/api/work.py @@ -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 diff --git a/backend/app/db/__pycache__/session.cpython-312.pyc b/backend/app/db/__pycache__/session.cpython-312.pyc index 450a171..7d7d283 100644 Binary files a/backend/app/db/__pycache__/session.cpython-312.pyc and b/backend/app/db/__pycache__/session.cpython-312.pyc differ diff --git a/backend/app/db/base.py b/backend/app/db/base.py deleted file mode 100644 index fa2b68a..0000000 --- a/backend/app/db/base.py +++ /dev/null @@ -1,5 +0,0 @@ -from sqlalchemy.orm import DeclarativeBase - - -class Base(DeclarativeBase): - pass diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 27cdbb5..ca3dc24 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index a69bee4..abae236 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index ff3fc7f..0266e58 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/__pycache__/__init__.cpython-312.pyc b/backend/app/models/__pycache__/__init__.cpython-312.pyc index f78d778..50b4622 100644 Binary files a/backend/app/models/__pycache__/__init__.cpython-312.pyc and b/backend/app/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/activity.cpython-312.pyc b/backend/app/models/__pycache__/activity.cpython-312.pyc new file mode 100644 index 0000000..d412768 Binary files /dev/null and b/backend/app/models/__pycache__/activity.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/hr.cpython-312.pyc b/backend/app/models/__pycache__/hr.cpython-312.pyc new file mode 100644 index 0000000..bfa75cb Binary files /dev/null and b/backend/app/models/__pycache__/hr.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/org.cpython-312.pyc b/backend/app/models/__pycache__/org.cpython-312.pyc new file mode 100644 index 0000000..fd18fac Binary files /dev/null and b/backend/app/models/__pycache__/org.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/projects.cpython-312.pyc b/backend/app/models/__pycache__/projects.cpython-312.pyc new file mode 100644 index 0000000..ab7a339 Binary files /dev/null and b/backend/app/models/__pycache__/projects.cpython-312.pyc differ diff --git a/backend/app/models/__pycache__/task.cpython-312.pyc b/backend/app/models/__pycache__/task.cpython-312.pyc deleted file mode 100644 index be95870..0000000 Binary files a/backend/app/models/__pycache__/task.cpython-312.pyc and /dev/null differ diff --git a/backend/app/models/__pycache__/work.cpython-312.pyc b/backend/app/models/__pycache__/work.cpython-312.pyc new file mode 100644 index 0000000..532721b Binary files /dev/null and b/backend/app/models/__pycache__/work.cpython-312.pyc differ diff --git a/backend/app/models/activity.py b/backend/app/models/activity.py new file mode 100644 index 0000000..51c3a8f --- /dev/null +++ b/backend/app/models/activity.py @@ -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) diff --git a/backend/app/models/hr.py b/backend/app/models/hr.py new file mode 100644 index 0000000..2b3f59a --- /dev/null +++ b/backend/app/models/hr.py @@ -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) diff --git a/backend/app/models/org.py b/backend/app/models/org.py new file mode 100644 index 0000000..9beb459 --- /dev/null +++ b/backend/app/models/org.py @@ -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") diff --git a/backend/app/models/projects.py b/backend/app/models/projects.py new file mode 100644 index 0000000..acfeb15 --- /dev/null +++ b/backend/app/models/projects.py @@ -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 diff --git a/backend/app/models/task.py b/backend/app/models/task.py deleted file mode 100644 index 2f298f5..0000000 --- a/backend/app/models/task.py +++ /dev/null @@ -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() - ) diff --git a/backend/app/models/work.py b/backend/app/models/work.py new file mode 100644 index 0000000..545d565 --- /dev/null +++ b/backend/app/models/work.py @@ -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) diff --git a/backend/app/schemas/__pycache__/hr.cpython-312.pyc b/backend/app/schemas/__pycache__/hr.cpython-312.pyc new file mode 100644 index 0000000..c8957d2 Binary files /dev/null and b/backend/app/schemas/__pycache__/hr.cpython-312.pyc differ diff --git a/backend/app/schemas/__pycache__/org.cpython-312.pyc b/backend/app/schemas/__pycache__/org.cpython-312.pyc new file mode 100644 index 0000000..40fca14 Binary files /dev/null and b/backend/app/schemas/__pycache__/org.cpython-312.pyc differ diff --git a/backend/app/schemas/__pycache__/projects.cpython-312.pyc b/backend/app/schemas/__pycache__/projects.cpython-312.pyc new file mode 100644 index 0000000..d9fc278 Binary files /dev/null and b/backend/app/schemas/__pycache__/projects.cpython-312.pyc differ diff --git a/backend/app/schemas/__pycache__/task.cpython-312.pyc b/backend/app/schemas/__pycache__/task.cpython-312.pyc deleted file mode 100644 index ba37677..0000000 Binary files a/backend/app/schemas/__pycache__/task.cpython-312.pyc and /dev/null differ diff --git a/backend/app/schemas/__pycache__/work.cpython-312.pyc b/backend/app/schemas/__pycache__/work.cpython-312.pyc new file mode 100644 index 0000000..ddbdc63 Binary files /dev/null and b/backend/app/schemas/__pycache__/work.cpython-312.pyc differ diff --git a/backend/app/schemas/hr.py b/backend/app/schemas/hr.py new file mode 100644 index 0000000..78dceb4 --- /dev/null +++ b/backend/app/schemas/hr.py @@ -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 diff --git a/backend/app/schemas/org.py b/backend/app/schemas/org.py new file mode 100644 index 0000000..0079b28 --- /dev/null +++ b/backend/app/schemas/org.py @@ -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 diff --git a/backend/app/schemas/projects.py b/backend/app/schemas/projects.py new file mode 100644 index 0000000..98c3397 --- /dev/null +++ b/backend/app/schemas/projects.py @@ -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 diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py deleted file mode 100644 index 4b43d01..0000000 --- a/backend/app/schemas/task.py +++ /dev/null @@ -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} diff --git a/backend/app/schemas/work.py b/backend/app/schemas/work.py new file mode 100644 index 0000000..b483e5b --- /dev/null +++ b/backend/app/schemas/work.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index 297a406..86c261c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/src/app/_components/Shell.module.css b/frontend/src/app/_components/Shell.module.css new file mode 100644 index 0000000..4192c02 --- /dev/null +++ b/frontend/src/app/_components/Shell.module.css @@ -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}} diff --git a/frontend/src/app/_components/Shell.tsx b/frontend/src/app/_components/Shell.tsx new file mode 100644 index 0000000..48503dc --- /dev/null +++ b/frontend/src/app/_components/Shell.tsx @@ -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 ( +
- Simple Kanban (no auth). Everyone can see who owns what. +
+ Company dashboard: departments, employees/agents, projects, and work โ designed to run like a real org.