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
@@ -25,6 +25,8 @@ import { TaskExecutionService } from "../services/task-execution-service";
|
||||
import { DocumentService } from "../services/document-service";
|
||||
import { KnowledgeService } from "../services/knowledge-service";
|
||||
import { AutomationService } from "../services/automation-service";
|
||||
import { MeetingService } from "../services/meeting-service";
|
||||
import { SprintService } from "../services/sprint-service";
|
||||
|
||||
export interface IpcDeps {
|
||||
connection: DatabaseConnection;
|
||||
@@ -54,6 +56,8 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
const documents = new DocumentService(deps.connection.db);
|
||||
const knowledge = new KnowledgeService(deps.connection.db);
|
||||
const automation = new AutomationService(deps.connection.db);
|
||||
const meetingService = new MeetingService(deps.connection.db);
|
||||
const sprintService = new SprintService(deps.connection.db);
|
||||
const onboarding = new OnboardingService({
|
||||
db: deps.connection.db,
|
||||
saveProviderCredential: (input) => providers.saveCredential(input),
|
||||
@@ -120,6 +124,10 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
ipcMain.handle(IPC_CHANNELS.createAutomationRule, (_event, input) => mutate(() => automation.create(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.updateAutomationRule, (_event, input) => mutate(() => automation.update(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.deleteAutomationRule, (_event, input) => mutate(() => automation.delete(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createMeeting, (_event, input) => mutate(() => meetingService.create(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.deleteMeeting, (_event, input) => mutate(() => meetingService.delete(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createSprint, (_event, input) => mutate(() => sprintService.create(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.deleteSprint, (_event, input) => mutate(() => sprintService.delete(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.createApproval, (_event, input) => mutate(() => approvals.create(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.decideApproval, (_event, input) => mutate(() => approvals.decide(input)));
|
||||
ipcMain.handle(IPC_CHANNELS.syncEmployeeIdentity, (_event, input) => mutate(() => identityAccess.syncEmployeeIdentity(input)));
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export const approvalNotesMeetingsSprintsSchema = `
|
||||
alter table approvals add column decision_notes text;
|
||||
|
||||
create table if not exists meetings (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
title text not null,
|
||||
meeting_type text not null,
|
||||
status text not null,
|
||||
scheduled_at text,
|
||||
duration_minutes integer not null,
|
||||
agenda text not null,
|
||||
notes text not null,
|
||||
decisions text not null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
create index if not exists idx_meetings_org on meetings(org_id, scheduled_at);
|
||||
|
||||
create table if not exists sprints (
|
||||
id text primary key,
|
||||
org_id text not null references organizations(id) on delete cascade,
|
||||
name text not null,
|
||||
goal text not null,
|
||||
status text not null,
|
||||
start_date text not null,
|
||||
end_date text not null,
|
||||
velocity_points integer not null default 0,
|
||||
completed_points integer not null default 0,
|
||||
retrospective text not null,
|
||||
created_at text not null,
|
||||
updated_at text not null
|
||||
);
|
||||
|
||||
create index if not exists idx_sprints_org on sprints(org_id, status);
|
||||
`;
|
||||
@@ -40,6 +40,7 @@ import { providerAuthProfileCascadeSchema } from "./migrations/038-provider-auth
|
||||
import { auditImmutabilityOrgTeardownSchema } from "./migrations/039-audit-immutability-org-teardown";
|
||||
import { documentsKnowledgeAutomationSchema } from "./migrations/040-documents-knowledge-automation";
|
||||
import { taskSubtasksDeleteSchema } from "./migrations/041-task-subtasks-delete";
|
||||
import { approvalNotesMeetingsSprintsSchema } from "./migrations/042-approval-notes-meetings-sprints";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -89,6 +90,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 39, name: "audit_immutability_org_teardown", sql: auditImmutabilityOrgTeardownSchema },
|
||||
{ version: 40, name: "documents_knowledge_automation", sql: documentsKnowledgeAutomationSchema },
|
||||
{ version: 41, name: "task_subtasks_delete", sql: taskSubtasksDeleteSchema },
|
||||
{ version: 42, name: "approval_notes_meetings_sprints", sql: approvalNotesMeetingsSprintsSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -79,11 +79,12 @@ export class ApprovalService {
|
||||
"Deciding employee was not found.",
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
const notes = input.notes?.trim() || null;
|
||||
const result = this.db.prepare(`
|
||||
update approvals
|
||||
set state = ?, decided_by_employee_id = ?, decided_at = ?
|
||||
set state = ?, decided_by_employee_id = ?, decided_at = ?, decision_notes = ?
|
||||
where id = ? and org_id = ? and state = ?
|
||||
`).run(input.state, input.decidedByEmployeeId, now, input.approvalId, input.orgId, APPROVAL_STATE.PENDING);
|
||||
`).run(input.state, input.decidedByEmployeeId, now, notes, input.approvalId, input.orgId, APPROVAL_STATE.PENDING);
|
||||
if (result.changes === 0) throw new Error("Pending approval was not found.");
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateMeetingInput, DeleteMeetingInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
export class MeetingService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
create(input: CreateMeetingInput): string {
|
||||
const id = createId("mtg");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into meetings(id, org_id, title, meeting_type, status, scheduled_at, duration_minutes, agenda, notes, decisions, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.title.trim(), input.meetingType, "scheduled", input.scheduledAt, input.durationMinutes, input.agenda.trim(), "", "", now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "meeting",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { title: input.title.trim(), meetingType: input.meetingType },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
delete(input: DeleteMeetingInput): void {
|
||||
const result = this.db.prepare("delete from meetings where id = ? and org_id = ?").run(input.meetingId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Meeting was not found.");
|
||||
}
|
||||
}
|
||||
@@ -160,6 +160,8 @@ export class SnapshotService {
|
||||
knowledgeEntries: list(this.db, "knowledge_entries", "updated_at desc"),
|
||||
automationRules: list<AppSnapshot["automationRules"][number]>(this.db, "automation_rules", "priority asc")
|
||||
.map((rule) => ({ ...rule, enabled: toBool(rule.enabled) })),
|
||||
meetings: list(this.db, "meetings", "scheduled_at desc"),
|
||||
sprints: list(this.db, "sprints", "start_date desc"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { CreateSprintInput, DeleteSprintInput } from "../../shared/commands";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { AuditService } from "./audit-service";
|
||||
|
||||
export class SprintService {
|
||||
private readonly audit: AuditService;
|
||||
|
||||
constructor(private readonly db: DatabaseSync) {
|
||||
this.audit = new AuditService(db);
|
||||
}
|
||||
|
||||
create(input: CreateSprintInput): string {
|
||||
const id = createId("spr");
|
||||
const now = new Date().toISOString();
|
||||
this.db.prepare(`
|
||||
insert into sprints(id, org_id, name, goal, status, start_date, end_date, velocity_points, completed_points, retrospective, created_at, updated_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?, ?)
|
||||
`).run(id, input.orgId, input.name.trim(), input.goal.trim(), "planned", input.startDate, input.endDate, "", now, now);
|
||||
this.audit.record({
|
||||
orgId: input.orgId,
|
||||
entityType: "sprint",
|
||||
entityId: id,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
action: "created",
|
||||
payload: { name: input.name.trim() },
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
delete(input: DeleteSprintInput): void {
|
||||
const result = this.db.prepare("delete from sprints where id = ? and org_id = ?").run(input.sprintId, input.orgId);
|
||||
if (result.changes === 0) throw new Error("Sprint was not found.");
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,10 @@ const api: DesktopApi = {
|
||||
createAutomationRule: (input) => invoke(IPC_CHANNELS.createAutomationRule, input),
|
||||
updateAutomationRule: (input) => invoke(IPC_CHANNELS.updateAutomationRule, input),
|
||||
deleteAutomationRule: (input) => invoke(IPC_CHANNELS.deleteAutomationRule, input),
|
||||
createMeeting: (input) => invoke(IPC_CHANNELS.createMeeting, input),
|
||||
deleteMeeting: (input) => invoke(IPC_CHANNELS.deleteMeeting, input),
|
||||
createSprint: (input) => invoke(IPC_CHANNELS.createSprint, input),
|
||||
deleteSprint: (input) => invoke(IPC_CHANNELS.deleteSprint, input),
|
||||
createApproval: (input) => invoke(IPC_CHANNELS.createApproval, input),
|
||||
decideApproval: (input) => invoke(IPC_CHANNELS.decideApproval, input),
|
||||
syncEmployeeIdentity: (input) => invoke(IPC_CHANNELS.syncEmployeeIdentity, input),
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -143,6 +143,7 @@ export interface DecideApprovalInput {
|
||||
approvalId: string;
|
||||
state: Extract<ApprovalState, "approved" | "declined">;
|
||||
decidedByEmployeeId: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface SyncEmployeeIdentityInput {
|
||||
@@ -554,6 +555,33 @@ export interface DeleteAutomationRuleInput {
|
||||
ruleId: string;
|
||||
}
|
||||
|
||||
export interface CreateMeetingInput {
|
||||
orgId: string;
|
||||
title: string;
|
||||
meetingType: string;
|
||||
scheduledAt: string | null;
|
||||
durationMinutes: number;
|
||||
agenda: string;
|
||||
}
|
||||
|
||||
export interface DeleteMeetingInput {
|
||||
orgId: string;
|
||||
meetingId: string;
|
||||
}
|
||||
|
||||
export interface CreateSprintInput {
|
||||
orgId: string;
|
||||
name: string;
|
||||
goal: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export interface DeleteSprintInput {
|
||||
orgId: string;
|
||||
sprintId: string;
|
||||
}
|
||||
|
||||
export interface NativeDialogResult {
|
||||
canceled: boolean;
|
||||
filePaths: string[];
|
||||
|
||||
@@ -12,6 +12,10 @@ import type {
|
||||
CreateAutomationRuleInput,
|
||||
CreateDocumentInput,
|
||||
CreateGoalInput,
|
||||
CreateMeetingInput,
|
||||
CreateSprintInput,
|
||||
DeleteMeetingInput,
|
||||
DeleteSprintInput,
|
||||
CreateKnowledgeEntryInput,
|
||||
CreateProjectInput,
|
||||
CreateWorkspaceInput,
|
||||
@@ -116,6 +120,10 @@ export interface DesktopApi {
|
||||
createAutomationRule(input: CreateAutomationRuleInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
updateAutomationRule(input: UpdateAutomationRuleInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
deleteAutomationRule(input: DeleteAutomationRuleInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createMeeting(input: CreateMeetingInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
deleteMeeting(input: DeleteMeetingInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createSprint(input: CreateSprintInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
deleteSprint(input: DeleteSprintInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createApproval(input: CreateApprovalInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
decideApproval(input: DecideApprovalInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
syncEmployeeIdentity(input: SyncEmployeeIdentityInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
|
||||
@@ -178,11 +178,42 @@ export interface ApprovalRecord {
|
||||
sourceEntityType: string | null;
|
||||
sourceEntityId: string | null;
|
||||
state: ApprovalState;
|
||||
decisionNotes: string | null;
|
||||
decidedByEmployeeId: string | null;
|
||||
createdAt: string;
|
||||
decidedAt: string | null;
|
||||
}
|
||||
|
||||
export interface MeetingRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
title: string;
|
||||
meetingType: string;
|
||||
status: string;
|
||||
scheduledAt: string | null;
|
||||
durationMinutes: number;
|
||||
agenda: string;
|
||||
notes: string;
|
||||
decisions: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SprintRecord {
|
||||
id: string;
|
||||
orgId: string;
|
||||
name: string;
|
||||
goal: string;
|
||||
status: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
velocityPoints: number;
|
||||
completedPoints: number;
|
||||
retrospective: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type {
|
||||
ChatSpaceRecord,
|
||||
ChatMessageRecord,
|
||||
@@ -488,4 +519,6 @@ export interface AppSnapshot {
|
||||
documents: DocumentRecord[];
|
||||
knowledgeEntries: KnowledgeEntryRecord[];
|
||||
automationRules: AutomationRuleRecord[];
|
||||
meetings: MeetingRecord[];
|
||||
sprints: SprintRecord[];
|
||||
}
|
||||
@@ -70,6 +70,10 @@ export const IPC_CHANNELS = {
|
||||
createAutomationRule: "agentcompany:create-automation-rule",
|
||||
updateAutomationRule: "agentcompany:update-automation-rule",
|
||||
deleteAutomationRule: "agentcompany:delete-automation-rule",
|
||||
createMeeting: "agentcompany:create-meeting",
|
||||
deleteMeeting: "agentcompany:delete-meeting",
|
||||
createSprint: "agentcompany:create-sprint",
|
||||
deleteSprint: "agentcompany:delete-sprint",
|
||||
search: "agentcompany:search",
|
||||
rendererEvent: "agentcompany:renderer-event",
|
||||
windowMinimize: "agentcompany:window-minimize",
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("domain services", () => {
|
||||
const connection = createConnection();
|
||||
|
||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41]);
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user