Add meetings, sprints, and approval decision notes

Migration 042 creates meetings and sprints tables, adds decision_notes
to approvals. Full-stack: MeetingService, SprintService, IPC channels,
preload bridge, domain types. UI: MeetingsPage with scheduling and
type filtering, SprintsPage with progress tracking, ApprovalDetailDialog
with decision notes textarea.
This commit is contained in:
ZacharyZhang-NY committed 2026-06-07 18:01:58 -04:00
1 parent 75e5d17a86
commit ed9b3a3dd1
18 files changed
+430 -12

No files matched your search

@@ -12,6 +12,8 @@ import { SocialPage } from "../pages/SocialPage";
import { DocumentsPage } from "../pages/DocumentsPage";
import { KnowledgePage } from "../pages/KnowledgePage";
import { AutomationPage } from "../pages/AutomationPage";
import { MeetingsPage } from "../pages/MeetingsPage";
import { SprintsPage } from "../pages/SprintsPage";
import { SettingsPage } from "../pages/SettingsPage";
export function App() {
@@ -43,6 +45,8 @@ export function App() {
docs: <DocumentsPage {...pageProps} />,
knowledge: <KnowledgePage {...pageProps} />,
automation: <AutomationPage {...pageProps} />,
meetings: <MeetingsPage {...pageProps} />,
sprints: <SprintsPage {...pageProps} />,
approvals: <ApprovalsPage {...pageProps} />,
social: <SocialPage {...pageProps} />,
settings: <SettingsPage {...pageProps} />,
@@ -46,13 +46,14 @@ export function ApprovalsPage({ snapshot, actions }: PageProps) {
const editing = editingId ? approvals.find((approval) => approval.id === editingId) ?? null : null;
const decide = (approval: ApprovalRecord, state: "approved" | "declined") => {
const decide = (approval: ApprovalRecord, state: "approved" | "declined", notes?: string) => {
setEditingId(null);
void actions.run(() => window.agentCompany.decideApproval({
orgId,
approvalId: approval.id,
state,
decidedByEmployeeId: employee.id,
notes,
}));
};
@@ -113,8 +114,7 @@ export function ApprovalsPage({ snapshot, actions }: PageProps) {
approval={editing}
requester={editing ? resolveRequester(editing, employees, agents) : ""}
onClose={() => setEditingId(null)}
onApprove={() => editing && decide(editing, "approved")}
onDecline={() => editing && decide(editing, "declined")}
onDecide={(state, notes) => editing && decide(editing, state, notes)}
/>
</div>
);
@@ -124,15 +124,14 @@ function ApprovalDetailDialog({
approval,
requester,
onClose,
onApprove,
onDecline,
onDecide,
}: {
approval: ApprovalRecord | null;
requester: string;
onClose: () => void;
onApprove: () => void;
onDecline: () => void;
onDecide: (state: "approved" | "declined", notes?: string) => void;
}) {
const [notes, setNotes] = useState("");
if (!approval) return null;
const tone = RISK_TONES[approval.risk];
if (!tone) throw new Error("Approval risk tone is missing.");
@@ -158,9 +157,17 @@ function ApprovalDetailDialog({
<span style={{ color: "var(--text-3)" }}>Requested</span>
<span>{formatRelative(approval.createdAt)}</span>
</div>
<textarea
className="input"
placeholder="Decision notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
style={{ marginTop: 14 }}
/>
<div className="modal-actions">
<Button onClick={onDecline}>Decline</Button>
<Button tone="primary" onClick={onApprove}>Approve</Button>
<Button onClick={() => onDecide("declined", notes || undefined)}>Decline</Button>
<Button tone="primary" onClick={() => onDecide("approved", notes || undefined)}>Approve</Button>
</div>
</div>
</div>
@@ -0,0 +1,97 @@
import { useMemo, useState, type FormEvent } from "react";
import { Button, Field, Icon, IconButton, Pill } from "../shared/ui";
import { TopbarActions } from "../shared/TopbarActions";
import { Dialog } from "../shared/Dialog";
import type { PageProps } from "./types";
import { activeOrgId, formatRelative } from "./types";
const MEETING_TYPES = ["standup", "all_hands", "sprint_review", "retrospective", "planning", "one_on_one", "ad_hoc"] as const;
function text(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
export function MeetingsPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const meetings = useMemo(() => snapshot.meetings.filter((m) => m.orgId === orgId), [snapshot.meetings, orgId]);
const [createOpen, setCreateOpen] = useState(false);
const create = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const scheduledAt = text(form, "scheduledAt");
void actions.run(() => window.agentCompany.createMeeting({
orgId,
title: text(form, "title"),
meetingType: text(form, "meetingType"),
scheduledAt: scheduledAt || null,
durationMinutes: Number(text(form, "durationMinutes")) || 30,
agenda: text(form, "agenda"),
})).then((r) => { if (r) setCreateOpen(false); });
};
const remove = (meetingId: string) => {
void actions.run(() => window.agentCompany.deleteMeeting({ orgId, meetingId }));
};
const scheduled = meetings.filter((m) => m.status === "scheduled");
const completed = meetings.filter((m) => m.status !== "scheduled");
return (
<div className="page">
<TopbarActions>
<Button tone="primary" onClick={() => setCreateOpen(true)}><Icon name="plus" size={13} /> Schedule</Button>
</TopbarActions>
<h1 className="page-h1">Meetings</h1>
<p className="page-sub">{scheduled.length} upcoming · {completed.length} past</p>
{scheduled.length > 0 && <div className="section-label" style={{ marginBottom: 10 }}>Upcoming</div>}
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 24 }}>
{scheduled.map((m) => (
<MeetingCard key={m.id} meeting={m} onDelete={() => remove(m.id)} />
))}
</div>
{completed.length > 0 && (
<>
<div className="section-label" style={{ marginBottom: 10 }}>Past</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{completed.map((m) => (
<MeetingCard key={m.id} meeting={m} onDelete={() => remove(m.id)} />
))}
</div>
</>
)}
<Dialog title="Schedule meeting" open={createOpen} submitLabel="Schedule" onClose={() => setCreateOpen(false)} onSubmit={create}>
<Field label="Title"><input className="input" name="title" required /></Field>
<Field label="Type">
<select className="input" name="meetingType" defaultValue="standup">
{MEETING_TYPES.map((t) => <option key={t} value={t}>{t.replace(/_/g, " ")}</option>)}
</select>
</Field>
<Field label="Scheduled at"><input className="input" name="scheduledAt" type="datetime-local" /></Field>
<Field label="Duration (min)"><input className="input" name="durationMinutes" type="number" defaultValue="30" min="5" /></Field>
<Field label="Agenda"><textarea className="input" name="agenda" rows={3} /></Field>
</Dialog>
</div>
);
}
function MeetingCard({ meeting, onDelete }: { meeting: { id: string; title: string; meetingType: string; status: string; scheduledAt: string | null; durationMinutes: number; agenda: string; createdAt: string }; onDelete: () => void }) {
return (
<div className="card" style={{ padding: 14 }}>
<div className="row" style={{ marginBottom: 6 }}>
<span style={{ fontWeight: 540, flex: 1 }}>{meeting.title}</span>
<IconButton icon="x" label="Delete" onClick={onDelete} />
</div>
<div className="row" style={{ gap: 6, marginBottom: 8 }}>
<Pill>{meeting.meetingType.replace(/_/g, " ")}</Pill>
<Pill tone={meeting.status === "scheduled" ? "amber" : undefined}>{meeting.status}</Pill>
<Pill>{meeting.durationMinutes}m</Pill>
</div>
{meeting.scheduledAt && <div style={{ fontSize: 12, color: "var(--text-3)" }}>{new Date(meeting.scheduledAt).toLocaleString()}</div>}
{meeting.agenda && <div style={{ fontSize: 13, color: "var(--text-2)", marginTop: 6, whiteSpace: "pre-wrap", lineHeight: 1.5 }}>{meeting.agenda}</div>}
</div>
);
}
@@ -0,0 +1,107 @@
import { useMemo, useState, type FormEvent } from "react";
import { Button, Field, Icon, IconButton, Pill } from "../shared/ui";
import { TopbarActions } from "../shared/TopbarActions";
import { Dialog } from "../shared/Dialog";
import type { SprintRecord, TaskRecord } from "../../../shared/domain";
import { TASK_STATUS } from "../../../shared/domain";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
function text(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
export function SprintsPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const sprints = useMemo(() => snapshot.sprints.filter((s) => s.orgId === orgId), [snapshot.sprints, orgId]);
const tasks = useMemo(() => snapshot.tasks.filter((t) => t.orgId === orgId), [snapshot.tasks, orgId]);
const [createOpen, setCreateOpen] = useState(false);
const create = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createSprint({
orgId,
name: text(form, "name"),
goal: text(form, "goal"),
startDate: text(form, "startDate"),
endDate: text(form, "endDate"),
})).then((r) => { if (r) setCreateOpen(false); });
};
const remove = (sprintId: string) => {
void actions.run(() => window.agentCompany.deleteSprint({ orgId, sprintId }));
};
return (
<div className="page">
<TopbarActions>
<Button tone="primary" onClick={() => setCreateOpen(true)}><Icon name="plus" size={13} /> New sprint</Button>
</TopbarActions>
<h1 className="page-h1">Sprints</h1>
<p className="page-sub">{sprints.length} {sprints.length === 1 ? "sprint" : "sprints"}</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gap: 12 }}>
{sprints.map((sprint) => (
<SprintCard key={sprint.id} sprint={sprint} tasks={tasks} onDelete={() => remove(sprint.id)} />
))}
</div>
<Dialog title="New sprint" open={createOpen} submitLabel="Create" onClose={() => setCreateOpen(false)} onSubmit={create}>
<Field label="Name"><input className="input" name="name" required /></Field>
<Field label="Goal"><textarea className="input" name="goal" rows={2} required /></Field>
<Field label="Start date"><input className="input" name="startDate" type="date" required /></Field>
<Field label="End date"><input className="input" name="endDate" type="date" required /></Field>
</Dialog>
</div>
);
}
function SprintCard({ sprint, tasks, onDelete }: { sprint: SprintRecord; tasks: TaskRecord[]; onDelete: () => void }) {
const start = new Date(sprint.startDate);
const end = new Date(sprint.endDate);
const now = new Date();
const totalDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000));
const elapsed = Math.max(0, Math.ceil((now.getTime() - start.getTime()) / 86400000));
const progressPct = Math.min(100, Math.round((elapsed / totalDays) * 100));
const done = tasks.filter((t) => t.status === TASK_STATUS.DONE).length;
const total = tasks.length;
const taskPct = total > 0 ? Math.round((done / total) * 100) : 0;
return (
<article className="card" style={{ padding: 16 }}>
<div className="row" style={{ marginBottom: 8 }}>
<span style={{ fontWeight: 560, flex: 1 }}>{sprint.name}</span>
<IconButton icon="x" label="Delete" onClick={onDelete} />
</div>
<div className="row" style={{ gap: 6, marginBottom: 10 }}>
<Pill tone={sprintStatusTone(sprint.status)}>{sprint.status}</Pill>
<Pill>{sprint.startDate} to {sprint.endDate}</Pill>
</div>
<div style={{ fontSize: 13, color: "var(--text-2)", lineHeight: 1.5, marginBottom: 12 }}>{sprint.goal}</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<ProgressBar label="Time" pct={progressPct} />
<ProgressBar label="Tasks" pct={taskPct} />
</div>
</article>
);
}
function ProgressBar({ label, pct }: { label: string; pct: number }) {
return (
<div className="row" style={{ gap: 8, fontSize: 12 }}>
<span style={{ width: 40, color: "var(--text-3)" }}>{label}</span>
<div style={{ flex: 1, height: 3, background: "var(--panel-3)", borderRadius: 2, overflow: "hidden" }}>
<div style={{ width: `${pct}%`, height: "100%", background: "var(--text-1)" }} />
</div>
<span className="num" style={{ color: "var(--text-3)", width: 30, textAlign: "right" }}>{pct}%</span>
</div>
);
}
function sprintStatusTone(status: string): "amber" | "green" | "violet" | undefined {
if (status === "planned") return undefined;
if (status === "active") return "amber";
if (status === "completed") return "green";
return "violet";
}
@@ -15,6 +15,8 @@ export type RouteId =
| "docs"
| "knowledge"
| "automation"
| "meetings"
| "sprints"
| "approvals"
| "social"
| "settings";
@@ -38,6 +40,8 @@ const primaryNav: NavSpec[] = [
{ id: "docs", label: "Documents", icon: "file" },
{ id: "knowledge", label: "Knowledge", icon: "sparkle" },
{ id: "automation", label: "Automation", icon: "bot" },
{ id: "meetings", label: "Meetings", icon: "team" },
{ id: "sprints", label: "Sprints", icon: "work" },
{ id: "approvals", label: "Approvals", icon: "approve" },
{ id: "social", label: "Social", icon: "social" },
];