From eba17f0332315743e270bff7027fe0707e508217 Mon Sep 17 00:00:00 2001 From: saadiqbal-dev Date: Fri, 30 Oct 2026 11:00:58 +0500 Subject: [PATCH 1/2] feat(work-orders): simpler first work order, Home, clearer list and detail pages Work order creation - Prefill the builder's GitHub login and email from their profile or the Agency's last work order (new builderDefaults route, Agency-scoped) - Create a project or builder inline from the form; project slugs avoid collisions - Keep an unsent work order as a local draft per user, network and Agency; representative details are never stored and drafts clear on sign-out - Import repositories from GitHub (NEARBuilders and MultiAgency by default, or any organization); public repositories only, cached for 10 minutes, each chosen repo shown as a link - Explain why "Send for signature" is disabled Home and lists - New Home page with the work orders waiting on you; Home added to the menu - Work orders list: one row of tabs, full titles, builder account, "$X of $Y" budget - list() loads requests, documents, signatures and builders in batches Work order page - Overview, payouts and documents tabs (old tab links still work), key facts strip, Edit and More menu, Terminate for offered orders - "Withdraw and delete" checks Documenso first: it refuses when the builder already signed, when Documenso can't be checked, or when the envelope can't be cancelled - Payout account shows "Confirmed when they sign" only for Documenso signing; no duplicate payout-account warning on accepted orders - Shows how the order was signed (Documenso, Google Docs or MultiAgency) Fixes - Store created/updated times in UTC (PGlite and pg sessions) - Renewals keep the project title and slug; "Unknown project" instead of raw ids, and documents refuse to generate without a project title - Editing an order never auto-fills terms from builder defaults - Shorter setup and status copy across admin pages --- api/src/db/index.ts | 6 + api/src/index.ts | 8 + api/src/services/work-orders/github.ts | 44 ++ api/src/services/work-orders/progress.ts | 3 +- api/src/services/work-orders/service.ts | 228 +++++- api/src/services/work-orders/setup-status.ts | 26 +- api/src/work-orders-contract.ts | 37 + api/tests/fakes/github.ts | 10 + .../integration/work-order-setup.test.ts | 4 +- api/tests/integration/work-orders.test.ts | 167 +++++ api/tests/unit/progress.test.ts | 42 ++ ui/src/components/admin-sidebar.tsx | 6 +- .../components/admin/applications-section.tsx | 8 +- .../components/admin/contributors-section.tsx | 10 +- ui/src/components/admin/project-form.tsx | 8 +- ui/src/components/admin/projects-section.tsx | 7 +- .../components/home/home-work-order-lists.tsx | 125 ++++ ui/src/components/home/home-work-orders.ts | 19 + ui/src/components/user-nav.tsx | 2 + .../work-orders/activity-timeline.tsx | 6 +- .../work-orders/builder-signing.tsx | 8 +- .../work-orders/delivered-work-section.tsx | 14 +- .../duplicate-work-order-button.tsx | 41 -- .../components/work-orders/evidence-list.tsx | 7 +- .../work-orders/github-repo-picker.tsx | 145 ++++ .../work-orders/google-settings.tsx | 5 +- .../work-orders/integration-settings.tsx | 11 +- .../components/work-orders/next-step-card.tsx | 45 +- .../components/work-orders/operator-hint.tsx | 2 +- .../work-orders/payee-payouts-prompt.tsx | 4 +- .../work-orders/quick-create-builder.tsx | 95 +++ .../work-orders/quick-create-project.tsx | 112 +++ .../components/work-orders/renewal-panel.tsx | 9 +- .../work-orders/representative-settings.tsx | 15 +- .../components/work-orders/requests-panel.tsx | 10 +- .../work-orders/setup-checklist.tsx | 48 +- .../components/work-orders/signing-panel.tsx | 62 +- .../work-orders/template-fields.tsx | 2 +- .../components/work-orders/template-setup.tsx | 5 +- .../components/work-orders/trust-settings.tsx | 7 +- .../work-orders/work-order-form.tsx | 677 ++++++++++++------ .../work-orders/work-order-key-facts.tsx | 73 ++ .../work-orders/work-order-status.tsx | 14 + .../work-orders/work-orders-card.tsx | 5 +- .../work-orders/work-orders-inbox.tsx | 99 +-- ui/src/lib/navigation.ts | 5 +- ui/src/lib/queries.ts | 24 + ui/src/lib/setup-status.ts | 10 +- ui/src/lib/work-order-defaults.ts | 39 + ui/src/lib/work-order-draft.ts | 67 ++ ui/src/lib/work-orders-inbox.ts | 2 +- .../admin/contributors/$nearAccount.tsx | 20 +- .../admin/contributors/index.tsx | 5 +- .../_layout/_authenticated/admin/index.tsx | 75 +- .../_layout/_authenticated/admin/members.tsx | 2 +- .../_authenticated/admin/projects/$slug.tsx | 14 +- .../_authenticated/admin/projects/index.tsx | 5 +- .../_layout/_authenticated/admin/settings.tsx | 28 +- .../_authenticated/admin/work-orders/$id.tsx | 224 +++--- .../admin/work-orders/index.tsx | 2 +- .../routes/_layout/_authenticated/profile.tsx | 2 + .../_authenticated/work-orders/$id.tsx | 104 ++- ui/tests/home-work-orders.test.ts | 35 + ui/tests/landing.test.ts | 8 +- ui/tests/navigation.test.ts | 19 +- ui/tests/setup-status.test.ts | 37 +- ui/tests/work-order-defaults.test.ts | 50 ++ ui/tests/work-order-draft.test.ts | 89 +++ 68 files changed, 2415 insertions(+), 732 deletions(-) create mode 100644 ui/src/components/home/home-work-order-lists.tsx create mode 100644 ui/src/components/home/home-work-orders.ts delete mode 100644 ui/src/components/work-orders/duplicate-work-order-button.tsx create mode 100644 ui/src/components/work-orders/github-repo-picker.tsx create mode 100644 ui/src/components/work-orders/quick-create-builder.tsx create mode 100644 ui/src/components/work-orders/quick-create-project.tsx create mode 100644 ui/src/components/work-orders/work-order-key-facts.tsx create mode 100644 ui/src/lib/work-order-draft.ts create mode 100644 ui/tests/home-work-orders.test.ts create mode 100644 ui/tests/work-order-draft.test.ts diff --git a/api/src/db/index.ts b/api/src/db/index.ts index 67d3fa7e..46269408 100644 --- a/api/src/db/index.ts +++ b/api/src/db/index.ts @@ -47,6 +47,7 @@ export async function createDatabaseDriver(url: string): Promise mkdirSync(dirname(dataDir), { recursive: true }); } const pglite = new PGlite(dataDir); + await pglite.exec("SET TIME ZONE 'UTC'"); const db = drizzle(pglite, { schema }); return { db, @@ -71,6 +72,11 @@ export async function createDatabaseDriver(url: string): Promise pool.on("error", (err: Error) => { console.error("[Database] Unexpected pool error:", err.message); }); + pool.on("connect", (client) => { + client.query("SET TIME ZONE 'UTC'").catch((err: Error) => { + console.error("[Database] Couldn't set the session time zone to UTC:", err.message); + }); + }); let closed = false; return { db: drizzle(pool, { schema }), diff --git a/api/src/index.ts b/api/src/index.ts index 44ce0015..e472bd9f 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -1184,6 +1184,14 @@ export default createPlugin.withPlugins()({ .use(agencyManager) .handler(async ({ context }) => workOrders.nextCode(context.scope)), + githubRepos: builder.workOrders.githubRepos + .use(agencyManager) + .handler(async ({ context, input }) => workOrders.githubRepos(context.scope, input)), + + builderDefaults: builder.workOrders.builderDefaults + .use(agencyManager) + .handler(async ({ context, input }) => workOrders.builderDefaults(context.scope, input)), + offer: builder.workOrders.offer .use(agencyManager) .handler(async ({ context, input }) => workOrders.offer(context.scope, input)), diff --git a/api/src/services/work-orders/github.ts b/api/src/services/work-orders/github.ts index 3e2e0bf7..7919b2c4 100644 --- a/api/src/services/work-orders/github.ts +++ b/api/src/services/work-orders/github.ts @@ -72,6 +72,13 @@ export type GhReviewBy = { submittedAt: string; }; +export type GhRepo = { + fullName: string; + url: string; + description: string | null; + pushedAt: string | null; +}; + export interface GitHubPort { getPull(repo: string, number: number): Promise; getClosingIssues(repo: string, number: number): Promise; @@ -82,6 +89,7 @@ export interface GitHubPort { getAssignmentEvents(repo: string, number: number): Promise; listPullsByAuthor(repo: string, login: string, since: string): Promise; listReviewsBy(repo: string, login: string, since: string): Promise; + listOrgRepos(org: string): Promise; } export class GitHubError extends Error { @@ -137,6 +145,15 @@ type RestSearchItem = { user?: { login: string } | null; }; +type RestRepo = { + full_name: string; + html_url: string; + description: string | null; + pushed_at: string | null; + archived?: boolean; + private?: boolean; +}; + type RestReview = { user: { login: string } | null; state: string; submitted_at?: string | null }; type ClosingData = { @@ -374,6 +391,33 @@ export function createGitHub(token: string | null, fetcher: typeof fetch = fetch } return out; }, + + async listOrgRepos(org) { + const repos = await pages( + `/orgs/${encodeURIComponent(org)}/repos?type=public&sort=pushed`, + ); + return repos + .filter((r) => !r.archived && !r.private) + .map((r) => ({ + fullName: r.full_name, + url: r.html_url, + description: r.description, + pushedAt: r.pushed_at, + })); + }, + }; +} + +export function cachedOrgRepos(github: GitHubPort, ttlMs: number, now: () => number = Date.now) { + const cache = new Map }>(); + return (org: string): Promise => { + const key = org.toLowerCase(); + const hit = cache.get(key); + if (hit && hit.until > now()) return hit.value; + const value = github.listOrgRepos(org); + cache.set(key, { until: now() + ttlMs, value }); + value.catch(() => cache.delete(key)); + return value; }; } diff --git a/api/src/services/work-orders/progress.ts b/api/src/services/work-orders/progress.ts index 2db24a5e..83aa858b 100644 --- a/api/src/services/work-orders/progress.ts +++ b/api/src/services/work-orders/progress.ts @@ -372,7 +372,8 @@ export function progressOf(input: ProgressInput): Progress { }); } } - if (input.payeeNeedsConfirmation && !terminated && input.status !== "draft") { + const blockersShown = input.status === "accepted" && input.blockers.length > 0; + if (input.payeeNeedsConfirmation && !blockersShown && (input.status === "accepted" || started)) { attention.push({ key: "payee", message: `${builder} hasn't confirmed their payout account yet`, diff --git a/api/src/services/work-orders/service.ts b/api/src/services/work-orders/service.ts index 84668179..232a4669 100644 --- a/api/src/services/work-orders/service.ts +++ b/api/src/services/work-orders/service.ts @@ -42,7 +42,7 @@ import { } from "./documents"; import { eventId, type WorkOrderEvent, type WorkOrderEventType } from "./events"; import { collectEvidence, summarizeActivity } from "./evidence"; -import type { GhRepoOwnership, GitHubPort } from "./github"; +import { cachedOrgRepos, type GhRepoOwnership, GitHubError, type GitHubPort } from "./github"; import type { DriveFile, GooglePort } from "./google"; import { type PriceFeed, type PriceQuote, USD, USD_DECIMALS, usdToTokens } from "./prices"; import { type Progress, progressOf } from "./progress"; @@ -225,6 +225,7 @@ const TICK_LOCK_MS = 2 * 60_000; const SIGNATURE_LOCK_MS = 2 * 60_000; const AUTO_PAY_RETRY_MS = 5 * 60_000; const NO_AGENT = "No payout agent"; +const UNKNOWN_PROJECT = "Unknown project"; const IN_FLIGHT: AgentPayoutView["status"][] = [ "sending", @@ -398,6 +399,7 @@ const sum = (amounts: (string | null)[]) => export function createWorkOrdersService(deps: WorkOrderDeps) { const { db, directory } = deps; + const orgRepos = cachedOrgRepos(deps.github, 10 * 60_000); const now = () => deps.now?.() ?? new Date(); const timeZone = deps.timeZone ?? UTC; const today = () => dateOf(now(), timeZone); @@ -522,7 +524,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { workOrder: { id: row.code, title: row.title, - project: project?.title ?? row.projectId, + project: project?.title ?? UNKNOWN_PROJECT, periodStart: row.periodStart, periodEnd: row.periodEnd, budget: display(row.budgetAmount, row.tokenId), @@ -659,7 +661,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { const contacts = await contactsOf(row, builder); const fields = documentFields({ code: row.code, - project: project?.title ?? row.projectId, + project: documentProjectTitle(row, project), contractorName: row.contractorName ?? builder?.name ?? row.builderNearAccount, contractorEmail: contacts.contractorEmail, representativeName: row.representativeName ?? agencyName, @@ -862,7 +864,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { return [ ["Work order", `${row.code}: ${row.title}`], ["Agency", agencyName], - ["Project", project?.title ?? row.projectId], + ["Project", documentProjectTitle(row, project)], ["Builder (contractor)", row.contractorName ?? builder?.name ?? row.builderNearAccount], ["Builder's email", contacts.contractorEmail ?? ""], ["Agency representative", row.representativeName ?? agencyName], @@ -1108,7 +1110,15 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { return false; } - async function payeeOf(row: WorkOrderRow) { + function paidBeforeOnce(row: WorkOrderRow, paidCache?: Map>) { + if (!paidCache) return paidBefore(row); + const key = `${row.organizationId}:${row.builderNearAccount}`; + const cached = paidCache.get(key) ?? paidBefore(row); + paidCache.set(key, cached); + return cached; + } + + async function payeeOf(row: WorkOrderRow, paidCache?: Map>) { if (row.payeeConfirmedAt) { return { account: row.builderNearAccount, @@ -1117,7 +1127,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { needsConfirmation: false, }; } - const known = EDITABLE.includes(row.status) ? await paidBefore(row) : null; + const known = EDITABLE.includes(row.status) ? await paidBeforeOnce(row, paidCache) : null; return { account: row.builderNearAccount, confirmedAt: null, @@ -1235,6 +1245,14 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { return { committed, paid, remaining }; } + function documentProjectTitle(row: WorkOrderRow, project: { title: string } | null): string { + if (project) return project.title; + throw badRequest( + "PROJECT_UNAVAILABLE", + `Couldn't load the project of ${row.code}, so its document wasn't made. Try again in a moment.`, + ); + } + async function projectOf( row: Pick & Partial>, @@ -1286,9 +1304,18 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { } async function signingOf(row: WorkOrderRow) { - const esign = esignOf(row); const offered = await latestDocument(row.id, "offered"); - if (!offered || offered.termsHash !== termsHash(row)) { + const current = offered && offered.termsHash === termsHash(row) ? offered : null; + return signingFrom(row, current, current ? await signaturesOf(row.id, current.sha256) : []); + } + + function signingFrom( + row: WorkOrderRow, + offered: { sha256: string } | null, + signatures: Pick[], + ) { + const esign = esignOf(row); + if (!offered) { return { documentSha256: null, builder: null, @@ -1298,7 +1325,6 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { esign, }; } - const signatures = await signaturesOf(row.id, offered.sha256); const signer = (role: "contractor" | "representative") => { const found = signatures.find((s) => s.role === role); return found ? { name: found.name, signedAt: found.signedAt.toISOString() } : null; @@ -1398,12 +1424,18 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { } } - async function cancelEnvelope(row: WorkOrderRow, reason: string) { + async function cancelEnvelope(row: WorkOrderRow, reason: string, strict = false) { if (!deps.esign || !row.esignEnvelopeId || row.signedAt) return; try { await deps.esign.cancel(row.esignEnvelopeId, reason); } catch (error) { console.warn(`[API] couldn't cancel the Documenso envelope of ${row.code}`, error); + if (strict) { + throw badRequest( + "ESIGN_CANCEL_FAILED", + `Couldn't withdraw ${row.code} in Documenso, so it wasn't deleted: ${errorText(error)}`, + ); + } } } @@ -1740,14 +1772,92 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { }; } - async function view(row: WorkOrderRow, cache?: { projects?: Map }) { + type ViewCache = { + projects?: Map; + requests?: Map; + builders?: Map>; + paid?: Map>; + signing?: Map>; + }; + + async function viewCacheOf(rows: WorkOrderRow[], projects: Map) { + const ids = rows.map((r) => r.id); + const [requestRows, offeredRows, signatureRows] = ids.length + ? await Promise.all([ + db + .select() + .from(compensationRequests) + .where(inArray(compensationRequests.workOrderId, ids)) + .orderBy(asc(compensationRequests.seq)), + db + .select({ + workOrderId: workOrderDocuments.workOrderId, + termsHash: workOrderDocuments.termsHash, + sha256: workOrderDocuments.sha256, + }) + .from(workOrderDocuments) + .where( + and( + inArray(workOrderDocuments.workOrderId, ids), + eq(workOrderDocuments.kind, "offered"), + ), + ) + .orderBy(desc(workOrderDocuments.createdAt)), + db + .select({ + workOrderId: workOrderSignatures.workOrderId, + documentSha256: workOrderSignatures.documentSha256, + role: workOrderSignatures.role, + name: workOrderSignatures.name, + signedAt: workOrderSignatures.signedAt, + }) + .from(workOrderSignatures) + .where(inArray(workOrderSignatures.workOrderId, ids)), + ]) + : [[], [], []]; + const requests = new Map(ids.map((id) => [id, []])); + for (const r of requestRows) requests.get(r.workOrderId)?.push(r); + const latestOffered = new Map(); + for (const doc of offeredRows) { + if (!latestOffered.has(doc.workOrderId)) latestOffered.set(doc.workOrderId, doc); + } + const signing = new Map( + rows.map((row) => { + const offered = latestOffered.get(row.id); + const current = offered && offered.termsHash === termsHash(row) ? offered : null; + return [ + row.id, + signingFrom( + row, + current, + current + ? signatureRows.filter( + (s) => s.workOrderId === row.id && s.documentSha256 === current.sha256, + ) + : [], + ), + ] as const; + }), + ); + const builders = new Map>(); + for (const account of new Set(rows.map((r) => r.builderNearAccount))) { + builders.set( + account, + deps.builders.get(account).catch(() => null), + ); + } + return { projects, requests, builders, paid: new Map(), signing } satisfies ViewCache; + } + + async function view(row: WorkOrderRow, cache?: ViewCache) { const [requests, builder] = await Promise.all([ - requestsOf(row.id), - deps.builders.get(row.builderNearAccount).catch(() => null), + cache?.requests?.get(row.id) ?? requestsOf(row.id), + cache?.builders?.get(row.builderNearAccount) ?? + deps.builders.get(row.builderNearAccount).catch(() => null), ]); const project = cache?.projects?.get(row.projectId) ?? (await projectOf(row)); - const payee = await payeeOf(row); - const signing = await signingOf(row); + const payee = await payeeOf(row, cache?.paid); + const signing = cache?.signing?.get(row.id) ?? (await signingOf(row)); const { committed, paid, remaining } = amountsOf(row, requests); const renewal = parseJson(row.renewalAssessment, null); const blockers = activationBlockers( @@ -1805,7 +1915,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { code: row.code, organizationId: row.organizationId, projectId: row.projectId, - projectTitle: project?.title ?? row.projectId, + projectTitle: project?.title ?? UNKNOWN_PROJECT, projectSlug: project?.slug ?? null, builder: { nearAccount: row.builderNearAccount, @@ -2008,7 +2118,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { ]); return { code: row.code, - projectTitle: project?.title ?? row.projectId, + projectTitle: project?.title ?? UNKNOWN_PROJECT, agencyName, builderName: builder?.name ?? row.builderNearAccount, budget: display(row.budgetAmount, row.tokenId), @@ -2327,7 +2437,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { ? await deps.summarizer .draftReport({ workOrderCode: row.code, - project: project?.title ?? row.projectId, + project: project?.title ?? UNKNOWN_PROJECT, contractorGithub: row.githubLogin ?? "", periodStart: coversFrom, periodEnd: coversTo, @@ -2350,6 +2460,8 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { summary: draft?.summary ?? null, contributionsMd, assessmentMd, + createdAt: now(), + updatedAt: now(), }) .returning(); if (auto) await update(row.id, { autoDraftedFor: row.periodEnd }); @@ -2794,7 +2906,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { const description = proposalDescription({ title: `${row.code}-R${request.seq}: ${row.title}`, notes: [ - `Project: ${project?.title ?? row.projectId}`, + `Project: ${project?.title ?? UNKNOWN_PROJECT}`, `Covers ${request.coversFrom} to ${request.coversTo}`, payout.priceUsd ? `${display(request.approvedAmount ?? "0", row.tokenId)} at ${payout.priceUsd} USD per ${symbolOf(payout.tokenId)}` @@ -2954,6 +3066,8 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { code, organizationId: parent.organizationId, projectId: parent.projectId, + projectTitle: parent.projectTitle, + projectSlug: parent.projectSlug, builderNearAccount: parent.builderNearAccount, githubLogin: parent.githubLogin, title: parent.title, @@ -2979,6 +3093,8 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { renewalRoot: root, renewalSeq: seq, createdBy: "renewal", + createdAt: now(), + updatedAt: now(), }) .returning(); await tx @@ -3285,8 +3401,9 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { .catch(() => []) ).map((p) => [p.id, p]), ); + const cache = await viewCacheOf(rows, projects); return { - data: await Promise.all(rows.map(async (row) => adminView(await view(row, { projects })))), + data: await Promise.all(rows.map(async (row) => adminView(await view(row, cache)))), }; } @@ -3331,6 +3448,56 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { nextCode: async (scope: OrganizationScope) => ({ code: await nextCode(scope.organizationId) }), + githubRepos: async (_scope: OrganizationScope, input: { org: string }) => { + try { + return { org: input.org, repos: await orgRepos(input.org) }; + } catch (error) { + if (error instanceof GitHubError && error.status === 404) { + throw badRequest("GITHUB_ORG_NOT_FOUND", `GitHub has no organization "${input.org}"`); + } + throw badRequest( + "GITHUB_FAILED", + `Couldn't load repositories from GitHub: ${errorText(error)}`, + ); + } + }, + + builderDefaults: async (scope: OrganizationScope, input: { nearAccount: string }) => { + const nearAccount = input.nearAccount.trim(); + const latestOf = ( + column: typeof workOrders.contractorEmail | typeof workOrders.githubLogin, + ) => + db + .select({ value: column }) + .from(workOrders) + .where( + and( + eq(workOrders.organizationId, scope.organizationId), + eq(workOrders.builderNearAccount, nearAccount), + isNotNull(column), + ), + ) + .orderBy(desc(workOrders.createdAt)) + .limit(1) + .then(([row]) => row?.value ?? null); + const [builder, remembered, previousEmail, previousLogin] = await Promise.all([ + nearAccount ? deps.builders.get(nearAccount).catch(() => null) : null, + nearAccount ? rememberedLogin(db, scope.organizationId, nearAccount) : null, + latestOf(workOrders.contractorEmail), + latestOf(workOrders.githubLogin), + ]); + const accountEmail = + builder?.userId && deps.userEmail + ? !!(await deps.userEmail(builder.userId).catch(() => null)) + : false; + return { + githubLogin: builder?.githubLogin?.toLowerCase() ?? remembered ?? previousLogin, + contractorEmail: previousEmail, + accountEmail, + needsEmails: !!deps.esign, + }; + }, + create: async (scope: OrganizationScope, input: WorkOrderFields) => { const code = input.code?.trim() || (await nextCode(scope.organizationId)); const builder = await deps.builders.get(input.builderNearAccount); @@ -3382,6 +3549,8 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { customFields: customFieldsText(fields.customFields), status: "draft", createdBy: scope.actorId, + createdAt: now(), + updatedAt: now(), }) .returning(); await rememberBuilderLogin( @@ -3689,8 +3858,21 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { "Only a draft or offered work order can be deleted; terminate it instead", ); } - await cancelEnvelope(row, "The work order was withdrawn"); - await db.delete(workOrders).where(eq(workOrders.id, row.id)); + const current = await syncEnvelope(row); + if (current.status !== "draft" && current.status !== "offered") { + throw badRequest( + "ALREADY_SIGNED", + `The builder has already signed ${row.code}; terminate it instead`, + ); + } + if (current.esignEnvelopeId && current.esignError) { + throw badRequest( + "ESIGN_UNCHECKED", + `${current.esignError}. ${row.code} wasn't deleted; try again in a moment.`, + ); + } + await cancelEnvelope(current, "The work order was withdrawn", true); + await db.delete(workOrders).where(eq(workOrders.id, current.id)); return { deleted: true as const }; }); }, @@ -4387,7 +4569,7 @@ export function createWorkOrdersService(deps: WorkOrderDeps) { const frozen = renderReport({ requestId: `${row.code}-R${request.seq}`, workOrderCode: row.code, - projectTitle: project?.title ?? row.projectId, + projectTitle: project?.title ?? UNKNOWN_PROJECT, contractor: builder?.name ?? row.builderNearAccount, github: row.githubLogin ?? "", coversFrom: request.coversFrom, diff --git a/api/src/services/work-orders/setup-status.ts b/api/src/services/work-orders/setup-status.ts index b0ba1742..ee192ebc 100644 --- a/api/src/services/work-orders/setup-status.ts +++ b/api/src/services/work-orders/setup-status.ts @@ -84,7 +84,7 @@ export function createSetupStatus(deps: SetupStatusDeps) { return missing( name ? `“${name}” looks like a placeholder; builders see it in every email and document` - : "Your agency has no name; builders see it in every email and document", + : "No name yet; builders see it in every email and document", ); } @@ -104,9 +104,7 @@ export function createSetupStatus(deps: SetupStatusDeps) { async function email(organizationId: string): Promise { if (!deps.emailEnabled) { - return missing( - "Emails are off, so builders and your representative only get notifications in MultiAgency", - ); + return missing("Emails are off on this server. Ask your server admin to turn them on."); } const { email } = await deps.representative(organizationId); return ok( @@ -119,7 +117,7 @@ export function createSetupStatus(deps: SetupStatusDeps) { async function signing(): Promise { return deps.esignEnabled ? ok("Builders sign the work order document") - : warning("No e-signature: builders accept the terms in MultiAgency"); + : warning("E-signature is off on this server. Builders accept the terms in MultiAgency."); } async function google(organizationId: string): Promise { @@ -138,14 +136,14 @@ export function createSetupStatus(deps: SetupStatusDeps) { ].filter((item): item is string => !!item); if (!linked) { return lacking.length === 3 - ? optional("Connect Google Drive to fill your own work order template") - : warning("Connect Google Drive to use the template you chose"); + ? optional("Connect Drive to use your own template") + : warning("Connect Drive to use the template you chose"); } const via = connection.connected ? `Connected${connection.email ? ` as ${connection.email}` : ""}` : "Using the server's Google account"; if (lacking.length === 3) { - return optional(`${via}; choose a template and folders to use your own document`); + return optional(`${via}; choose a template and folders`); } if (lacking.length > 0) return warning(`${via}; choose ${listOf(lacking)}`); if (settings?.googleLastError) return warning(`Google Drive: ${settings.googleLastError}`); @@ -162,16 +160,14 @@ export function createSetupStatus(deps: SetupStatusDeps) { const agent = async (): Promise => { const current = await viewOnce(); if (!current.available) { - return optional( - "Not available on this server: approved requests are paid through DAO proposals", - ); + return optional("Not on this server; requests are paid by DAO proposal"); } if (!current.agentId) { - return optional("No payout agent: approved requests are paid through DAO proposals"); + return optional("None; requests are paid by DAO proposal"); } if (current.error) return warning(`Couldn't check: ${current.error}`); if (!current.ready) return warning(current.blockers.join(". ")); - return ok("The agent's rules are applied and MultiAgency can pay approved requests"); + return ok("Rules applied; can pay approved requests"); }; const funding = async (): Promise => { @@ -196,7 +192,7 @@ export function createSetupStatus(deps: SetupStatusDeps) { } if (check.budgetTooLow) { problems.push( - `The $${check.monthlyBudgetUsd} monthly payout limit is below the ≈$${check.monthlyNeededUsd} active work orders need`, + `$${check.monthlyBudgetUsd} monthly limit is below the ≈$${check.monthlyNeededUsd} active work orders need`, ); } else if ( check.monthlyRemainingUsd !== null && @@ -210,7 +206,7 @@ export function createSetupStatus(deps: SetupStatusDeps) { if (problems.length > 0) return warning(problems.join(". ")); return ok( needed === 0n - ? `Holds ${check.balanceNear} NEAR; no unpaid work order budgets yet` + ? `Holds ${check.balanceNear} NEAR; no unpaid budgets yet` : `Holds ${check.balanceNear} NEAR, enough for the ≈${check.neededNear} NEAR active work orders need`, ); }; diff --git a/api/src/work-orders-contract.ts b/api/src/work-orders-contract.ts index 09948a7e..452fa8ca 100644 --- a/api/src/work-orders-contract.ts +++ b/api/src/work-orders-contract.ts @@ -623,6 +623,43 @@ export const workOrdersContract = { .output(z.object({ code: z.string() })) .errors({ UNAUTHORIZED, FORBIDDEN }), + githubRepos: oc + .route({ method: "GET", path: "/admin/work-order-github-repos" }) + .input( + z.object({ + org: z + .string() + .regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/, "Enter a GitHub organization name"), + }), + ) + .output( + z.object({ + org: z.string(), + repos: z.array( + z.object({ + fullName: z.string(), + url: z.string(), + description: z.string().nullable(), + pushedAt: z.string().nullable(), + }), + ), + }), + ) + .errors(errors), + + builderDefaults: oc + .route({ method: "GET", path: "/admin/work-order-builder-defaults" }) + .input(z.object({ nearAccount: z.string() })) + .output( + z.object({ + githubLogin: z.string().nullable(), + contractorEmail: z.string().nullable(), + accountEmail: z.boolean(), + needsEmails: z.boolean(), + }), + ) + .errors({ UNAUTHORIZED, FORBIDDEN }), + offer: oc .route({ method: "POST", path: "/admin/work-orders/{id}/offer" }) .input(byId) diff --git a/api/tests/fakes/github.ts b/api/tests/fakes/github.ts index 195854a7..44cf2b9f 100644 --- a/api/tests/fakes/github.ts +++ b/api/tests/fakes/github.ts @@ -4,11 +4,13 @@ import type { GhIssueRef, GhPull, GhPullSummary, + GhRepo, GhRepoOwnership, GhReview, GhReviewBy, GitHubPort, } from "../../src/services/work-orders/github"; +import { GitHubError } from "../../src/services/work-orders/github"; type PullRec = { pull: GhPull; @@ -30,6 +32,7 @@ type RepoRec = { export class FakeGitHub implements GitHubPort { repos = new Map(); + orgs = new Map(); calls: string[] = []; private failures = new Map(); @@ -126,6 +129,13 @@ export class FakeGitHub implements GitHubPort { this.failures.set(method, list); } + async listOrgRepos(org: string): Promise { + this.enter("listOrgRepos"); + const repos = this.orgs.get(org.toLowerCase()); + if (!repos) throw new GitHubError(404, `GitHub GET /orgs/${org}/repos → 404`); + return structuredClone(repos); + } + private enter(method: string) { this.calls.push(method); const error = this.failures.get(method)?.shift(); diff --git a/api/tests/integration/work-order-setup.test.ts b/api/tests/integration/work-order-setup.test.ts index ded20580..c069258b 100644 --- a/api/tests/integration/work-order-setup.test.ts +++ b/api/tests/integration/work-order-setup.test.ts @@ -133,7 +133,9 @@ describe("work order setup status", () => { detail: "Add the default representative's name and email", fixHref: "/admin/settings#work-orders", }); - expect(check.signing.detail).toBe("No e-signature: builders accept the terms in MultiAgency"); + expect(check.signing.detail).toBe( + "E-signature is off on this server. Builders accept the terms in MultiAgency.", + ); expect(check.google.fixHref).toBe("/admin/settings#work-orders"); expect(check.payouts_agent.fixHref).toBe("/admin/settings#payouts"); }); diff --git a/api/tests/integration/work-orders.test.ts b/api/tests/integration/work-orders.test.ts index 88975afc..e811c7c1 100644 --- a/api/tests/integration/work-orders.test.ts +++ b/api/tests/integration/work-orders.test.ts @@ -7,6 +7,7 @@ import { billings, compensationRequests, notifications, + organizationBuilderLogins, workOrderDocuments, workOrderDriveFiles, workOrderIntegrations, @@ -228,6 +229,23 @@ describe("work orders", () => { expect((await service.create(await studio(), fields())).code).toBe("WO-0002"); }); + test("timestamps follow the service clock, so a new draft is waiting since now", async () => { + const created = await service.create(await studio(), fields()); + expect(created.createdAt).toBe("2026-09-20T12:00:00.000Z"); + expect(created.progress.since).toBe("2026-09-20T12:00:00.000Z"); + }); + + test("a project that can't be found shows as unknown, never as its id", async () => { + const created = await service.create(await studio(), fields()); + await db + .update(workOrders) + .set({ projectId: "proj_gone", projectTitle: null, projectSlug: null }) + .where(eq(workOrders.id, created.id)); + const view = await service.get(await studio(), { id: created.id }); + expect(view.projectId).toBe("proj_gone"); + expect(view.projectTitle).toBe("Unknown project"); + }); + test("draft → offered → accepted by the builder's own account → active", async () => { const created = await service.create(await studio(), fields()); expect(created.code).toBe("WO-0001"); @@ -801,6 +819,8 @@ describe("work orders", () => { signature: { method: null }, }); expect(child.signing.builder).toBeNull(); + const [childRow] = await db.select().from(workOrders).where(eq(workOrders.id, child.id)); + expect(childRow?.projectTitle).toBe(parent.projectTitle); expect( (await service.get(await studio(), { id: parent.id })).requests.map((r) => r.status), ).toEqual(["draft"]); @@ -1506,6 +1526,33 @@ describe("work orders", () => { expect(documenso.cancelled.at(-1)?.envelopeId).toBe("envelope_3"); }); + test("an offered work order the builder already signed in Documenso can't be deleted", async () => { + const created = await offered(); + documenso.sign("representative", "2026-09-20T13:00:00.000Z"); + documenso.sign("contractor", "2026-09-20T14:00:00.000Z"); + await refused(service.remove(await studio(), { id: created.id }), "ALREADY_SIGNED"); + expect(documenso.cancelled).toEqual([]); + expect((await service.get(await studio(), { id: created.id })).status).not.toBe("offered"); + }); + + test("deleting stops when Documenso can't be checked or the envelope can't be cancelled", async () => { + const created = await offered(); + const status = documenso.status.bind(documenso); + documenso.status = async () => { + throw new Error("Documenso is down"); + }; + await refused(service.remove(await studio(), { id: created.id }), "ESIGN_UNCHECKED"); + documenso.status = status; + const cancel = documenso.cancel.bind(documenso); + documenso.cancel = async () => { + throw new Error("timeout"); + }; + await refused(service.remove(await studio(), { id: created.id }), "ESIGN_CANCEL_FAILED"); + documenso.cancel = cancel; + expect((await service.get(await studio(), { id: created.id })).status).toBe("offered"); + await service.remove(await studio(), { id: created.id }); + }); + async function completed() { const created = await offered(); documenso.sign("representative", "2026-09-20T13:00:00.000Z"); @@ -2996,4 +3043,124 @@ describe("work orders", () => { } }); }); + + describe("importing repositories from GitHub", () => { + test("lists an organization's public repositories and caches them", async () => { + github.orgs.set("nearbuilders", [ + { + fullName: "NEARBuilders/citynode.app", + url: "https://github.com/NEARBuilders/citynode.app", + description: "City node", + pushedAt: "2026-10-01T00:00:00Z", + }, + ]); + const listed = await service.githubRepos(await studio(), { org: "NEARBuilders" }); + expect(listed.repos.map((r) => r.fullName)).toEqual(["NEARBuilders/citynode.app"]); + await service.githubRepos(await studio(), { org: "nearbuilders" }); + expect(github.calls.filter((c) => c === "listOrgRepos")).toHaveLength(1); + }); + + test("an unknown organization or a GitHub outage is a clear error", async () => { + await refused(service.githubRepos(await studio(), { org: "nobody" }), "GITHUB_ORG_NOT_FOUND"); + github.orgs.set("down", []); + github.failNext("listOrgRepos", new Error("timeout")); + await refused(service.githubRepos(await studio(), { org: "down" }), "GITHUB_FAILED"); + }); + }); + + describe("builder defaults for a new work order", () => { + const rival = () => world.manager("rival-admin", "rival"); + + test("the profile login, a linked account email, and no email demand without e-signature", async () => { + expect(await service.builderDefaults(await studio(), { nearAccount: CAROL })).toEqual({ + githubLogin: "carol-gh", + contractorEmail: null, + accountEmail: true, + needsEmails: false, + }); + }); + + test("an unknown builder gets empty defaults, and e-signature asks for emails", async () => { + service = createWorkOrdersService({ ...serviceDeps, esign: new FakeDocumenso() }); + expect(await service.builderDefaults(await studio(), { nearAccount: "ghost.near" })).toEqual({ + githubLogin: null, + contractorEmail: null, + accountEmail: false, + needsEmails: true, + }); + }); + + test("no account email when the linked user has none", async () => { + service = createWorkOrdersService({ ...serviceDeps, userEmail: async () => null }); + const defaults = await service.builderDefaults(await studio(), { nearAccount: CAROL }); + expect(defaults.accountEmail).toBe(false); + }); + + test("the remembered login and latest email stay inside the organization", async () => { + const first = await service.create( + await studio(), + fields({ + builderNearAccount: "nogit.near", + githubLogin: "nogit-gh", + contractorEmail: "old@nogit.example", + }), + ); + clock = new Date("2026-09-21T12:00:00.000Z"); + await service.create( + await studio(), + fields({ + builderNearAccount: "nogit.near", + githubLogin: "nogit-gh", + contractorEmail: "new@nogit.example", + }), + ); + clock = new Date("2026-09-22T12:00:00.000Z"); + const leaked = await service.create( + await studio(), + fields({ + builderNearAccount: "nogit.near", + githubLogin: "nogit-gh", + contractorEmail: "rival@nogit.example", + }), + ); + await db + .update(workOrders) + .set({ organizationId: "rival", githubLogin: "rival-gh" }) + .where(eq(workOrders.id, leaked.id)); + await db.update(workOrders).set({ contractorEmail: null }).where(eq(workOrders.id, first.id)); + + expect(await service.builderDefaults(await studio(), { nearAccount: "nogit.near" })).toEqual({ + githubLogin: "nogit-gh", + contractorEmail: "new@nogit.example", + accountEmail: true, + needsEmails: false, + }); + expect(await service.builderDefaults(await rival(), { nearAccount: "nogit.near" })).toEqual({ + githubLogin: "rival-gh", + contractorEmail: "rival@nogit.example", + accountEmail: true, + needsEmails: false, + }); + }); + + test("the latest login on the organization's work orders fills in when none is remembered", async () => { + await service.create( + await studio(), + fields({ builderNearAccount: "nogit.near", githubLogin: "old-gh" }), + ); + clock = new Date("2026-09-21T12:00:00.000Z"); + await service.create( + await studio(), + fields({ builderNearAccount: "nogit.near", githubLogin: "new-gh" }), + ); + await db.delete(organizationBuilderLogins); + + expect( + (await service.builderDefaults(await studio(), { nearAccount: "nogit.near" })).githubLogin, + ).toBe("new-gh"); + expect( + (await service.builderDefaults(await rival(), { nearAccount: "nogit.near" })).githubLogin, + ).toBeNull(); + }); + }); }); diff --git a/api/tests/unit/progress.test.ts b/api/tests/unit/progress.test.ts index efd8cc54..2e0708e4 100644 --- a/api/tests/unit/progress.test.ts +++ b/api/tests/unit/progress.test.ts @@ -301,4 +301,46 @@ describe("work order progress", () => { }); expect(p.attention.map((a) => a.key)).toEqual(["esign", "payee", "drive"]); }); + + test("an unconfirmed payout account only needs attention once the builder has signed", () => { + const payeeKeys = (input: Partial) => + progressOf({ ...base, payeeNeedsConfirmation: true, ...input }).attention.map((a) => a.key); + expect(payeeKeys({})).toEqual([]); + expect( + payeeKeys({ + status: "offered", + offeredAt: "2026-10-26T10:01:00.000Z", + awaitingRepresentative: true, + }), + ).toEqual([]); + expect( + payeeKeys({ + status: "offered", + offeredAt: "2026-10-26T10:01:00.000Z", + representativeSignedAt: "x", + }), + ).toEqual([]); + expect( + payeeKeys({ + status: "accepted", + acceptedAt: "2026-10-26T10:05:00.000Z", + builderSignedAt: "a", + representativeSignedAt: "b", + }), + ).toEqual(["payee"]); + expect(payeeKeys(signedActive)).toEqual(["payee"]); + expect( + payeeKeys({ + status: "accepted", + acceptedAt: "2026-10-26T10:05:00.000Z", + builderSignedAt: "a", + representativeSignedAt: "b", + blockers: [ + "Add the builder's GitHub login", + "The builder hasn't confirmed the payout account", + ], + }), + ).toEqual(["blocker-1"]); + expect(payeeKeys({ ...signedActive, status: "terminated" })).toEqual([]); + }); }); diff --git a/ui/src/components/admin-sidebar.tsx b/ui/src/components/admin-sidebar.tsx index a8da52b7..ebc64c48 100644 --- a/ui/src/components/admin-sidebar.tsx +++ b/ui/src/components/admin-sidebar.tsx @@ -11,7 +11,7 @@ import { workspaceNavigation, } from "@/lib/navigation"; -const WORK_ORDERS = "/admin/work-orders"; +const NEEDS_YOU_BADGE = new Set(["/admin", "/admin/work-orders"]); const VIEW_LABELS: Record = { agency: "Agency", client: "Client" }; @@ -35,7 +35,7 @@ export function AdminSidebar() { const matching = groups .flatMap((group) => group.items) - .filter((item) => !!matchRoute({ to: item.match ?? item.to, fuzzy: true })); + .filter((item) => !!matchRoute({ to: item.match ?? item.to, fuzzy: !item.exact })); const activeTo = matching.reduce( (best, item) => (best === null || item.to.length > best.length ? item.to : best), null, @@ -89,7 +89,7 @@ export function AdminSidebar() { > {item.label} - {item.to === WORK_ORDERS && } + {NEEDS_YOU_BADGE.has(item.to) && } ); diff --git a/ui/src/components/admin/applications-section.tsx b/ui/src/components/admin/applications-section.tsx index 091ddbbc..69bd157d 100644 --- a/ui/src/components/admin/applications-section.tsx +++ b/ui/src/components/admin/applications-section.tsx @@ -128,9 +128,7 @@ export function ApplicationsAdminSection() {

Applications

- - Founder, builder and client applications from the public site. - + From the public site. {apps.length} @@ -233,7 +231,7 @@ function ApplicationActions({ application }: { application: Application }) { await refreshAfter(queryClient, { type: "applications" }); toast.success("Status updated"); }, - onError: (err: Error) => toast.error(err.message || "Failed to update status"), + onError: (err: Error) => toast.error(err.message || "Couldn't update the status"), }); const convertMutation = useMutation({ @@ -242,7 +240,7 @@ function ApplicationActions({ application }: { application: Application }) { await refreshAfter(queryClient, { type: "applications" }, { type: "builders" }); toast.success("Converted to builder"); }, - onError: (err: Error) => toast.error(err.message || "Failed to convert"), + onError: (err: Error) => toast.error(err.message || "Couldn't convert"), }); return ( diff --git a/ui/src/components/admin/contributors-section.tsx b/ui/src/components/admin/contributors-section.tsx index f8ce4d47..64cacdf9 100644 --- a/ui/src/components/admin/contributors-section.tsx +++ b/ui/src/components/admin/contributors-section.tsx @@ -175,9 +175,7 @@ export function ContributorsAdminSection() {

Builder directory

- - Profiles are shared across Agencies and keyed by NEAR account. - + Shared across Agencies. {!creating && ( {!importing && ( @@ -237,7 +235,7 @@ function ContributorCreateForm({ onDone }: { onDone: () => void }) { toast.success("Builder created"); onDone(); }, - onError: (err: Error) => toast.error(err.message || "Failed to create builder"), + onError: (err: Error) => toast.error(err.message || "Couldn't create the builder"), }); const isPending = createMutation.isPending; @@ -380,7 +378,7 @@ export function ContributorProfileForm({ await refreshAfter(queryClient, { type: "builders" }); toast.success("Profile saved"); }, - onError: (err: Error) => toast.error(err.message || "Failed to save profile"), + onError: (err: Error) => toast.error(err.message || "Couldn't save"), }); const isPending = saveMutation.isPending; @@ -502,7 +500,7 @@ export function RemoveBuilderButton({ open={confirming} onOpenChange={setConfirming} title={`Remove ${name}?`} - description="Their profile is deleted from the builder directory. Billings already recorded for them are kept." + description="Their profile leaves the directory. Recorded billings are kept." confirmLabel="Remove builder" destructive onConfirm={async () => { diff --git a/ui/src/components/admin/project-form.tsx b/ui/src/components/admin/project-form.tsx index 4fedd164..f76ca0e7 100644 --- a/ui/src/components/admin/project-form.tsx +++ b/ui/src/components/admin/project-form.tsx @@ -6,7 +6,6 @@ import { Button, Card, CardContent, - CardDescription, CardFooter, CardHeader, CardTitle, @@ -129,7 +128,7 @@ export function ProjectForm({ }, onError: (err: Error) => { if (isSlugTakenError(err)) return; - toast.error(err.message || (mode === "create" ? "Failed to create" : "Failed to update")); + toast.error(err.message || "Couldn't save the project"); }, }); @@ -187,11 +186,6 @@ export function ProjectForm({

{mode === "create" ? "New project" : "Project details"}

- - {mode === "create" - ? "Projects hold the team, budget and billings for work you deliver." - : "Title, notes, repository, NEARN link, status and visibility."} -

Your projects

- - Open a project to manage its builders, budget and billings. - {!creating && ( {!importing && ( @@ -302,9 +299,7 @@ function NearnSponsorBountiesPanel({

Unlinked NEARN bounties

- - Bounties from your NEARN sponsor account that no project tracks yet. - + No project tracks these yet. diff --git a/ui/src/components/home/home-work-order-lists.tsx b/ui/src/components/home/home-work-order-lists.tsx new file mode 100644 index 00000000..6a803d69 --- /dev/null +++ b/ui/src/components/home/home-work-order-lists.tsx @@ -0,0 +1,125 @@ +import { ArrowRightIcon } from "@phosphor-icons/react"; +import { Link } from "@tanstack/react-router"; +import { LoadError } from "@/components/load-error"; +import { SectionHeader } from "@/components/page-header"; +import { Button } from "@/components/ui/button"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemGroup, + ItemTitle, +} from "@/components/ui/item"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { WorkOrder } from "@/components/work-orders/work-order-status"; +import { waitingPhrase } from "@/lib/work-orders-inbox"; + +function builderName(workOrder: WorkOrder): string { + return workOrder.builder.name ?? workOrder.builder.nearAccount; +} + +function ListSkeleton() { + return ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + ))} +
+ ); +} + +export function WaitingOnYouList({ + workOrders, + isLoading, + error, + onRetry, +}: { + workOrders: WorkOrder[]; + isLoading: boolean; + error: Error | null; + onRetry: () => void; +}) { + return ( +
+ + {error ? ( + + ) : isLoading ? ( + + ) : workOrders.length === 0 ? ( +

+ No work orders are waiting on you. +

+ ) : ( + + {workOrders.map((workOrder) => ( +
  • + + + + {workOrder.code} + + {builderName(workOrder)} + + + {workOrder.progress.headline} + + + + + +
  • + ))} +
    + )} +
    + ); +} + +export function InProgressList({ workOrders }: { workOrders: WorkOrder[] }) { + if (workOrders.length === 0) return null; + return ( +
    + + + All work orders + + + + } + /> + + {workOrders.map((workOrder) => ( +
  • + + + + + {workOrder.code} + + {builderName(workOrder)} + + + + + {waitingPhrase(workOrder.progress.waitingOn, builderName(workOrder)) ?? + "In progress"} + + + +
  • + ))} +
    +
    + ); +} diff --git a/ui/src/components/home/home-work-orders.ts b/ui/src/components/home/home-work-orders.ts new file mode 100644 index 00000000..add4a2eb --- /dev/null +++ b/ui/src/components/home/home-work-orders.ts @@ -0,0 +1,19 @@ +import { type InboxItem, inboxCategory, sortInbox } from "@/lib/work-orders-inbox"; + +export const IN_PROGRESS_LIMIT = 5; + +export function waitingOnYou>(items: T[]): T[] { + return sortInbox(items.filter((item) => item.progress.needsYou)); +} + +export function activeWorkOrders>( + items: T[], + limit = IN_PROGRESS_LIMIT, +): T[] { + return sortInbox( + items.filter((item) => { + const category = inboxCategory(item); + return category === "in-progress" || category === "builder"; + }), + ).slice(0, limit); +} diff --git a/ui/src/components/user-nav.tsx b/ui/src/components/user-nav.tsx index b2153b10..f8fb73dd 100644 --- a/ui/src/components/user-nav.tsx +++ b/ui/src/components/user-nav.tsx @@ -20,6 +20,7 @@ import { sessionQueryOptions } from "@/lib/auth"; import { formatCount } from "@/lib/format-count"; import { nearProfileQueryOptions } from "@/lib/near-profile"; import { meRolesQueryOptions } from "@/lib/queries"; +import { clearAllWorkOrderDrafts } from "@/lib/work-order-draft"; export function UserNav() { const queryClient = useQueryClient(); @@ -55,6 +56,7 @@ export function UserNav() { await authClient.near.disconnect().catch(() => {}); }, onSuccess: async () => { + clearAllWorkOrderDrafts(); queryClient.clear(); navigate({ to: "/", replace: true }); }, diff --git a/ui/src/components/work-orders/activity-timeline.tsx b/ui/src/components/work-orders/activity-timeline.tsx index 182be307..d526fef3 100644 --- a/ui/src/components/work-orders/activity-timeline.tsx +++ b/ui/src/components/work-orders/activity-timeline.tsx @@ -34,11 +34,7 @@ export function ActivityTimeline({ workOrder }: { workOrder: WorkOrderDetail })

    Activity

    - - {activity.isError - ? "Everything that happened to this work order. The emails couldn't be loaded." - : "Everything that happened to this work order, newest first, with the emails it sent."} - + {activity.isError && The emails couldn't be loaded.} {events.length === 0 ? ( diff --git a/ui/src/components/work-orders/builder-signing.tsx b/ui/src/components/work-orders/builder-signing.tsx index 30e8854a..b2e72f70 100644 --- a/ui/src/components/work-orders/builder-signing.tsx +++ b/ui/src/components/work-orders/builder-signing.tsx @@ -67,18 +67,18 @@ export function BuilderSigning({

    {waiting ? `Sign Work Order ${wo.code}` : "Work order document"}

    - + {agencyFirst ? `${wo.agencyName} is signing this work order first. We'll email you a link to sign it as soon as they have.` : waiting && esign - ? `${wo.agencyName} signed this work order. Sign it to accept; it starts right away. We emailed you a link to sign it; you can also open it from here. You'll be paid to ${wo.builder.nearAccount}.` + ? `${wo.agencyName} signed. Sign to accept; it starts right away. You'll be paid to ${wo.builder.nearAccount}.` : waiting - ? `${wo.agencyName} sent you this work order to sign. Read it, then sign with your full name below. You're signed in with ${wo.builder.nearAccount}, the account you'll be paid to.` + ? `Read it, then sign with your full name. You'll be paid to ${wo.builder.nearAccount}.` : complete ? `Signed by you on ${signedByBuilder.signedAt.slice(0, 10)} and by ${wo.signing.representative?.name} on ${wo.signing.representative?.signedAt.slice(0, 10)}.` : signedByBuilder ? `You signed on ${signedByBuilder.signedAt.slice(0, 10)}. Waiting for ${wo.agencyName} to countersign.` - : "The document of this work order."} + : null} diff --git a/ui/src/components/work-orders/delivered-work-section.tsx b/ui/src/components/work-orders/delivered-work-section.tsx index a165ca36..36c199f4 100644 --- a/ui/src/components/work-orders/delivered-work-section.tsx +++ b/ui/src/components/work-orders/delivered-work-section.tsx @@ -1,13 +1,5 @@ import { useState } from "react"; -import { - Badge, - Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components"; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle } from "@/components"; import { formatTokenAmount } from "@/lib/format-amount"; import { ReportMarkdown } from "./report-markdown"; @@ -71,10 +63,6 @@ export function DeliveredWorkSection({ items }: { items: DeliveredWork[] }) {

    Delivered work

    - - Work accepted for this period: the GitHub issues each builder was assigned, the pull - requests the repository owner approved or merged, and their reviews. -
      diff --git a/ui/src/components/work-orders/duplicate-work-order-button.tsx b/ui/src/components/work-orders/duplicate-work-order-button.tsx deleted file mode 100644 index b3077a93..00000000 --- a/ui/src/components/work-orders/duplicate-work-order-button.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useNavigate } from "@tanstack/react-router"; -import { useState } from "react"; -import { Button, Dialog, DialogContent } from "@/components"; -import { WorkOrderForm } from "./work-order-form"; -import type { WorkOrder } from "./work-order-status"; - -export function DuplicateWorkOrderButton({ - workOrder, - variant = "outline", - className, -}: { - workOrder: WorkOrder; - variant?: "outline" | "ghost" | "secondary"; - className?: string; -}) { - const navigate = useNavigate(); - const [open, setOpen] = useState(false); - return ( - <> - - - event.preventDefault()} - > - {open && ( - { - setOpen(false); - void navigate({ to: "/admin/work-orders/$id", params: { id: created.id } }); - }} - /> - )} - - - - ); -} diff --git a/ui/src/components/work-orders/evidence-list.tsx b/ui/src/components/work-orders/evidence-list.tsx index 0ccbe878..2590ade5 100644 --- a/ui/src/components/work-orders/evidence-list.tsx +++ b/ui/src/components/work-orders/evidence-list.tsx @@ -17,12 +17,7 @@ const STATE_LABEL: Record = { export function EvidenceList({ evidence }: { evidence: Evidence[] }) { if (evidence.length === 0) { - return ( -

      - Nothing collected yet. Evidence comes from GitHub: issues assigned to the builder, pull - requests the repository owner approved or merged, and reviews. -

      - ); + return

      Nothing collected from GitHub yet.

      ; } return (
      diff --git a/ui/src/components/work-orders/github-repo-picker.tsx b/ui/src/components/work-orders/github-repo-picker.tsx new file mode 100644 index 00000000..7fd1c0b3 --- /dev/null +++ b/ui/src/components/work-orders/github-repo-picker.tsx @@ -0,0 +1,145 @@ +import { ArrowSquareOutIcon } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; +import { type KeyboardEvent, useState } from "react"; +import { Button, Input } from "@/components"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { useApiClient } from "@/lib/api"; +import { workOrderGithubReposQueryOptions } from "@/lib/queries"; + +export const DEFAULT_GITHUB_ORGS = ["NEARBuilders", "MultiAgency"] as const; +const OTHER = "other"; + +const sameRepo = (a: string, b: string) => a.toLowerCase() === b.toLowerCase(); + +export function GithubRepoPicker({ + selected, + onToggle, + onClose, +}: { + selected: string[]; + onToggle: (repo: string, checked: boolean) => void; + onClose: () => void; +}) { + const apiClient = useApiClient(); + const [choice, setChoice] = useState(DEFAULT_GITHUB_ORGS[0]); + const [otherInput, setOtherInput] = useState(""); + const [otherOrg, setOtherOrg] = useState(""); + const [search, setSearch] = useState(""); + const org = choice === OTHER ? otherOrg : choice; + const reposQuery = useQuery(workOrderGithubReposQueryOptions(apiClient, org)); + const term = search.trim().toLowerCase(); + const repos = (reposQuery.data?.repos ?? []).filter( + (r) => + !term || + r.fullName.toLowerCase().includes(term) || + r.description?.toLowerCase().includes(term), + ); + + const applyOther = () => setOtherOrg(otherInput.trim()); + const onOtherEnter = (e: KeyboardEvent) => { + if (e.key !== "Enter") return; + e.preventDefault(); + e.stopPropagation(); + applyOther(); + }; + + return ( +
      +
      +

      Import from GitHub

      + +
      + { + if (value) setChoice(value); + }} + aria-label="GitHub organization" + > + {DEFAULT_GITHUB_ORGS.map((name) => ( + + {name} + + ))} + Other + + {choice === OTHER && ( +
      + setOtherInput(e.target.value)} + /> + +
      + )} + {org !== "" && ( + setSearch(e.target.value)} + /> + )} + {org === "" ? null : reposQuery.isPending ? ( +
      + {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
      + ) : reposQuery.error ? ( +

      {reposQuery.error.message}

      + ) : repos.length === 0 ? ( +

      No public repositories match.

      + ) : ( +
        + {repos.map((repo) => { + const id = `gh-repo-${repo.fullName}`; + const checked = selected.some((s) => sameRepo(s, repo.fullName)); + return ( +
      • + onToggle(repo.fullName, value === true)} + /> + + + + +
      • + ); + })} +
      + )} +
      + ); +} diff --git a/ui/src/components/work-orders/google-settings.tsx b/ui/src/components/work-orders/google-settings.tsx index 1ad710df..311cc1fd 100644 --- a/ui/src/components/work-orders/google-settings.tsx +++ b/ui/src/components/work-orders/google-settings.tsx @@ -114,7 +114,7 @@ export function GoogleDriveSettings({ mutationFn: () => apiClient.workOrders.copyStarterTemplate({}), onSuccess: (next) => { onUpdate(next); - toast.success("The starter template was copied to your folder and selected"); + toast.success("Starter template copied"); }, onError: (err: Error) => toast.error(err.message || "Couldn't copy the starter template"), }); @@ -148,8 +148,7 @@ export function GoogleDriveSettings({ Google Docs and Drive

      - MultiAgency fills your template in your Drive when you create a work order, and saves the - signed PDF, with its signature certificate, to the Signed folder. + Each work order is filled from your template. Signed PDFs go to the Signed folder.

      diff --git a/ui/src/components/work-orders/integration-settings.tsx b/ui/src/components/work-orders/integration-settings.tsx index 01b6d650..3b453471 100644 --- a/ui/src/components/work-orders/integration-settings.tsx +++ b/ui/src/components/work-orders/integration-settings.tsx @@ -6,7 +6,6 @@ import { Badge, Card, CardContent, - CardDescription, CardHeader, CardTitle, Table, @@ -49,10 +48,6 @@ export function WorkOrderIntegrationSettings() {

      Work orders: documents and email

      - - MultiAgency fills your Google Docs template for each work order, collects both signatures - in the dashboard and emails the builder and your representative at each step. -
      @@ -67,10 +62,8 @@ export function WorkOrderIntegrationSettings() { Emails are off on this server - - Set RESEND_API_KEY and NOTIFY_FROM_EMAIL. Until then people get in-app notifications - only. - + Set RESEND_API_KEY and NOTIFY_FROM_EMAIL. Until then, + notifications are in-app only. )} diff --git a/ui/src/components/work-orders/next-step-card.tsx b/ui/src/components/work-orders/next-step-card.tsx index eecb6772..52da2faf 100644 --- a/ui/src/components/work-orders/next-step-card.tsx +++ b/ui/src/components/work-orders/next-step-card.tsx @@ -10,11 +10,15 @@ import type { WorkOrderProgress } from "./progress-stepper"; export type ProgressAction = NonNullable; export type InPageAction = Exclude; -function waitingLine(progress: WorkOrderProgress, builderName: string): string | null { - const since = progress.since ? relativeTime(progress.since) : null; - const who = waitingPhrase(progress.waitingOn, builderName); - if (who) return since ? `${who} · since ${since}` : who; - return since ? `Since ${since}` : null; +function metaLine(progress: WorkOrderProgress, builderName: string): string | null { + const since = progress.since ? `since ${relativeTime(progress.since)}` : null; + const who = + progress.waitingOn === "builder" && !progress.headline.includes(builderName) + ? waitingPhrase(progress.waitingOn, builderName) + : null; + if (who && since) return `${who} · ${since}`; + if (who) return who; + return since ? since.charAt(0).toUpperCase() + since.slice(1) : null; } export function ActionButton({ @@ -75,40 +79,37 @@ export function NextStepCard({ children?: ReactNode; }) { const needsYou = progress.waitingOn === "you"; - const waiting = waitingLine(progress, builderName); + const meta = metaLine(progress, builderName); return ( - + - - Next step + + {needsYou ? "Your turn" : "Next step"}

      {progress.headline}

      - {waiting && ( - - {waiting} - - )} + {meta && {meta}}
      - + {canManage && progress.next && (
      )} {progress.attention.length > 0 && ( -
      +

      Needs attention

      -
        +
          {progress.attention.map((item) => (
        • {children}; - return <>Ask your MultiAgency operator to turn this on.; + return <>Ask your server admin to turn this on.; } diff --git a/ui/src/components/work-orders/payee-payouts-prompt.tsx b/ui/src/components/work-orders/payee-payouts-prompt.tsx index 6250ceba..9afa46b9 100644 --- a/ui/src/components/work-orders/payee-payouts-prompt.tsx +++ b/ui/src/components/work-orders/payee-payouts-prompt.tsx @@ -28,8 +28,8 @@ export function PayeePayoutsPrompt({ workOrder }: { workOrder: WorkOrderDetail } The payout agent can't pay {name} yet - The rules you signed don't include {payee}. Sign the updated rules with your wallet so - approved requests are paid automatically. + The rules you signed don't include {payee}. Sign the updated rules so approved requests + are paid automatically. {agent.changes.length > 0 ? ( diff --git a/ui/src/components/work-orders/quick-create-builder.tsx b/ui/src/components/work-orders/quick-create-builder.tsx new file mode 100644 index 00000000..6ca79893 --- /dev/null +++ b/ui/src/components/work-orders/quick-create-builder.tsx @@ -0,0 +1,95 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { type KeyboardEvent, useState } from "react"; +import { toast } from "sonner"; +import { Button, FieldError, Input } from "@/components"; +import { Field } from "@/components/admin-form"; +import { type ApiClient, useApiClient } from "@/lib/api"; +import { isValidNearAccountId } from "@/lib/near-account"; +import { refreshAfter } from "@/lib/queries"; + +type CreatedBuilder = Awaited>["contributor"]; + +export function QuickCreateBuilder({ + onCreated, + onCancel, +}: { + onCreated: (builder: CreatedBuilder) => void; + onCancel?: () => void; +}) { + const apiClient = useApiClient(); + const queryClient = useQueryClient(); + const [nearAccount, setNearAccount] = useState(""); + const [name, setName] = useState(""); + const nearTrimmed = nearAccount.trim(); + const nearOk = nearTrimmed !== "" && isValidNearAccountId(nearTrimmed); + + const create = useMutation({ + mutationFn: async () => { + const { contributor } = await apiClient.contributors.create({ + nearAccount: nearTrimmed, + name: name.trim() || undefined, + }); + return contributor; + }, + onSuccess: async (builder) => { + await refreshAfter(queryClient, { type: "builders" }); + toast.success(`${builder.name ?? builder.nearAccount} added`); + onCreated(builder); + }, + onError: (err: Error) => toast.error(err.message || "Failed to add the builder"), + }); + + const canSubmit = nearOk && !create.isPending; + const submit = () => { + if (canSubmit) create.mutate(); + }; + const onEnter = (e: KeyboardEvent) => { + if (e.key !== "Enter") return; + e.preventDefault(); + e.stopPropagation(); + submit(); + }; + + return ( +
          +

          New builder

          + + setNearAccount(e.target.value)} + /> + {nearTrimmed !== "" && !nearOk && Invalid NEAR account ID} + + + setName(e.target.value)} + /> + +
          + + {onCancel && ( + + )} +
          +
          + ); +} diff --git a/ui/src/components/work-orders/quick-create-project.tsx b/ui/src/components/work-orders/quick-create-project.tsx new file mode 100644 index 00000000..427f7c61 --- /dev/null +++ b/ui/src/components/work-orders/quick-create-project.tsx @@ -0,0 +1,112 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { type KeyboardEvent, useState } from "react"; +import { toast } from "sonner"; +import { Button, Input } from "@/components"; +import { Field } from "@/components/admin-form"; +import { type ApiClient, useApiClient } from "@/lib/api"; +import { refreshAfter } from "@/lib/queries"; +import { isSlugTakenError, slugify } from "@/lib/slugify"; +import { isHttpUrl } from "@/lib/url"; + +type CreatedProject = Awaited>["project"]; + +const SLUG_ATTEMPTS = 5; + +export function QuickCreateProject({ + onCreated, + onCancel, +}: { + onCreated: (project: CreatedProject) => void; + onCancel?: () => void; +}) { + const apiClient = useApiClient(); + const queryClient = useQueryClient(); + const [title, setTitle] = useState(""); + const [repository, setRepository] = useState(""); + const repositoryTrimmed = repository.trim(); + const repositoryOk = isHttpUrl(repositoryTrimmed); + + const create = useMutation({ + mutationFn: async () => { + const base = slugify(title) || "project"; + let slug = base; + for (let attempt = 0; ; attempt++) { + try { + const { project } = await apiClient.agency.projects.create({ + slug, + title: title.trim(), + repository: repositoryTrimmed, + kind: "project", + status: "active", + visibility: "private", + }); + return project; + } catch (err) { + if (!isSlugTakenError(err) || attempt >= SLUG_ATTEMPTS) throw err; + const suffix = `-${attempt + 2}`; + slug = `${base.slice(0, 80 - suffix.length).replace(/-+$/, "")}${suffix}`; + } + } + }, + onSuccess: async (project) => { + await refreshAfter(queryClient, { type: "projects" }); + toast.success(`${project.title} created`); + onCreated(project); + }, + onError: (err: Error) => toast.error(err.message || "Failed to create the project"), + }); + + const canSubmit = title.trim() !== "" && repositoryOk && !create.isPending; + const submit = () => { + if (canSubmit) create.mutate(); + }; + const onEnter = (e: KeyboardEvent) => { + if (e.key !== "Enter") return; + e.preventDefault(); + e.stopPropagation(); + submit(); + }; + + return ( +
          +

          New project

          + + setTitle(e.target.value)} + /> + + + setRepository(e.target.value)} + /> + +
          + + {onCancel && ( + + )} +
          +
          + ); +} diff --git a/ui/src/components/work-orders/renewal-panel.tsx b/ui/src/components/work-orders/renewal-panel.tsx index ff5e4dba..6946e9a8 100644 --- a/ui/src/components/work-orders/renewal-panel.tsx +++ b/ui/src/components/work-orders/renewal-panel.tsx @@ -45,9 +45,8 @@ export function RenewalPanel({

          Monthly renewal

          - After {workOrder.periodEnd} and {workOrder.graceDays} grace days, this period's work is - measured against the base metrics. All met: next month's work order starts by itself. - Any missed: you decide whether it renews. + Checked after {workOrder.periodEnd} + {workOrder.graceDays} grace days. Renews by itself + if every base metric is met; otherwise you decide. @@ -153,8 +152,8 @@ function DecideRenewal({ workOrder }: { workOrder: WorkOrder }) { Renewal needs your decision - Renew anyway to start next month's work order with the same terms, or let {workOrder.code}{" "} - end here. The builder is told either way. + Renew with the same terms, or let {workOrder.code} end here. The builder is told either + way. { queryClient.setQueryData(workOrderRepresentativeQueryOptions(apiClient).queryKey, data); setForm(null); - toast.success("Default representative saved"); + toast.success("Representative saved"); }, onError: (err: Error) => toast.error(err.message || "Couldn't save"), }); @@ -50,8 +50,7 @@ export function WorkOrderRepresentativeSettings() {

          Default representative

          - Who signs work orders for your Agency. New work orders start with these; you can change - them on each one. + Signs first and gets agency emails. You can change it per work order. @@ -66,11 +65,7 @@ export function WorkOrderRepresentativeSettings() { >
          - +

          Compensation requests

          - - Each period the builder sends a report and you approve an amount within the budget. Your - payout agent pays it right away, or you pay it through an Agency DAO proposal. - {canDraft && ( {dismissible && summary.ready && ( )}
            - {query.data.map((check) => ( + {groups.main.map((check) => ( ))}
          + {groups.optional.length > 0 && ( + + + + + +
            + {groups.optional.map((check) => ( + + ))} +
          +
          +
          + )}
          ); @@ -240,10 +263,7 @@ export function SetupPreflight() {
        • ))}
        - - Fix links open in a new tab, so this draft stays open. You can still save it and fix these - before you send it. - + Fix links open in a new tab. You can still save this draft. ); diff --git a/ui/src/components/work-orders/signing-panel.tsx b/ui/src/components/work-orders/signing-panel.tsx index a30237dd..f46754a8 100644 --- a/ui/src/components/work-orders/signing-panel.tsx +++ b/ui/src/components/work-orders/signing-panel.tsx @@ -1,21 +1,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { toast } from "sonner"; -import { - Badge, - Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - Input, -} from "@/components"; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from "@/components"; import { Checkbox } from "@/components/ui/checkbox"; import { useApiClient } from "@/lib/api"; import { adminWorkOrdersQueryKey } from "@/lib/queries"; import { OpenDocumentButton } from "./open-document-button"; -import type { WorkOrderDetail } from "./work-order-status"; +import { payeeConfirmedBySigning, type WorkOrderDetail } from "./work-order-status"; function Row({ label, children }: { label: string; children: React.ReactNode }) { return ( @@ -35,6 +26,7 @@ export function SigningPanel({ canManage: boolean; onAttach: () => void; }) { + const confirmsOnSigning = payeeConfirmedBySigning(wo); const apiClient = useApiClient(); const queryClient = useQueryClient(); const [github, setGithub] = useState(""); @@ -66,11 +58,6 @@ export function SigningPanel({

        Signature and payout account

        - - Offering the work order sends it for e-signature: the representative signs first, then the - builder, and it starts as soon as the builder signs. A payout account this Agency hasn't - paid before is confirmed by its wallet. -
        @@ -88,27 +75,31 @@ export function SigningPanel({ Confirmed {date(wo.payee.confirmedAt)} ) : wo.payee.paidBefore ? ( Paid by this Agency before + ) : confirmsOnSigning ? ( + Confirmed when they sign ) : wo.payee.needsConfirmation ? ( Needs the builder's confirmation ) : null} - {wo.payee.needsConfirmation && ( + {wo.payee.needsConfirmation && !confirmsOnSigning && ( - This Agency hasn't paid this account before, so the builder confirms it by signing - in with that NEAR wallet. Accepting in MultiAgency confirms it too. + New account: the builder confirms it by signing in with that NEAR wallet. )} - {canManage && wo.payee.needsConfirmation && wo.status !== "draft" && ( - - )} + {canManage && + wo.payee.needsConfirmation && + !confirmsOnSigning && + wo.status !== "draft" && ( + + )}
      @@ -154,7 +145,6 @@ export function SigningPanel({ · {wo.representativeEmail} ) : null} - {wo.ipModel && {wo.ipModel.replace("_", " ")}}
      @@ -267,8 +257,8 @@ function SignatureStatus({
      {canManage - ? "Sign it in Documenso to send it to the builder. They're emailed a link to sign as soon as you've signed." - : `Waiting for ${wo.representativeName ?? "the representative"} to sign it in Documenso; then it's sent to the builder.`} + ? "Sign it in Documenso to send it to the builder." + : `Waiting for ${wo.representativeName ?? "the representative"} to sign it in Documenso.`} {esignError}
      @@ -294,8 +284,7 @@ function SignatureStatus({
      {representative ? `Signed by ${representative.name}. ` : ""}Sent to {builderName} on{" "} - {day(representative?.signedAt ?? wo.offeredAt)}; waiting for their signature. It becomes - active as soon as they sign. + {day(representative?.signedAt ?? wo.offeredAt)}; waiting for their signature. {esignError} +

      + )}
      - - { - const project = projects.find((p) => p.id === value); - setDraft((current) => ({ - ...current, - projectId: value, - repos: current.repos || repoFromProject(project?.repository), - })); - }} - options={projects.map((p) => ({ value: p.id, label: p.title }))} - /> - - { - const builder = builders.find((b) => b.nearAccount === value); - setDraft((current) => ({ - ...current, - builderNearAccount: value, - githubLogin: builder?.githubLogin ?? "", - })); - }} - options={builders.map((b) => ({ - value: b.nearAccount, - label: `${b.name ?? b.nearAccount}${b.githubLogin ? ` · @${b.githubLogin}` : " · no GitHub login"}`, - }))} - /> - -
      -
      - - set("code", e.target.value)} - /> + {builders.length === 0 || creatingBuilder ? ( + 0 ? () => setCreatingBuilder(false) : undefined} + onCreated={(builder) => { + setCreatingBuilder(false); + setDraft((current) => ({ + ...current, + builderNearAccount: builder.nearAccount, + githubLogin: builder.githubLogin ?? "", + contractorEmail: "", + })); + }} + /> + ) : ( + { + if (value === NEW_ITEM) { + setCreatingBuilder(true); + return; + } + const builder = builders.find((b) => b.nearAccount === value); + setDraft((current) => ({ + ...current, + builderNearAccount: value, + githubLogin: builder?.githubLogin ?? "", + contractorEmail: "", + })); + }} + options={[ + ...builders.map((b) => ({ + value: b.nearAccount, + label: `${b.name ?? b.nearAccount}${b.githubLogin ? ` · @${b.githubLogin}` : ""}`, + })), + { value: NEW_ITEM, label: "+ New builder" }, + ]} + /> + )} - - set("title", e.target.value)} - /> + + {projects.length === 0 || creatingProject ? ( + 0 ? () => setCreatingProject(false) : undefined} + onCreated={(project) => { + setCreatingProject(false); + setDraft((current) => ({ + ...current, + projectId: project.id, + repos: current.repos.trim() + ? current.repos + : repoFromProject(project.repository), + })); + }} + /> + ) : ( + { + if (value === NEW_ITEM) { + setCreatingProject(true); + return; + } + const project = projects.find((p) => p.id === value); + setDraft((current) => ({ + ...current, + projectId: value, + repos: current.repos.trim() + ? current.repos + : repoFromProject(project?.repository), + })); + }} + options={[ + ...projects.map((p) => ({ value: p.id, label: p.title })), + { value: NEW_ITEM, label: "+ New project" }, + ]} + /> + )}
      - -