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 sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.base import Base
|
from sqlmodel import SQLModel
|
||||||
from app.models import task # noqa: F401
|
|
||||||
|
# Import models to register tables in metadata
|
||||||
|
from app import models # noqa: F401
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
# Use SQLAlchemy models metadata
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = SQLModel.metadata
|
||||||
|
|
||||||
|
|
||||||
def get_url() -> str:
|
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 ###
|
||||||
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.
BIN
backend/app/api/__pycache__/activities.cpython-312.pyc
Normal file
BIN
backend/app/api/__pycache__/activities.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/hr.cpython-312.pyc
Normal file
BIN
backend/app/api/__pycache__/hr.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/org.cpython-312.pyc
Normal file
BIN
backend/app/api/__pycache__/org.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/projects.cpython-312.pyc
Normal file
BIN
backend/app/api/__pycache__/projects.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/app/api/__pycache__/utils.cpython-312.pyc
Normal file
BIN
backend/app/api/__pycache__/utils.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/api/__pycache__/work.cpython-312.pyc
Normal file
BIN
backend/app/api/__pycache__/work.cpython-312.pyc
Normal file
Binary file not shown.
30
backend/app/api/activities.py
Normal file
30
backend/app/api/activities.py
Normal file
@@ -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
|
||||||
61
backend/app/api/hr.py
Normal file
61
backend/app/api/hr.py
Normal file
@@ -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
|
||||||
79
backend/app/api/org.py
Normal file
79
backend/app/api/org.py
Normal file
@@ -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
|
||||||
45
backend/app/api/projects.py
Normal file
45
backend/app/api/projects.py
Normal file
@@ -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}
|
|
||||||
28
backend/app/api/utils.py
Normal file
28
backend/app/api/utils.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
80
backend/app/api/work.py
Normal file
80
backend/app/api/work.py
Normal file
@@ -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 __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlmodel import Session, SQLModel, create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
engine = create_engine(settings.database_url, pool_pre_ping=True)
|
engine = create_engine(settings.database_url, echo=False)
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
||||||
|
|
||||||
|
def init_db() -> None:
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
|
||||||
|
def get_session():
|
||||||
|
with Session(engine) as session:
|
||||||
|
yield session
|
||||||
|
|||||||
@@ -3,10 +3,15 @@ from __future__ import annotations
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.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()]
|
origins = [o.strip() for o in settings.cors_origins.split(",") if o.strip()]
|
||||||
if origins:
|
if origins:
|
||||||
@@ -14,11 +19,21 @@ if origins:
|
|||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=origins,
|
allow_origins=origins,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"] ,
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
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")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -1,2 +1,17 @@
|
|||||||
# Import models here so Alembic can discover them
|
from app.models.org import Department, Employee
|
||||||
from .task import Task # noqa: F401
|
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.
BIN
backend/app/models/__pycache__/activity.cpython-312.pyc
Normal file
BIN
backend/app/models/__pycache__/activity.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/hr.cpython-312.pyc
Normal file
BIN
backend/app/models/__pycache__/hr.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/org.cpython-312.pyc
Normal file
BIN
backend/app/models/__pycache__/org.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/models/__pycache__/projects.cpython-312.pyc
Normal file
BIN
backend/app/models/__pycache__/projects.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/app/models/__pycache__/work.cpython-312.pyc
Normal file
BIN
backend/app/models/__pycache__/work.cpython-312.pyc
Normal file
Binary file not shown.
19
backend/app/models/activity.py
Normal file
19
backend/app/models/activity.py
Normal file
@@ -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)
|
||||||
35
backend/app/models/hr.py
Normal file
35
backend/app/models/hr.py
Normal file
@@ -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)
|
||||||
27
backend/app/models/org.py
Normal file
27
backend/app/models/org.py
Normal file
@@ -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")
|
||||||
20
backend/app/models/projects.py
Normal file
20
backend/app/models/projects.py
Normal file
@@ -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()
|
|
||||||
)
|
|
||||||
34
backend/app/models/work.py
Normal file
34
backend/app/models/work.py
Normal file
@@ -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)
|
||||||
BIN
backend/app/schemas/__pycache__/hr.cpython-312.pyc
Normal file
BIN
backend/app/schemas/__pycache__/hr.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/schemas/__pycache__/org.cpython-312.pyc
Normal file
BIN
backend/app/schemas/__pycache__/org.cpython-312.pyc
Normal file
Binary file not shown.
BIN
backend/app/schemas/__pycache__/projects.cpython-312.pyc
Normal file
BIN
backend/app/schemas/__pycache__/projects.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/app/schemas/__pycache__/work.cpython-312.pyc
Normal file
BIN
backend/app/schemas/__pycache__/work.cpython-312.pyc
Normal file
Binary file not shown.
24
backend/app/schemas/hr.py
Normal file
24
backend/app/schemas/hr.py
Normal file
@@ -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
|
||||||
31
backend/app/schemas/org.py
Normal file
31
backend/app/schemas/org.py
Normal file
@@ -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
|
||||||
13
backend/app/schemas/projects.py
Normal file
13
backend/app/schemas/projects.py
Normal file
@@ -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}
|
|
||||||
27
backend/app/schemas/work.py
Normal file
27
backend/app/schemas/work.py
Normal file
@@ -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
|
fastapi
|
||||||
annotated-doc==0.0.4
|
uvicorn[standard]
|
||||||
annotated-types==0.7.0
|
sqlmodel
|
||||||
anyio==4.12.1
|
alembic
|
||||||
click==8.3.1
|
psycopg2-binary
|
||||||
fastapi==0.128.0
|
python-dotenv
|
||||||
greenlet==3.3.1
|
pydantic-settings
|
||||||
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
|
|
||||||
|
|||||||
24
frontend/src/app/_components/Shell.module.css
Normal file
24
frontend/src/app/_components/Shell.module.css
Normal file
@@ -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}}
|
||||||
43
frontend/src/app/_components/Shell.tsx
Normal file
43
frontend/src/app/_components/Shell.tsx
Normal file
@@ -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 {
|
:root{
|
||||||
--background: #ffffff;
|
--mc-bg:#f6f4ef;
|
||||||
--foreground: #171717;
|
--mc-surface:#ffffff;
|
||||||
}
|
--mc-border:rgba(15,23,42,0.10);
|
||||||
|
--mc-text:#0f172a;
|
||||||
@media (prefers-color-scheme: dark) {
|
--mc-muted:rgba(15,23,42,0.62);
|
||||||
:root {
|
--mc-accent:#2563eb;
|
||||||
--background: #0a0a0a;
|
--mc-font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
||||||
--foreground: #ededed;
|
--mc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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 type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { Shell } from "./_components/Shell";
|
||||||
const geistSans = Geist({
|
|
||||||
variable: "--font-geist-sans",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "OpenClaw Agency — Mission Control",
|
||||||
description: "Generated by create next app",
|
description: "Company OS for projects, departments, people, and HR.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -24,8 +14,8 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body>
|
||||||
{children}
|
<Shell>{children}</Shell>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,212 +1,147 @@
|
|||||||
"use client";
|
"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 = {
|
type Task = {
|
||||||
id: number;
|
id: number;
|
||||||
|
project_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
status: string;
|
||||||
status: TaskStatus;
|
assignee_employee_id: number | null;
|
||||||
assignee: string | null;
|
reviewer_employee_id: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string | null;
|
updated_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUSES: Array<{key: TaskStatus; label: string}> = [
|
export default function MissionControlHome() {
|
||||||
{key: "todo", label: "To do"},
|
const [activities, setActivities] = useState<Activity[]>([]);
|
||||||
{key: "doing", label: "Doing"},
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
{key: "done", label: "Done"},
|
const [departments, setDepartments] = useState<Department[]>([]);
|
||||||
];
|
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||||
|
|
||||||
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() {
|
|
||||||
const [tasks, setTasks] = useState<Task[]>([]);
|
const [tasks, setTasks] = useState<Task[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [title, setTitle] = useState("");
|
async function load() {
|
||||||
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);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(apiUrl("/tasks"), {cache: "no-store"});
|
const [a, p, d, e, t] = await Promise.all([
|
||||||
if (!res.ok) throw new Error(`Failed to load tasks (${res.status})`);
|
apiGet<Activity[]>("/activities?limit=20"),
|
||||||
setTasks(await res.json());
|
apiGet<Project[]>("/projects"),
|
||||||
} catch (e: unknown) {
|
apiGet<Department[]>("/departments"),
|
||||||
const msg = e instanceof Error ? e.message : "Unknown error";
|
apiGet<Employee[]>("/employees"),
|
||||||
setError(msg);
|
apiGet<Task[]>("/tasks"),
|
||||||
} finally {
|
]);
|
||||||
setLoading(false);
|
setActivities(a);
|
||||||
|
setProjects(p);
|
||||||
|
setDepartments(d);
|
||||||
|
setEmployees(e);
|
||||||
|
setTasks(t);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : "Unknown error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
load();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function createTask() {
|
const activeProjects = projects.filter((x) => x.status === "active").length;
|
||||||
if (!title.trim()) return;
|
const activeEmployees = employees.filter((x) => x.status === "active").length;
|
||||||
setError(null);
|
const blockedTasks = tasks.filter((t) => t.status === "blocked").length;
|
||||||
const payload = {
|
const reviewQueue = tasks.filter((t) => t.status === "review").length;
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main style={{padding: 24, fontFamily: "ui-sans-serif, system-ui"}}>
|
<main>
|
||||||
<header style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 16}}>
|
<div className={styles.topbar}>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{fontSize: 28, fontWeight: 700, margin: 0}}>OpenClaw Agency Board</h1>
|
<h1 className={styles.h1}>Mission Control</h1>
|
||||||
<p style={{marginTop: 8, color: "#555"}}>
|
<p className={styles.p}>
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={refresh} disabled={loading} style={btn()}>Refresh</button>
|
<button className={styles.btn} onClick={load}>
|
||||||
</header>
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section style={{marginTop: 18, padding: 16, border: "1px solid #eee", borderRadius: 12}}>
|
{error ? (
|
||||||
<h2 style={{margin: 0, fontSize: 16}}>Create task</h2>
|
<div className={styles.card} style={{ borderColor: "rgba(176,0,32,0.25)" }}>
|
||||||
<div style={{display: "grid", gridTemplateColumns: "2fr 1fr", gap: 12, marginTop: 12}}>
|
<div className={styles.cardTitle}>Error</div>
|
||||||
<input
|
<div style={{ color: "#b00020" }}>{error}</div>
|
||||||
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()}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
) : null}
|
||||||
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>
|
|
||||||
|
|
||||||
<section style={{marginTop: 18}}>
|
<div className={styles.grid2} style={{ marginTop: 16 }}>
|
||||||
<div style={{display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 14}}>
|
<section className={styles.card}>
|
||||||
{STATUSES.map((s) => (
|
<div className={styles.cardTitle}>Company Snapshot</div>
|
||||||
<div key={s.key} style={{border: "1px solid #eee", borderRadius: 12, padding: 12, background: "#fafafa"}}>
|
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
|
||||||
<h3 style={{marginTop: 0}}>{s.label} ({byStatus[s.key].length})</h3>
|
<span className={styles.badge}>Projects: {activeProjects}</span>
|
||||||
<div style={{display: "flex", flexDirection: "column", gap: 10}}>
|
<span className={styles.badge}>Departments: {departments.length}</span>
|
||||||
{byStatus[s.key].map((t) => (
|
<span className={styles.badge}>Active people: {activeEmployees}</span>
|
||||||
<div key={t.id} style={{border: "1px solid #e5e5e5", background: "white", borderRadius: 12, padding: 12}}>
|
<span className={styles.badge}>In review: {reviewQueue}</span>
|
||||||
<div style={{display: "flex", justifyContent: "space-between", gap: 12}}>
|
<span className={styles.badge}>Blocked: {blockedTasks}</span>
|
||||||
<div>
|
</div>
|
||||||
<div style={{fontWeight: 650}}>{t.title}</div>
|
<div className={styles.list} style={{ marginTop: 12 }}>
|
||||||
<div style={{fontSize: 13, color: "#666", marginTop: 6}}>
|
{projects.slice(0, 6).map((p) => (
|
||||||
{t.assignee ? <>Owner: <strong>{t.assignee}</strong></> : "Unassigned"}
|
<div key={p.id} className={styles.item}>
|
||||||
</div>
|
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
|
||||||
</div>
|
<div style={{ fontWeight: 650 }}>{p.name}</div>
|
||||||
<button onClick={() => remove(t)} style={btn("danger")}>Delete</button>
|
<span className={styles.badge}>{p.status}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.mono} style={{ marginTop: 6 }}>
|
||||||
{t.description ? <p style={{marginTop: 10, color: "#333"}}>{t.description}</p> : null}
|
Project ID: {p.id}
|
||||||
|
</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
{projects.length === 0 ? <div className={styles.mono}>No projects yet. Create one in Projects.</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</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;
|
|
||||||
}
|
|
||||||
|
|||||||
24
frontend/src/lib/api.ts
Normal file
24
frontend/src/lib/api.ts
Normal file
@@ -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