Refactor backend to SQLModel; reset schema; add Company OS endpoints

This commit is contained in:
Abhimanyu Saharan
2026-02-01 23:16:56 +05:30
parent b37e7dd841
commit aa6b0c807b
56 changed files with 867 additions and 450 deletions

View 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}}

View 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 &nbsp; <span className={styles.kbd}>:8000</span> API
</div>
</aside>
<div className={styles.main}>{children}</div>
</div>
);
}

View File

@@ -1,42 +1,13 @@
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
a {
color: inherit;
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
}
:root{
--mc-bg:#f6f4ef;
--mc-surface:#ffffff;
--mc-border:rgba(15,23,42,0.10);
--mc-text:#0f172a;
--mc-muted:rgba(15,23,42,0.62);
--mc-accent:#2563eb;
--mc-font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--mc-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
html,body{height:100%}
body{margin:0;color:var(--mc-text);background:var(--mc-bg);font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";}
*{box-sizing:border-box}

View File

@@ -1,20 +1,10 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
import { Shell } from "./_components/Shell";
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "OpenClaw Agency — Mission Control",
description: "Company OS for projects, departments, people, and HR.",
};
export default function RootLayout({
@@ -24,8 +14,8 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
<body>
<Shell>{children}</Shell>
</body>
</html>
);

View File

@@ -1,212 +1,147 @@
"use client";
import {useEffect, useMemo, useState} from "react";
import { useEffect, useState } from "react";
import styles from "./_components/Shell.module.css";
import { apiGet } from "../lib/api";
type TaskStatus = "todo" | "doing" | "done";
type Activity = {
id: number;
actor_employee_id: number | null;
entity_type: string;
entity_id: number | null;
verb: string;
payload: any;
created_at: string;
};
type Project = { id: number; name: string; status: string };
type Department = { id: number; name: string; head_employee_id: number | null };
type Employee = {
id: number;
name: string;
employee_type: string;
department_id: number | null;
manager_id: number | null;
title: string | null;
status: string;
};
type Task = {
id: number;
project_id: number;
title: string;
description: string | null;
status: TaskStatus;
assignee: string | null;
status: string;
assignee_employee_id: number | null;
reviewer_employee_id: number | null;
created_at: string;
updated_at: string | null;
updated_at: string;
};
const STATUSES: Array<{key: TaskStatus; label: string}> = [
{key: "todo", label: "To do"},
{key: "doing", label: "Doing"},
{key: "done", label: "Done"},
];
function apiUrl(path: string) {
const base = process.env.NEXT_PUBLIC_API_URL;
if (!base) throw new Error("NEXT_PUBLIC_API_URL is not set");
return `${base}${path}`;
}
export default function Home() {
export default function MissionControlHome() {
const [activities, setActivities] = useState<Activity[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [employees, setEmployees] = useState<Employee[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [assignee, setAssignee] = useState("");
const [description, setDescription] = useState("");
const byStatus = useMemo(() => {
const map: Record<TaskStatus, Task[]> = {todo: [], doing: [], done: []};
for (const t of tasks) map[t.status].push(t);
return map;
}, [tasks]);
async function refresh() {
setLoading(true);
async function load() {
setError(null);
try {
const res = await fetch(apiUrl("/tasks"), {cache: "no-store"});
if (!res.ok) throw new Error(`Failed to load tasks (${res.status})`);
setTasks(await res.json());
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : "Unknown error";
setError(msg);
} finally {
setLoading(false);
const [a, p, d, e, t] = await Promise.all([
apiGet<Activity[]>("/activities?limit=20"),
apiGet<Project[]>("/projects"),
apiGet<Department[]>("/departments"),
apiGet<Employee[]>("/employees"),
apiGet<Task[]>("/tasks"),
]);
setActivities(a);
setProjects(p);
setDepartments(d);
setEmployees(e);
setTasks(t);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Unknown error");
}
}
useEffect(() => {
refresh();
load();
}, []);
async function createTask() {
if (!title.trim()) return;
setError(null);
const payload = {
title,
description: description.trim() ? description : null,
assignee: assignee.trim() ? assignee : null,
status: "todo" as const,
};
const res = await fetch(apiUrl("/tasks"), {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(payload),
});
if (!res.ok) {
setError(`Failed to create task (${res.status})`);
return;
}
setTitle("");
setAssignee("");
setDescription("");
await refresh();
}
async function move(task: Task, status: TaskStatus) {
const res = await fetch(apiUrl(`/tasks/${task.id}`), {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({status}),
});
if (!res.ok) {
setError(`Failed to update task (${res.status})`);
return;
}
await refresh();
}
async function remove(task: Task) {
const res = await fetch(apiUrl(`/tasks/${task.id}`), {method: "DELETE"});
if (!res.ok) {
setError(`Failed to delete task (${res.status})`);
return;
}
await refresh();
}
const activeProjects = projects.filter((x) => x.status === "active").length;
const activeEmployees = employees.filter((x) => x.status === "active").length;
const blockedTasks = tasks.filter((t) => t.status === "blocked").length;
const reviewQueue = tasks.filter((t) => t.status === "review").length;
return (
<main style={{padding: 24, fontFamily: "ui-sans-serif, system-ui"}}>
<header style={{display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 16}}>
<main>
<div className={styles.topbar}>
<div>
<h1 style={{fontSize: 28, fontWeight: 700, margin: 0}}>OpenClaw Agency Board</h1>
<p style={{marginTop: 8, color: "#555"}}>
Simple Kanban (no auth). Everyone can see who owns what.
<h1 className={styles.h1}>Mission Control</h1>
<p className={styles.p}>
Company dashboard: departments, employees/agents, projects, and work designed to run like a real org.
</p>
</div>
<button onClick={refresh} disabled={loading} style={btn()}>Refresh</button>
</header>
<button className={styles.btn} onClick={load}>
Refresh
</button>
</div>
<section style={{marginTop: 18, padding: 16, border: "1px solid #eee", borderRadius: 12}}>
<h2 style={{margin: 0, fontSize: 16}}>Create task</h2>
<div style={{display: "grid", gridTemplateColumns: "2fr 1fr", gap: 12, marginTop: 12}}>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Task title"
style={input()}
/>
<input
value={assignee}
onChange={(e) => setAssignee(e.target.value)}
placeholder="Assignee (e.g. Head: Design)"
style={input()}
/>
{error ? (
<div className={styles.card} style={{ borderColor: "rgba(176,0,32,0.25)" }}>
<div className={styles.cardTitle}>Error</div>
<div style={{ color: "#b00020" }}>{error}</div>
</div>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Description (optional)"
style={{...input(), marginTop: 12, minHeight: 80}}
/>
<div style={{display: "flex", gap: 12, marginTop: 12, alignItems: "center"}}>
<button onClick={createTask} style={btn("primary")}>Add</button>
{error ? <span style={{color: "#b00020"}}>{error}</span> : null}
</div>
</section>
) : null}
<section style={{marginTop: 18}}>
<div style={{display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 14}}>
{STATUSES.map((s) => (
<div key={s.key} style={{border: "1px solid #eee", borderRadius: 12, padding: 12, background: "#fafafa"}}>
<h3 style={{marginTop: 0}}>{s.label} ({byStatus[s.key].length})</h3>
<div style={{display: "flex", flexDirection: "column", gap: 10}}>
{byStatus[s.key].map((t) => (
<div key={t.id} style={{border: "1px solid #e5e5e5", background: "white", borderRadius: 12, padding: 12}}>
<div style={{display: "flex", justifyContent: "space-between", gap: 12}}>
<div>
<div style={{fontWeight: 650}}>{t.title}</div>
<div style={{fontSize: 13, color: "#666", marginTop: 6}}>
{t.assignee ? <>Owner: <strong>{t.assignee}</strong></> : "Unassigned"}
</div>
</div>
<button onClick={() => remove(t)} style={btn("danger")}>Delete</button>
</div>
{t.description ? <p style={{marginTop: 10, color: "#333"}}>{t.description}</p> : null}
<div style={{display: "flex", gap: 8, marginTop: 10, flexWrap: "wrap"}}>
{STATUSES.filter((x) => x.key !== t.status).map((x) => (
<button key={x.key} onClick={() => move(t, x.key)} style={btn()}>
Move {x.label}
</button>
))}
</div>
</div>
))}
{byStatus[s.key].length === 0 ? <div style={{color: "#777", fontSize: 13}}>No tasks</div> : null}
<div className={styles.grid2} style={{ marginTop: 16 }}>
<section className={styles.card}>
<div className={styles.cardTitle}>Company Snapshot</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
<span className={styles.badge}>Projects: {activeProjects}</span>
<span className={styles.badge}>Departments: {departments.length}</span>
<span className={styles.badge}>Active people: {activeEmployees}</span>
<span className={styles.badge}>In review: {reviewQueue}</span>
<span className={styles.badge}>Blocked: {blockedTasks}</span>
</div>
<div className={styles.list} style={{ marginTop: 12 }}>
{projects.slice(0, 6).map((p) => (
<div key={p.id} className={styles.item}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
<div style={{ fontWeight: 650 }}>{p.name}</div>
<span className={styles.badge}>{p.status}</span>
</div>
<div className={styles.mono} style={{ marginTop: 6 }}>
Project ID: {p.id}
</div>
</div>
</div>
))}
</div>
</section>
))}
{projects.length === 0 ? <div className={styles.mono}>No projects yet. Create one in Projects.</div> : null}
</div>
</section>
<section className={styles.card}>
<div className={styles.cardTitle}>Activity Feed</div>
<div className={styles.list}>
{activities.map((a) => (
<div key={a.id} className={styles.item}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
<div>
<span style={{ fontWeight: 650 }}>{a.entity_type}</span> · {a.verb}
{a.entity_id != null ? ` #${a.entity_id}` : ""}
</div>
<span className={styles.mono}>{new Date(a.created_at).toLocaleString()}</span>
</div>
{a.payload ? <div className={styles.mono} style={{ marginTop: 6 }}>{JSON.stringify(a.payload)}</div> : null}
</div>
))}
{activities.length === 0 ? <div className={styles.mono}>No activity yet.</div> : null}
</div>
</section>
</div>
</main>
);
}
function input(): React.CSSProperties {
return {
width: "100%",
padding: "10px 12px",
borderRadius: 10,
border: "1px solid #ddd",
outline: "none",
};
}
function btn(kind: "primary" | "danger" | "default" = "default"): React.CSSProperties {
const base: React.CSSProperties = {
padding: "9px 12px",
borderRadius: 10,
border: "1px solid #ddd",
background: "white",
cursor: "pointer",
};
if (kind === "primary") return {...base, background: "#111", color: "white", borderColor: "#111"};
if (kind === "danger") return {...base, background: "#fff", borderColor: "#f2b8b5", color: "#b00020"};
return base;
}