Add goal, project, and workspace CRUD
Full-stack: IPC channels, command types, OrganizationService methods, IPC handlers, preload bridge, desktop-api types, and UI dialogs on the Work page. Users can now create goals and projects directly instead of relying on the onboarding bootstrap.
This commit is contained in:
1 parent
2ef721774f
commit
5f92cd0d75
9 files changed
+218
-1
No files matched your search
@@ -101,6 +101,9 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
|||||||
return fail("task_run_failed", error instanceof Error ? error.message : String(error));
|
return fail("task_run_failed", error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
ipcMain.handle(IPC_CHANNELS.createGoal, (_event, input) => mutate(() => organization.createGoal(input)));
|
||||||
|
ipcMain.handle(IPC_CHANNELS.createProject, (_event, input) => mutate(() => organization.createProject(input)));
|
||||||
|
ipcMain.handle(IPC_CHANNELS.createWorkspace, (_event, input) => mutate(() => organization.createWorkspace(input)));
|
||||||
ipcMain.handle(IPC_CHANNELS.createApproval, (_event, input) => mutate(() => approvals.create(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.decideApproval, (_event, input) => mutate(() => approvals.decide(input)));
|
||||||
ipcMain.handle(IPC_CHANNELS.syncEmployeeIdentity, (_event, input) => mutate(() => identityAccess.syncEmployeeIdentity(input)));
|
ipcMain.handle(IPC_CHANNELS.syncEmployeeIdentity, (_event, input) => mutate(() => identityAccess.syncEmployeeIdentity(input)));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { DatabaseSync } from "node:sqlite";
|
|||||||
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, type ApprovalState } from "../../shared/domain";
|
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, type ApprovalState } from "../../shared/domain";
|
||||||
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
import { PROVIDER_CATALOG } from "../../shared/provider-catalog";
|
||||||
import { createId } from "../../shared/ids";
|
import { createId } from "../../shared/ids";
|
||||||
import type { CreateAgentInput, CreateOrganizationInput, CreateTaskInput, UpdateAgentInput } from "../../shared/commands";
|
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, UpdateAgentInput } from "../../shared/commands";
|
||||||
import { AuditService } from "./audit-service";
|
import { AuditService } from "./audit-service";
|
||||||
import { PolicyService } from "./policy-service";
|
import { PolicyService } from "./policy-service";
|
||||||
import { IdentityAccessService } from "./identity-access-service";
|
import { IdentityAccessService } from "./identity-access-service";
|
||||||
@@ -382,6 +382,67 @@ export class OrganizationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createGoal(input: CreateGoalInput): string {
|
||||||
|
const id = createId("gol");
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
if (input.ownerAgentId) this.verifyOptionalOrgEntity("agents", input.ownerAgentId, input.orgId, "Owner agent");
|
||||||
|
this.db.prepare(`
|
||||||
|
insert into goals(id, org_id, title, description, status, owner_agent_id, created_at, updated_at)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(id, input.orgId, input.title.trim(), input.description.trim(), "active", input.ownerAgentId, now, now);
|
||||||
|
this.audit.record({
|
||||||
|
orgId: input.orgId,
|
||||||
|
entityType: "goal",
|
||||||
|
entityId: id,
|
||||||
|
actorType: "employee",
|
||||||
|
actorId: null,
|
||||||
|
action: "created",
|
||||||
|
payload: { title: input.title.trim() },
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
createProject(input: CreateProjectInput): string {
|
||||||
|
const id = createId("prj");
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
if (input.goalId) this.verifyOptionalOrgEntity("goals", input.goalId, input.orgId, "Goal");
|
||||||
|
if (input.leadAgentId) this.verifyOptionalOrgEntity("agents", input.leadAgentId, input.orgId, "Lead agent");
|
||||||
|
this.db.prepare(`
|
||||||
|
insert into projects(id, org_id, goal_id, name, description, lead_agent_id, status, created_at, updated_at)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(id, input.orgId, input.goalId, input.name.trim(), input.description.trim(), input.leadAgentId, "in_progress", now, now);
|
||||||
|
this.audit.record({
|
||||||
|
orgId: input.orgId,
|
||||||
|
entityType: "project",
|
||||||
|
entityId: id,
|
||||||
|
actorType: "employee",
|
||||||
|
actorId: null,
|
||||||
|
action: "created",
|
||||||
|
payload: { name: input.name.trim() },
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
createWorkspace(input: CreateWorkspaceInput): string {
|
||||||
|
const id = createId("wsp");
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
if (input.projectId) this.verifyProject(input.projectId, null, input.orgId);
|
||||||
|
this.db.prepare(`
|
||||||
|
insert into workspaces(id, org_id, name, local_path, repo_url, repo_ref, project_id, is_primary, created_at, updated_at)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(id, input.orgId, input.name.trim(), input.localPath.trim(), "", "", input.projectId, 0, now, now);
|
||||||
|
this.audit.record({
|
||||||
|
orgId: input.orgId,
|
||||||
|
entityType: "workspace",
|
||||||
|
entityId: id,
|
||||||
|
actorType: "employee",
|
||||||
|
actorId: null,
|
||||||
|
action: "created",
|
||||||
|
payload: { name: input.name.trim() },
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
updateTaskStatus(orgId: string, taskId: string, status: string): void {
|
updateTaskStatus(orgId: string, taskId: string, status: string): void {
|
||||||
const result = this.db.prepare("update tasks set status = ?, updated_at = ? where id = ? and org_id = ?")
|
const result = this.db.prepare("update tasks set status = ?, updated_at = ? where id = ? and org_id = ?")
|
||||||
.run(status, new Date().toISOString(), taskId, orgId);
|
.run(status, new Date().toISOString(), taskId, orgId);
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ const api: DesktopApi = {
|
|||||||
createTask: (input) => invoke(IPC_CHANNELS.createTask, input),
|
createTask: (input) => invoke(IPC_CHANNELS.createTask, input),
|
||||||
updateTaskStatus: (input) => invoke(IPC_CHANNELS.updateTaskStatus, input),
|
updateTaskStatus: (input) => invoke(IPC_CHANNELS.updateTaskStatus, input),
|
||||||
runTask: (input) => invoke(IPC_CHANNELS.runTask, input),
|
runTask: (input) => invoke(IPC_CHANNELS.runTask, input),
|
||||||
|
createGoal: (input) => invoke(IPC_CHANNELS.createGoal, input),
|
||||||
|
createProject: (input) => invoke(IPC_CHANNELS.createProject, input),
|
||||||
|
createWorkspace: (input) => invoke(IPC_CHANNELS.createWorkspace, input),
|
||||||
createApproval: (input) => invoke(IPC_CHANNELS.createApproval, input),
|
createApproval: (input) => invoke(IPC_CHANNELS.createApproval, input),
|
||||||
decideApproval: (input) => invoke(IPC_CHANNELS.decideApproval, input),
|
decideApproval: (input) => invoke(IPC_CHANNELS.decideApproval, input),
|
||||||
syncEmployeeIdentity: (input) => invoke(IPC_CHANNELS.syncEmployeeIdentity, input),
|
syncEmployeeIdentity: (input) => invoke(IPC_CHANNELS.syncEmployeeIdentity, input),
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
type WorkspaceRecord,
|
type WorkspaceRecord,
|
||||||
} from "../../../shared/domain";
|
} from "../../../shared/domain";
|
||||||
import { NewTaskDialog } from "./NewTaskDialog";
|
import { NewTaskDialog } from "./NewTaskDialog";
|
||||||
|
import { GoalDialog } from "./work/GoalDialog";
|
||||||
|
import { ProjectDialog } from "./work/ProjectDialog";
|
||||||
import { TaskDetailDialog } from "./work/TaskDetailDialog";
|
import { TaskDetailDialog } from "./work/TaskDetailDialog";
|
||||||
import { ProjectOverview } from "./work/ProjectOverview";
|
import { ProjectOverview } from "./work/ProjectOverview";
|
||||||
import { canRun, latestTaskRun, nextStatus, prettyRunState, prettyStatus, providerFor, runTone } from "./work/task-helpers";
|
import { canRun, latestTaskRun, nextStatus, prettyRunState, prettyStatus, providerFor, runTone } from "./work/task-helpers";
|
||||||
@@ -41,6 +43,8 @@ export function WorkPage({ snapshot, actions }: PageProps) {
|
|||||||
const orgId = activeOrgId(snapshot);
|
const orgId = activeOrgId(snapshot);
|
||||||
const [view, setView] = useState<View>("board");
|
const [view, setView] = useState<View>("board");
|
||||||
const [newTaskOpen, setNewTaskOpen] = useState(false);
|
const [newTaskOpen, setNewTaskOpen] = useState(false);
|
||||||
|
const [newGoalOpen, setNewGoalOpen] = useState(false);
|
||||||
|
const [newProjectOpen, setNewProjectOpen] = useState(false);
|
||||||
const [priorityFilter, setPriorityFilter] = useState<TaskPriority | "all">("all");
|
const [priorityFilter, setPriorityFilter] = useState<TaskPriority | "all">("all");
|
||||||
const [assigneeFilter, setAssigneeFilter] = useState<string>("all");
|
const [assigneeFilter, setAssigneeFilter] = useState<string>("all");
|
||||||
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
||||||
@@ -88,6 +92,8 @@ export function WorkPage({ snapshot, actions }: PageProps) {
|
|||||||
onPriority={setPriorityFilter}
|
onPriority={setPriorityFilter}
|
||||||
onAssignee={setAssigneeFilter}
|
onAssignee={setAssigneeFilter}
|
||||||
/>
|
/>
|
||||||
|
<Button tone="ghost" onClick={() => setNewGoalOpen(true)}><Icon name="plus" size={13} /> Goal</Button>
|
||||||
|
<Button tone="ghost" onClick={() => setNewProjectOpen(true)}><Icon name="plus" size={13} /> Project</Button>
|
||||||
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={13} /> New task</Button>
|
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={13} /> New task</Button>
|
||||||
</TopbarActions>
|
</TopbarActions>
|
||||||
<h1 className="page-h1">Work</h1>
|
<h1 className="page-h1">Work</h1>
|
||||||
@@ -124,6 +130,8 @@ export function WorkPage({ snapshot, actions }: PageProps) {
|
|||||||
)}
|
)}
|
||||||
{view === "timeline" && <TimelineView tasks={tasks} goals={goals} />}
|
{view === "timeline" && <TimelineView tasks={tasks} goals={goals} />}
|
||||||
|
|
||||||
|
<GoalDialog open={newGoalOpen} orgId={orgId} agents={agents} actions={actions} onClose={() => setNewGoalOpen(false)} />
|
||||||
|
<ProjectDialog open={newProjectOpen} orgId={orgId} goals={goals} agents={agents} actions={actions} onClose={() => setNewProjectOpen(false)} />
|
||||||
<NewTaskDialog open={newTaskOpen} orgId={orgId} snapshot={snapshot} actions={actions} onClose={() => setNewTaskOpen(false)} />
|
<NewTaskDialog open={newTaskOpen} orgId={orgId} snapshot={snapshot} actions={actions} onClose={() => setNewTaskOpen(false)} />
|
||||||
<TaskDetailDialog
|
<TaskDetailDialog
|
||||||
task={detailTask}
|
task={detailTask}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { type FormEvent } from "react";
|
||||||
|
import { Field } from "../../shared/ui";
|
||||||
|
import { Dialog } from "../../shared/Dialog";
|
||||||
|
import type { AgentRecord, AppSnapshot } from "../../../../shared/domain";
|
||||||
|
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||||
|
|
||||||
|
function text(form: FormData, key: string): string {
|
||||||
|
return String(form.get(key) ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function optional(form: FormData, key: string): string | null {
|
||||||
|
return text(form, key) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GoalDialog({
|
||||||
|
open,
|
||||||
|
orgId,
|
||||||
|
agents,
|
||||||
|
actions,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
orgId: string;
|
||||||
|
agents: AgentRecord[];
|
||||||
|
actions: SnapshotActions;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
void actions.run(() => window.agentCompany.createGoal({
|
||||||
|
orgId,
|
||||||
|
title: text(form, "title"),
|
||||||
|
description: text(form, "description"),
|
||||||
|
ownerAgentId: optional(form, "ownerAgentId"),
|
||||||
|
})).then((result) => { if (result) onClose(); });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title="New goal" open={open} submitLabel="Create" onClose={onClose} onSubmit={submit}>
|
||||||
|
<Field label="Title"><input className="input" name="title" required /></Field>
|
||||||
|
<Field label="Description"><textarea className="input" name="description" required /></Field>
|
||||||
|
<Field label="Owner agent">
|
||||||
|
<select className="input" name="ownerAgentId" defaultValue="">
|
||||||
|
<option value="">Unassigned</option>
|
||||||
|
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name} · {agent.role}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { type FormEvent } from "react";
|
||||||
|
import { Field } from "../../shared/ui";
|
||||||
|
import { Dialog } from "../../shared/Dialog";
|
||||||
|
import type { AgentRecord, GoalRecord } from "../../../../shared/domain";
|
||||||
|
import type { SnapshotActions } from "../../app/useSnapshot";
|
||||||
|
|
||||||
|
function text(form: FormData, key: string): string {
|
||||||
|
return String(form.get(key) ?? "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function optional(form: FormData, key: string): string | null {
|
||||||
|
return text(form, key) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectDialog({
|
||||||
|
open,
|
||||||
|
orgId,
|
||||||
|
goals,
|
||||||
|
agents,
|
||||||
|
actions,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
orgId: string;
|
||||||
|
goals: GoalRecord[];
|
||||||
|
agents: AgentRecord[];
|
||||||
|
actions: SnapshotActions;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = new FormData(event.currentTarget);
|
||||||
|
void actions.run(() => window.agentCompany.createProject({
|
||||||
|
orgId,
|
||||||
|
goalId: optional(form, "goalId"),
|
||||||
|
name: text(form, "name"),
|
||||||
|
description: text(form, "description"),
|
||||||
|
leadAgentId: optional(form, "leadAgentId"),
|
||||||
|
})).then((result) => { if (result) onClose(); });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog title="New project" open={open} submitLabel="Create" onClose={onClose} onSubmit={submit}>
|
||||||
|
<Field label="Name"><input className="input" name="name" required /></Field>
|
||||||
|
<Field label="Description"><textarea className="input" name="description" required /></Field>
|
||||||
|
<Field label="Goal">
|
||||||
|
<select className="input" name="goalId" defaultValue="">
|
||||||
|
<option value="">None</option>
|
||||||
|
{goals.map((goal) => <option key={goal.id} value={goal.id}>{goal.title}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Lead agent">
|
||||||
|
<select className="input" name="leadAgentId" defaultValue="">
|
||||||
|
<option value="">Unassigned</option>
|
||||||
|
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name} · {agent.role}</option>)}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -88,6 +88,28 @@ export interface RunTaskInput {
|
|||||||
taskId: string;
|
taskId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateGoalInput {
|
||||||
|
orgId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
ownerAgentId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProjectInput {
|
||||||
|
orgId: string;
|
||||||
|
goalId: string | null;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
leadAgentId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateWorkspaceInput {
|
||||||
|
orgId: string;
|
||||||
|
name: string;
|
||||||
|
localPath: string;
|
||||||
|
projectId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateApprovalInput {
|
export interface CreateApprovalInput {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
kind: string;
|
kind: string;
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import type {
|
|||||||
CreateAgentInput,
|
CreateAgentInput,
|
||||||
UpdateAgentInput,
|
UpdateAgentInput,
|
||||||
CreateApprovalInput,
|
CreateApprovalInput,
|
||||||
|
CreateGoalInput,
|
||||||
|
CreateProjectInput,
|
||||||
|
CreateWorkspaceInput,
|
||||||
CreateAuditReplayInput,
|
CreateAuditReplayInput,
|
||||||
CreateChatSpaceInput,
|
CreateChatSpaceInput,
|
||||||
CreateComplianceExportInput,
|
CreateComplianceExportInput,
|
||||||
@@ -90,6 +93,9 @@ export interface DesktopApi {
|
|||||||
createTask(input: CreateTaskInput): Promise<DesktopResult<AppSnapshot>>;
|
createTask(input: CreateTaskInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
updateTaskStatus(input: UpdateTaskStatusInput): Promise<DesktopResult<AppSnapshot>>;
|
updateTaskStatus(input: UpdateTaskStatusInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
runTask(input: RunTaskInput): Promise<DesktopResult<AppSnapshot>>;
|
runTask(input: RunTaskInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
|
createGoal(input: CreateGoalInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
|
createProject(input: CreateProjectInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
|
createWorkspace(input: CreateWorkspaceInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
createApproval(input: CreateApprovalInput): Promise<DesktopResult<AppSnapshot>>;
|
createApproval(input: CreateApprovalInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
decideApproval(input: DecideApprovalInput): Promise<DesktopResult<AppSnapshot>>;
|
decideApproval(input: DecideApprovalInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
syncEmployeeIdentity(input: SyncEmployeeIdentityInput): Promise<DesktopResult<AppSnapshot>>;
|
syncEmployeeIdentity(input: SyncEmployeeIdentityInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ export const IPC_CHANNELS = {
|
|||||||
setLegalHoldState: "agentcompany:set-legal-hold-state",
|
setLegalHoldState: "agentcompany:set-legal-hold-state",
|
||||||
createRetentionFreeze: "agentcompany:create-retention-freeze",
|
createRetentionFreeze: "agentcompany:create-retention-freeze",
|
||||||
setRetentionFreezeState: "agentcompany:set-retention-freeze-state",
|
setRetentionFreezeState: "agentcompany:set-retention-freeze-state",
|
||||||
|
createGoal: "agentcompany:create-goal",
|
||||||
|
createProject: "agentcompany:create-project",
|
||||||
|
createWorkspace: "agentcompany:create-workspace",
|
||||||
search: "agentcompany:search",
|
search: "agentcompany:search",
|
||||||
rendererEvent: "agentcompany:renderer-event",
|
rendererEvent: "agentcompany:renderer-event",
|
||||||
windowMinimize: "agentcompany:window-minimize",
|
windowMinimize: "agentcompany:window-minimize",
|
||||||
|
|||||||
Reference in new issue
Block a user