From 4351248a12dd094c3d19ff3ecc9dfc510d13e655 Mon Sep 17 00:00:00 2001 From: Brandon Lu Date: Mon, 29 Jun 2026 01:37:45 +0800 Subject: [PATCH 1/2] [feature] unify customer fields to searchable PartyCombobox + add CD pipeline Replace static Select dropdowns with the existing PartyCombobox (search + auto-create) across subscription, contract, receivable, and project forms. Backend mutations now accept customerPartyName/clientPartyName and resolve via getOrCreateParty, so users can type a new customer name and have it created on save. Also adds a GitHub Actions workflow for deploying to Cloudflare Workers on push to main. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/deploy.yml | 37 ++++++++++++ .../contracts/edit-contract-form.tsx | 21 +++---- .../contracts/new-contract-dialog.tsx | 15 +---- .../projects/edit-project-form.tsx | 25 +++----- .../projects/new-project-dialog.tsx | 16 +---- .../subscriptions/edit-subscription-form.tsx | 21 +++---- .../subscriptions/new-subscription-dialog.tsx | 15 +---- .../transactions/edit-transaction-form.tsx | 2 +- .../transactions/new-transaction-dialog.tsx | 2 +- .../party-combobox.tsx | 0 src/db/mutations.ts | 60 +++++++++++++------ 11 files changed, 111 insertions(+), 103 deletions(-) create mode 100644 .github/workflows/deploy.yml rename src/{app/(dashboard)/transactions => components}/party-combobox.tsx (100%) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..06c418f --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,37 @@ +name: Deploy to Cloudflare Workers + +on: + push: + branches: [main] + +concurrency: + group: deploy + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - run: bun install --frozen-lockfile + + - run: bun run lint + + # Generate wrangler.jsonc from the example template, + # replacing placeholders with secrets / env vars. + - name: Generate wrangler.jsonc + run: | + sed \ + -e 's/"my-app"/"${{ vars.WORKER_NAME || 'internal' }}"/' \ + -e 's|"app.example.com"|"${{ vars.WORKER_ROUTE }}"|' \ + -e 's/"my-app-storage"/"${{ vars.R2_BUCKET_NAME || 'internal-storage' }}"/' \ + wrangler.jsonc.example > wrangler.jsonc + + - name: Build & Deploy + run: bun run cf:deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/src/app/(dashboard)/contracts/edit-contract-form.tsx b/src/app/(dashboard)/contracts/edit-contract-form.tsx index 38ba667..67afca3 100644 --- a/src/app/(dashboard)/contracts/edit-contract-form.tsx +++ b/src/app/(dashboard)/contracts/edit-contract-form.tsx @@ -10,6 +10,7 @@ import { Label, Req } from "@/components/ui/label"; import { DatePicker } from "@/components/date-picker"; import { DialogFooter } from "@/components/ui/dialog"; import { useRowDialogClose } from "@/components/row-dialog"; +import { PartyCombobox } from "@/components/party-combobox"; import { Select, SelectContent, @@ -50,8 +51,8 @@ export function EditContractForm({ footer?: React.ReactNode; }>) { const [state, action, pending] = useActionState(updateContract, initial); - const partyItems = Object.fromEntries(parties.map((p) => [String(p.id), p.name])); const projectItems = Object.fromEntries(projects.map((p) => [String(p.id), p.name])); + const defaultCustomerName = parties.find((p) => p.id === contract.customerPartyId)?.name ?? ""; const close = useRowDialogClose(); useEffect(() => { @@ -96,18 +97,12 @@ export function EditContractForm({
- +
diff --git a/src/app/(dashboard)/contracts/new-contract-dialog.tsx b/src/app/(dashboard)/contracts/new-contract-dialog.tsx index aac69fe..e06e2e6 100644 --- a/src/app/(dashboard)/contracts/new-contract-dialog.tsx +++ b/src/app/(dashboard)/contracts/new-contract-dialog.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label, Req } from "@/components/ui/label"; import { DatePicker } from "@/components/date-picker"; +import { PartyCombobox } from "@/components/party-combobox"; import { Select, SelectContent, @@ -38,7 +39,6 @@ export function NewContractDialog({ }>) { const [open, setOpen] = useState(false); const [state, action, pending] = useActionState(createContract, initial); - const partyItems = Object.fromEntries(parties.map((p) => [String(p.id), p.name])); const projectItems = Object.fromEntries(projects.map((p) => [String(p.id), p.name])); useEffect(() => { @@ -66,18 +66,7 @@ export function NewContractDialog({
- +
diff --git a/src/app/(dashboard)/projects/edit-project-form.tsx b/src/app/(dashboard)/projects/edit-project-form.tsx index 91a68ad..a14d354 100644 --- a/src/app/(dashboard)/projects/edit-project-form.tsx +++ b/src/app/(dashboard)/projects/edit-project-form.tsx @@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input"; import { Label, Req } from "@/components/ui/label"; import { DialogFooter } from "@/components/ui/dialog"; import { useRowDialogClose } from "@/components/row-dialog"; +import { PartyCombobox } from "@/components/party-combobox"; import { Select, SelectContent, @@ -36,7 +37,7 @@ export function EditProjectForm({ footer?: React.ReactNode; }>) { const [state, action, pending] = useActionState(updateProject, initial); - const partyItems = Object.fromEntries(parties.map((p) => [String(p.id), p.name])); + const defaultClientName = parties.find((p) => p.id === project.clientPartyId)?.name ?? ""; const close = useRowDialogClose(); useEffect(() => { @@ -59,22 +60,12 @@ export function EditProjectForm({
- +
diff --git a/src/app/(dashboard)/projects/new-project-dialog.tsx b/src/app/(dashboard)/projects/new-project-dialog.tsx index 4d59d0d..7143ba8 100644 --- a/src/app/(dashboard)/projects/new-project-dialog.tsx +++ b/src/app/(dashboard)/projects/new-project-dialog.tsx @@ -7,6 +7,7 @@ import { createProject, type ActionState } from "@/db/mutations"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label, Req } from "@/components/ui/label"; +import { PartyCombobox } from "@/components/party-combobox"; import { Select, SelectContent, @@ -33,8 +34,6 @@ export function NewProjectDialog({ }>) { const [open, setOpen] = useState(false); const [state, action, pending] = useActionState(createProject, initial); - const partyItems = Object.fromEntries(parties.map((p) => [String(p.id), p.name])); - useEffect(() => { if (state.ok) { setOpen(false); @@ -64,18 +63,7 @@ export function NewProjectDialog({
- +
diff --git a/src/app/(dashboard)/subscriptions/edit-subscription-form.tsx b/src/app/(dashboard)/subscriptions/edit-subscription-form.tsx index 308326b..62ee303 100644 --- a/src/app/(dashboard)/subscriptions/edit-subscription-form.tsx +++ b/src/app/(dashboard)/subscriptions/edit-subscription-form.tsx @@ -9,6 +9,7 @@ import { Label, Req } from "@/components/ui/label"; import { DatePicker } from "@/components/date-picker"; import { DialogFooter } from "@/components/ui/dialog"; import { useRowDialogClose } from "@/components/row-dialog"; +import { PartyCombobox } from "@/components/party-combobox"; import { Select, SelectContent, @@ -47,8 +48,8 @@ export function EditSubscriptionForm({ footer?: React.ReactNode; }>) { const [state, action, pending] = useActionState(updateSubscription, initial); - const partyItems = Object.fromEntries(parties.map((p) => [String(p.id), p.name])); const projectItems = Object.fromEntries(projects.map((p) => [String(p.id), p.name])); + const defaultCustomerName = parties.find((p) => p.id === subscription.customerPartyId)?.name ?? ""; const close = useRowDialogClose(); useEffect(() => { @@ -67,18 +68,12 @@ export function EditSubscriptionForm({
- +
diff --git a/src/app/(dashboard)/subscriptions/new-subscription-dialog.tsx b/src/app/(dashboard)/subscriptions/new-subscription-dialog.tsx index 3678473..26cafde 100644 --- a/src/app/(dashboard)/subscriptions/new-subscription-dialog.tsx +++ b/src/app/(dashboard)/subscriptions/new-subscription-dialog.tsx @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label, Req } from "@/components/ui/label"; import { DatePicker } from "@/components/date-picker"; +import { PartyCombobox } from "@/components/party-combobox"; import { Select, SelectContent, @@ -38,7 +39,6 @@ export function NewSubscriptionDialog({ }>) { const [open, setOpen] = useState(false); const [state, action, pending] = useActionState(createSubscription, initial); - const partyItems = Object.fromEntries(parties.map((p) => [String(p.id), p.name])); const projectItems = Object.fromEntries(projects.map((p) => [String(p.id), p.name])); useEffect(() => { @@ -66,18 +66,7 @@ export function NewSubscriptionDialog({
- +
diff --git a/src/app/(dashboard)/transactions/edit-transaction-form.tsx b/src/app/(dashboard)/transactions/edit-transaction-form.tsx index 613e490..0933846 100644 --- a/src/app/(dashboard)/transactions/edit-transaction-form.tsx +++ b/src/app/(dashboard)/transactions/edit-transaction-form.tsx @@ -21,7 +21,7 @@ import { } from "@/components/ui/select"; import { DatePicker } from "@/components/date-picker"; import { CategoryCombobox } from "./category-combobox"; -import { PartyCombobox } from "./party-combobox"; +import { PartyCombobox } from "@/components/party-combobox"; import { ContractCombobox, type ContractOption } from "./contract-combobox"; const initial: ActionState = { ok: false }; diff --git a/src/app/(dashboard)/transactions/new-transaction-dialog.tsx b/src/app/(dashboard)/transactions/new-transaction-dialog.tsx index 1d58eb2..55a46d3 100644 --- a/src/app/(dashboard)/transactions/new-transaction-dialog.tsx +++ b/src/app/(dashboard)/transactions/new-transaction-dialog.tsx @@ -24,7 +24,7 @@ import { SheetTrigger, } from "@/components/ui/sheet"; import { DatePicker } from "@/components/date-picker"; -import { PartyCombobox } from "./party-combobox"; +import { PartyCombobox } from "@/components/party-combobox"; import { CategoryCombobox } from "./category-combobox"; import { ContractCombobox, type ContractOption } from "./contract-combobox"; diff --git a/src/app/(dashboard)/transactions/party-combobox.tsx b/src/components/party-combobox.tsx similarity index 100% rename from src/app/(dashboard)/transactions/party-combobox.tsx rename to src/components/party-combobox.tsx diff --git a/src/db/mutations.ts b/src/db/mutations.ts index 13cf2a2..d50cfce 100644 --- a/src/db/mutations.ts +++ b/src/db/mutations.ts @@ -1146,14 +1146,20 @@ export async function createProject( if (!PROJECT_STATUS.has(status)) return { ok: false, error: "狀態不正確" }; try { const { orgId } = await requireOrg(); - await getDb().insert(projects).values({ + const db = getDb(); + const clientPartyName = str(formData.get("clientPartyName")); + const clientPartyId = clientPartyName + ? await getOrCreateParty(db, orgId, clientPartyName, "customer") + : null; + await db.insert(projects).values({ organizationId: orgId, name, - clientPartyId: num(formData.get("clientPartyId")), + clientPartyId, status, description: str(formData.get("description")), }); revalidatePath("/projects"); + if (clientPartyName) revalidatePath("/parties"); return { ok: true }; } catch (e) { return { ok: false, error: e instanceof Error ? e.message : "新增失敗" }; @@ -1172,16 +1178,22 @@ export async function updateProject( if (!PROJECT_STATUS.has(status)) return { ok: false, error: "狀態不正確" }; try { const { orgId } = await requireOrg(); - await getDb() + const db = getDb(); + const clientPartyName = str(formData.get("clientPartyName")); + const clientPartyId = clientPartyName + ? await getOrCreateParty(db, orgId, clientPartyName, "customer") + : null; + await db .update(projects) .set({ name, - clientPartyId: num(formData.get("clientPartyId")), + clientPartyId, status, description: str(formData.get("description")), }) .where(and(eq(projects.organizationId, orgId), eq(projects.id, id))); revalidatePath("/projects"); + if (clientPartyName) revalidatePath("/parties"); return { ok: true }; } catch (e) { return { ok: false, error: e instanceof Error ? e.message : "更新失敗" }; @@ -1206,7 +1218,7 @@ const SUBSCRIPTION_STATUS = new Set(["active", "paused", "ended"]); function subscriptionValues(formData: FormData) { return { - customerPartyId: num(formData.get("customerPartyId")), + customerPartyName: str(formData.get("customerPartyName")), projectId: num(formData.get("projectId")), name: str(formData.get("name")), amount: str(formData.get("amount")), @@ -1224,18 +1236,20 @@ export async function createSubscription( formData: FormData, ): Promise { const v = subscriptionValues(formData); - if (!v.customerPartyId) return { ok: false, error: "請選擇客戶" }; + if (!v.customerPartyName) return { ok: false, error: "請輸入客戶" }; if (!v.name) return { ok: false, error: "請輸入方案名稱" }; if (v.amount === null) return { ok: false, error: "請輸入金額" }; if (!v.startDate) return { ok: false, error: "請選擇開始日期" }; if (!SUBSCRIPTION_STATUS.has(v.status)) return { ok: false, error: "狀態不正確" }; try { const { orgId } = await requireOrg(); - await getDb() + const db = getDb(); + const customerPartyId = await getOrCreateParty(db, orgId, v.customerPartyName, "customer"); + await db .insert(subscriptions) .values({ organizationId: orgId, - customerPartyId: v.customerPartyId, + customerPartyId, projectId: v.projectId, name: v.name, amount: v.amount, @@ -1247,6 +1261,7 @@ export async function createSubscription( note: v.note, }); revalidatePath("/subscriptions"); + revalidatePath("/parties"); return { ok: true }; } catch (e) { return { ok: false, error: e instanceof Error ? e.message : "新增失敗" }; @@ -1260,17 +1275,19 @@ export async function updateSubscription( const id = num(formData.get("id")); if (!id) return { ok: false, error: "缺少 ID" }; const v = subscriptionValues(formData); - if (!v.customerPartyId) return { ok: false, error: "請選擇客戶" }; + if (!v.customerPartyName) return { ok: false, error: "請輸入客戶" }; if (!v.name) return { ok: false, error: "請輸入方案名稱" }; if (v.amount === null) return { ok: false, error: "請輸入金額" }; if (!v.startDate) return { ok: false, error: "請選擇開始日期" }; if (!SUBSCRIPTION_STATUS.has(v.status)) return { ok: false, error: "狀態不正確" }; try { const { orgId } = await requireOrg(); - await getDb() + const db = getDb(); + const customerPartyId = await getOrCreateParty(db, orgId, v.customerPartyName, "customer"); + await db .update(subscriptions) .set({ - customerPartyId: v.customerPartyId, + customerPartyId, projectId: v.projectId, name: v.name, amount: v.amount, @@ -1283,6 +1300,7 @@ export async function updateSubscription( }) .where(and(eq(subscriptions.organizationId, orgId), eq(subscriptions.id, id))); revalidatePath("/subscriptions"); + revalidatePath("/parties"); return { ok: true }; } catch (e) { return { ok: false, error: e instanceof Error ? e.message : "更新失敗" }; @@ -1307,7 +1325,7 @@ const CONTRACT_STATUS = new Set(["draft", "active", "completed", "cancelled"]); function contractValues(formData: FormData) { return { - customerPartyId: num(formData.get("customerPartyId")), + customerPartyName: str(formData.get("customerPartyName")), projectId: num(formData.get("projectId")), title: str(formData.get("title")), amount: str(formData.get("amount")), @@ -1325,16 +1343,18 @@ export async function createContract( formData: FormData, ): Promise { const v = contractValues(formData); - if (!v.customerPartyId) return { ok: false, error: "請選擇客戶" }; + if (!v.customerPartyName) return { ok: false, error: "請輸入客戶" }; if (!v.title) return { ok: false, error: "請輸入合約名稱" }; if (!CONTRACT_STATUS.has(v.status)) return { ok: false, error: "狀態不正確" }; try { const { orgId } = await requireOrg(); - await getDb() + const db = getDb(); + const customerPartyId = await getOrCreateParty(db, orgId, v.customerPartyName, "customer"); + await db .insert(contracts) .values({ organizationId: orgId, - customerPartyId: v.customerPartyId, + customerPartyId, projectId: v.projectId, title: v.title, amount: v.amount, @@ -1346,6 +1366,7 @@ export async function createContract( fileUrl: v.fileUrl, }); revalidatePath("/contracts"); + revalidatePath("/parties"); return { ok: true }; } catch (e) { return { ok: false, error: e instanceof Error ? e.message : "新增失敗" }; @@ -1359,15 +1380,17 @@ export async function updateContract( const id = num(formData.get("id")); if (!id) return { ok: false, error: "缺少 ID" }; const v = contractValues(formData); - if (!v.customerPartyId) return { ok: false, error: "請選擇客戶" }; + if (!v.customerPartyName) return { ok: false, error: "請輸入客戶" }; if (!v.title) return { ok: false, error: "請輸入合約名稱" }; if (!CONTRACT_STATUS.has(v.status)) return { ok: false, error: "狀態不正確" }; try { const { orgId } = await requireOrg(); - await getDb() + const db = getDb(); + const customerPartyId = await getOrCreateParty(db, orgId, v.customerPartyName, "customer"); + await db .update(contracts) .set({ - customerPartyId: v.customerPartyId, + customerPartyId, projectId: v.projectId, title: v.title, amount: v.amount, @@ -1380,6 +1403,7 @@ export async function updateContract( }) .where(and(eq(contracts.organizationId, orgId), eq(contracts.id, id))); revalidatePath("/contracts"); + revalidatePath("/parties"); return { ok: true }; } catch (e) { return { ok: false, error: e instanceof Error ? e.message : "更新失敗" }; From fc9ec69418d6ed59d231bc7d92c683aadeaa81b6 Mon Sep 17 00:00:00 2001 From: Brandon Lu Date: Mon, 29 Jun 2026 01:43:18 +0800 Subject: [PATCH 2/2] =?UTF-8?q?remove=20CD=20pipeline=20=E2=80=94=20manual?= =?UTF-8?q?=20deploy=20preferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .github/workflows/deploy.yml | 37 ------------------------------------ 1 file changed, 37 deletions(-) delete mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 06c418f..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Deploy to Cloudflare Workers - -on: - push: - branches: [main] - -concurrency: - group: deploy - cancel-in-progress: true - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: oven-sh/setup-bun@v2 - - - run: bun install --frozen-lockfile - - - run: bun run lint - - # Generate wrangler.jsonc from the example template, - # replacing placeholders with secrets / env vars. - - name: Generate wrangler.jsonc - run: | - sed \ - -e 's/"my-app"/"${{ vars.WORKER_NAME || 'internal' }}"/' \ - -e 's|"app.example.com"|"${{ vars.WORKER_ROUTE }}"|' \ - -e 's/"my-app-storage"/"${{ vars.R2_BUCKET_NAME || 'internal-storage' }}"/' \ - wrangler.jsonc.example > wrangler.jsonc - - - name: Build & Deploy - run: bun run cf:deploy - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}