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:
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" },
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user