diff --git a/packages/dashboard/app/orders/new/compose.test.ts b/packages/dashboard/app/orders/new/compose.test.ts new file mode 100644 index 0000000..9feb74e --- /dev/null +++ b/packages/dashboard/app/orders/new/compose.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { composeOrderText } from './compose'; + +describe('composeOrderText', () => { + it('composes a full natural-language order from structured fields', () => { + const text = composeOrderText({ + workerDisplayName: 'Mameta29', + repository: 'Mameta29/agentic-gig-flow', + amountJpyc: '50000', + deadline: '1週間後', + description: 'Dashboard 発注フォームの改善', + acceptanceCriteria: '既存テストがすべて通る、CIが通過している', + }); + // Worker, repository, amount, deadline, description and criteria must all + // survive into the text Contract Agent parses. + expect(text).toContain('Mameta29 さんに'); + expect(text).toContain('Dashboard 発注フォームの改善 を'); + expect(text).toContain('50,000 JPYC で'); + expect(text).toContain('1週間後 まで'); + expect(text).toContain('リポジトリは Mameta29/agentic-gig-flow。'); + expect(text).toContain( + '受け入れ基準: 既存テストがすべて通る、CIが通過している。', + ); + }); + + it('formats the amount with thousands separators', () => { + const text = composeOrderText({ + workerDisplayName: 'Sato Taro', + repository: 'Mameta29/gigflow-demo-workspace', + amountJpyc: '80000', + deadline: '3週間後', + description: 'リファクタ', + }); + expect(text).toContain('80,000 JPYC'); + }); + + it('omits optional acceptance criteria when empty', () => { + const text = composeOrderText({ + workerDisplayName: 'Sato Taro', + repository: 'Mameta29/gigflow-demo-workspace', + amountJpyc: '30000', + deadline: '1週間後', + description: 'バグ修正', + }); + expect(text).not.toContain('受け入れ基準'); + }); + + it('drops a non-numeric amount instead of emitting NaN', () => { + const text = composeOrderText({ + workerDisplayName: 'Sato Taro', + repository: 'Mameta29/gigflow-demo-workspace', + amountJpyc: 'abc', + deadline: '1週間後', + description: 'バグ修正', + }); + expect(text).not.toContain('NaN'); + expect(text).not.toContain('JPYC で'); + }); +}); diff --git a/packages/dashboard/app/orders/new/compose.ts b/packages/dashboard/app/orders/new/compose.ts new file mode 100644 index 0000000..8575c45 --- /dev/null +++ b/packages/dashboard/app/orders/new/compose.ts @@ -0,0 +1,38 @@ +// Pure helper extracted from page.tsx so it can be unit-tested without +// rendering the client component (Next.js page files may only export the +// default component + reserved names). + +/** + * Compose a natural-language order sentence from the structured selections. + * Contract Agent still parses this text — the natural-language entry point is + * intentionally preserved. The form just removes the typo class of errors + * ("受注者が登録されていません" / "リポジトリが登録されていません") by letting the + * PM pick the worker and repository from the tenant's allow-list instead of + * free-typing them. + */ +export function composeOrderText(input: { + workerDisplayName: string; + repository: string; + amountJpyc: string; + deadline: string; + description: string; + acceptanceCriteria?: string; +}): string { + const amount = input.amountJpyc.trim(); + const amountLabel = + amount && Number.isFinite(Number(amount)) + ? `${Number(amount).toLocaleString('en-US')} JPYC` + : ''; + const parts = [ + `${input.workerDisplayName} さんに`, + input.description.trim() ? `${input.description.trim()} を` : '', + amountLabel ? `${amountLabel} で` : '', + input.deadline.trim() ? `${input.deadline.trim()} まで` : '', + 'お願いします。', + `リポジトリは ${input.repository}。`, + input.acceptanceCriteria?.trim() + ? `受け入れ基準: ${input.acceptanceCriteria.trim()}。` + : '', + ].filter(Boolean); + return parts.join(' '); +} diff --git a/packages/dashboard/app/orders/new/page.tsx b/packages/dashboard/app/orders/new/page.tsx index 6a1e959..70a88e3 100644 --- a/packages/dashboard/app/orders/new/page.tsx +++ b/packages/dashboard/app/orders/new/page.tsx @@ -1,12 +1,14 @@ 'use client'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import type { OrderErrorView } from '@/lib/order-errors'; +import { composeOrderText } from './compose'; -// Live values verified against production Cosmos (scripts/inspect-tenant.ts). -// displayName is what shows in the UI / what the PM types in natural language; -// githubLogin is what Contract Agent puts on the Issue assignee. +// Live values verified against production Cosmos (scripts/inspect-tenant.ts / +// scripts/seed-dogfood.ts). displayName is what shows in the UI / what the PM +// types in natural language; githubLogin is what Contract Agent puts on the +// Issue assignee. const KNOWN_WORKERS = [ { displayName: 'Sato Taro', @@ -18,11 +20,16 @@ const KNOWN_WORKERS = [ githubLogin: 'Mameta29', note: 'dogfooding: 開発者本人 (seed-dogfood.ts で登録)', }, -]; +] as const; + const KNOWN_REPOSITORIES = [ 'Mameta29/gigflow-demo-workspace', 'Mameta29/agentic-gig-flow', -]; +] as const; + +// Deadline presets keep the natural-language phrasing Contract Agent already +// parses well ("2週間後" 等)。datalist なので自由入力もできる。 +const DEADLINE_PRESETS = ['1週間後', '2週間後', '3週間後', '1ヶ月後'] as const; // Sample prompts that include every field Contract Agent needs to succeed: // worker name, amount (JPYC), deadline (relative), description, repository. @@ -51,10 +58,55 @@ const SAMPLES: { label: string; text: string }[] = [ export default function NewOrderPage() { const router = useRouter(); - const [text, setText] = useState(''); + + // Structured selections compose the natural-language text below, which stays + // the single source of truth sent to Contract Agent (NL entry preserved). + const [workerLogin, setWorkerLogin] = useState( + KNOWN_WORKERS[0].githubLogin, + ); + const [repository, setRepository] = useState(KNOWN_REPOSITORIES[0]); + const [amountJpyc, setAmountJpyc] = useState('50000'); + const [deadline, setDeadline] = useState(DEADLINE_PRESETS[1]); + const [description, setDescription] = useState(''); + const [acceptanceCriteria, setAcceptanceCriteria] = useState(''); + + // When the PM edits the textarea (or picks a sample) we stop overwriting it + // from the form, so power users keep full natural-language control. + const [manualText, setManualText] = useState(null); + const [submitting, setSubmitting] = useState(false); const [err, setErr] = useState(null); + const selectedWorker = + KNOWN_WORKERS.find((w) => w.githubLogin === workerLogin) ?? KNOWN_WORKERS[0]; + + const composed = useMemo( + () => + composeOrderText({ + workerDisplayName: selectedWorker.displayName, + repository, + amountJpyc, + deadline, + description, + acceptanceCriteria, + }), + [ + selectedWorker.displayName, + repository, + amountJpyc, + deadline, + description, + acceptanceCriteria, + ], + ); + + const text = manualText ?? composed; + + // Reset back to form-driven composition. + function useFormText() { + setManualText(null); + } + async function submit() { setSubmitting(true); setErr(null); @@ -96,57 +148,146 @@ export default function NewOrderPage() {

新規発注

- 自然言語で書くだけで Contract Agent が GitHub Issue を起こします。 + 受注者とリポジトリを選び、業務内容を書くだけ。Contract Agent が + GitHub Issue を起こします。 - {' '}受注者名・金額・期日・業務内容を含めてください。 + {' '}下の発注文はそのまま編集もできます (自然言語入力)。

-
-
- 登録済みの受注者 -
-
    - {KNOWN_WORKERS.map((w) => ( -
  • - {w.displayName} - @{w.githubLogin} - {w.note} -
  • - ))} -
-
- ここに無い名前 (例: 後藤さん) を指定すると「受注者が登録されていません」エラーになります。 -
+ {/* Structured selectors remove the typo class of "登録されていません" errors. */} +
+ + + + + + +
-
-
- 使えるリポジトリ -
-
    - {KNOWN_REPOSITORIES.map((r) => ( -
  • - {r} -
  • - ))} -
-
- このテナントで許可されているリポジトリです。他のリポジトリ名を書くと - 「リポジトリが登録されていません」エラーになります。 -
+
+ +
- サンプル (クリックで入力欄に挿入) + サンプル (クリックで発注文に挿入)
{SAMPLES.map((s) => (
-