Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,10 @@ the 待開發票 list.
**Client ops** — projects/subscriptions/contracts each have `list_*` +
`create_*`/`update_*`/`delete_*` (`list_customers` is a convenience filter of
`list_parties`). A contract carries a `fileUrl` — the link to the signed contract
file (e.g. a Google Drive link). Contract status is stored, not derived: linking
file (e.g. a Google Drive link). A subscription carries an optional `contractId`
(`create_subscription` / `update_subscription`; pass `null` on update to unlink)
so a recurring fee quoted in a contract stays traceable — `list_subscriptions`
returns it as `contractId` / `contractTitle`. Contract status is stored, not derived: linking
an income transaction that fills the contract does **not** move it to
`completed` on its own. So `create_transaction` / `bulk_create_transactions` /
`update_transaction` return `contractProgress` (amount / received / remaining /
Expand Down
18 changes: 18 additions & 0 deletions migrations/0022_subscription_contract.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- 0022: subscriptions.contract_id(訂閱可綁定合約)。
--
-- 為什麼需要:訂閱(週期性月費)與合約是兩張各自獨立的表,訂閱只能掛專案
-- (project_id),掛不到合約。但週期性費用多半是合約裡談好的條件 —— 合約寫「每月
-- 維運費 30,000」,系統裡卻只有一張孤兒訂閱,回頭要問「這筆月費是哪張合約來的」
-- 沒有任何欄位答得出來,只能靠方案名稱猜。請款看板上的訂閱期別也因此永遠是
-- contract_id = NULL,跟 billing_items 那側的合約欄位對不起來。
--
-- 一次性分期走 billing_items.contract_id,週期性月費走本欄位,兩邊語意一致。
-- 刻意維持選填:既有訂閱不見得對得到合約,也有純口頭約定的月費。
--
-- Forward-only, additive. 既有資料一律 NULL,不需要 backfill。

ALTER TABLE subscriptions
ADD COLUMN contract_id bigint REFERENCES contracts(id);

-- 合約詳情頁要反查「這張合約綁了哪些訂閱」,比照 idx_billing_item_contract。
CREATE INDEX idx_subscriptions_contract ON subscriptions(contract_id);
63 changes: 63 additions & 0 deletions src/app/dashboard/contracts/contract-subscriptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Badge } from "@/components/ui/badge";
import { formatCurrency } from "@/lib/format";
import type { ContractSubscription } from "@/db/queries";

const statusVariant: Record<string, "default" | "secondary" | "outline"> = {
active: "default",
paused: "secondary",
ended: "outline",
};

function intervalLabel(
t: Awaited<ReturnType<typeof getTranslations<"contracts">>>,
months: number,
) {
if (months === 1) return t("linkedSubscriptions.interval.monthly");
if (months === 3) return t("linkedSubscriptions.interval.quarterly");
if (months === 12) return t("linkedSubscriptions.interval.yearly");
return t("linkedSubscriptions.interval.everyNMonths", { months });
}

/**
* 合約編輯視窗裡的「綁定的訂閱」區塊:這張合約談好的週期性費用。
* 沒有綁任何訂閱就整塊不出現 —— 大多數合約都沒有,留一行空狀態只是噪音。
*/
export async function ContractSubscriptions({
rows,
}: Readonly<{ rows: ContractSubscription[] }>) {
if (rows.length === 0) return null;
const t = await getTranslations("contracts");
const statusLabels: Record<string, string> = {
active: t("linkedSubscriptions.status.active"),
paused: t("linkedSubscriptions.status.paused"),
ended: t("linkedSubscriptions.status.ended"),
};
return (
<section className="space-y-2 rounded-lg border p-3 sm:col-span-2">
<div className="text-sm font-medium">{t("linkedSubscriptions.heading")}</div>
<ul className="divide-y">
{rows.map((s) => (
<li key={s.id} className="flex items-center justify-between gap-2 py-2 text-sm">
<div className="min-w-0">
<div className="truncate font-medium">{s.name}</div>
<div className="text-xs text-muted-foreground tabular-nums">
{formatCurrency(s.amount, s.currency)} · {intervalLabel(t, s.intervalMonths)}
</div>
</div>
<Badge variant={statusVariant[s.status] ?? "outline"}>
{statusLabels[s.status] ?? s.status}
</Badge>
</li>
))}
</ul>
<Link
href="/dashboard/subscriptions"
className="block w-full py-1 text-center text-xs text-muted-foreground hover:text-foreground"
>
{t("linkedSubscriptions.manageLink")}
</Link>
</section>
);
}
20 changes: 13 additions & 7 deletions src/app/dashboard/contracts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ import {
listContracts,
listParties,
listProjects,
listSubscriptionsByContract,
} from "@/db/queries";
import { formatCurrency, formatDate } from "@/lib/format";
import { cn } from "@/lib/utils";
import { NewContractDialog } from "./new-contract-dialog";
import { ContractFileLink } from "./contract-file-link";
import { requireOrg } from "@/lib/session";
import { ContractBillingItems } from "./contract-billing-items";
import { ContractSubscriptions } from "./contract-subscriptions";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -101,11 +103,12 @@ function CollectionCell({
export default async function ContractsPage() {
const { orgId } = await requireOrg();
const t = await getTranslations("contracts");
const [rows, parties, projects, board] = await Promise.all([
const [rows, parties, projects, board, subsByContract] = await Promise.all([
listContracts(orgId),
listParties(orgId),
listProjects(orgId),
listBillingBoard(orgId, { includeAllHistory: true }),
listSubscriptionsByContract(orgId),
]);
const partyOptions = parties.map((p) => ({ id: p.id, name: p.name }));
const projectOptions = projects.map((p) => ({ id: p.id, name: p.name }));
Expand Down Expand Up @@ -221,12 +224,15 @@ export default async function ContractsPage() {
(billingByContract.get(c.id) ?? []).filter(isLockedScheduleRow).length
}
extra={
<ContractBillingItems
contractId={c.id}
rows={billingByContract.get(c.id) ?? []}
parties={partyOptions}
projects={projectOptions}
/>
<>
<ContractBillingItems
contractId={c.id}
rows={billingByContract.get(c.id) ?? []}
parties={partyOptions}
projects={projectOptions}
/>
<ContractSubscriptions rows={subsByContract.get(c.id) ?? []} />
</>
}
footer={<DeleteButton action={deleteContract} id={c.id} />}
/>
Expand Down
9 changes: 8 additions & 1 deletion src/app/dashboard/subscriptions/edit-subscription-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ export function EditSubscriptionForm({
subscription,
parties,
projects,
contracts,
footer,
}: Readonly<{
subscription: Subscription;
parties: Option[];
projects: Option[];
contracts: Option[];
footer?: React.ReactNode;
}>) {
const t = useTranslations("subscriptions");
Expand All @@ -34,7 +36,12 @@ export function EditSubscriptionForm({
footer={footer}
>
<input type="hidden" name="id" value={subscription.id} />
<SubscriptionFields parties={parties} projects={projects} values={subscription} />
<SubscriptionFields
parties={parties}
projects={projects}
contracts={contracts}
values={subscription}
/>
</EditForm>
);
}
4 changes: 3 additions & 1 deletion src/app/dashboard/subscriptions/new-subscription-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import { SubscriptionFields, type Option } from "./subscription-fields";
export function NewSubscriptionDialog({
parties,
projects,
contracts,
}: Readonly<{
parties: Option[];
projects: Option[];
contracts: Option[];
}>) {
const t = useTranslations("subscriptions");

Expand All @@ -25,7 +27,7 @@ export function NewSubscriptionDialog({
submittingLabel={t("newDialog.submitting")}
className="sm:max-w-lg max-h-[85vh] overflow-y-auto"
>
<SubscriptionFields parties={parties} projects={projects} />
<SubscriptionFields parties={parties} projects={projects} contracts={contracts} />
</CreateDialog>
);
}
23 changes: 20 additions & 3 deletions src/app/dashboard/subscriptions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
listSubscriptions,
listParties,
listProjects,
listContracts,
getSubscriptionSchedule,
type SubscriptionSchedule,
} from "@/db/queries";
Expand Down Expand Up @@ -117,13 +118,19 @@ function intervalLabel(t: Translator, months: number) {
export default async function SubscriptionsPage() {
const { orgId } = await requireOrg();
const t = await getTranslations("subscriptions");
const [rows, parties, projects] = await Promise.all([
const [rows, parties, projects, contracts] = await Promise.all([
listSubscriptions(orgId),
listParties(orgId),
listProjects(orgId),
listContracts(orgId),
]);
const partyOptions = parties.map((p) => ({ id: p.id, name: p.name }));
const projectOptions = projects.map((p) => ({ id: p.id, name: p.name }));
// 合約標題常常重複(同一客戶多張約),帶上客戶名才分得出是誰的合約。
const contractOptions = contracts.map((c) => ({
id: c.id,
name: c.customerName ? `${c.title} — ${c.customerName}` : c.title,
}));
// 訂閱不多,逐筆抓各期收款狀態即可(一次平行抓完)。
const schedules = await Promise.all(rows.map((s) => getSubscriptionSchedule(orgId, s.id)));
const scheduleById = new Map(schedules.filter((s) => s != null).map((s) => [s.id, s]));
Expand All @@ -142,7 +149,11 @@ export default async function SubscriptionsPage() {
return (
<>
<PageHeader title={t("list.title")} description={t("list.description")}>
<NewSubscriptionDialog parties={partyOptions} projects={projectOptions} />
<NewSubscriptionDialog
parties={partyOptions}
projects={projectOptions}
contracts={contractOptions}
/>
</PageHeader>

<TableCard>
Expand All @@ -152,14 +163,15 @@ export default async function SubscriptionsPage() {
<TableHead>{t("list.columns.plan")}</TableHead>
<TableHead>{t("list.columns.customer")}</TableHead>
<TableHead>{t("list.columns.project")}</TableHead>
<TableHead>{t("list.columns.contract")}</TableHead>
<TableHead className="text-right">{t("list.columns.amount")}</TableHead>
<TableHead>{t("list.columns.frequency")}</TableHead>
<TableHead>{t("list.columns.status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 ? (
<EmptyRow colSpan={6} message={t("list.empty")} />
<EmptyRow colSpan={7} message={t("list.empty")} />
) : (
rows.map((s) => (
<RowDialog
Expand All @@ -171,6 +183,9 @@ export default async function SubscriptionsPage() {
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell>{s.customerName ?? "—"}</TableCell>
<TableCell className="text-muted-foreground">{s.projectName ?? "—"}</TableCell>
<TableCell className="max-w-[22ch] truncate text-muted-foreground" title={s.contractTitle ?? undefined}>
{s.contractTitle ?? "—"}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatCurrency(s.amount, s.currency)}
</TableCell>
Expand All @@ -191,6 +206,7 @@ export default async function SubscriptionsPage() {
customerPartyId: s.customerPartyId,
customerName: s.customerName,
projectId: s.projectId,
contractId: s.contractId,
name: s.name,
amount: s.amount,
currency: s.currency,
Expand All @@ -202,6 +218,7 @@ export default async function SubscriptionsPage() {
}}
parties={partyOptions}
projects={projectOptions}
contracts={contractOptions}
footer={<DeleteButton action={deleteSubscription} id={s.id} />}
/>
<PeriodScheduleTable
Expand Down
17 changes: 17 additions & 0 deletions src/app/dashboard/subscriptions/subscription-fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type SubscriptionFormValues = {
/** 目前綁定的客戶名稱,給 PartyCombobox 預填 */
customerName: string | null;
projectId: number | null;
contractId: number | null;
name: string;
amount: string;
currency: string;
Expand All @@ -33,10 +34,12 @@ export type SubscriptionFormValues = {
export function SubscriptionFields({
parties,
projects,
contracts,
values,
}: Readonly<{
parties: Option[];
projects: Option[];
contracts: Option[];
values?: SubscriptionFormValues;
}>) {
const t = useTranslations("subscriptions");
Expand All @@ -61,6 +64,20 @@ export function SubscriptionFields({
</SelectItem>
))}
</SelectField>
<SelectField
name="contractId"
label={t("newDialog.contract.label")}
placeholder={t("newDialog.contract.placeholder")}
defaultValue={values?.contractId == null ? "none" : String(values.contractId)}
>
{/* 選填:Radix Select 不接受空字串,「不綁定」用 "none"(mutations 那側轉回 null)。 */}
<SelectItem value="none">{t("newDialog.contract.placeholder")}</SelectItem>
{contracts.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>
{c.name}
</SelectItem>
))}
</SelectField>
<TextField
name="name"
label={t("newDialog.name.label")}
Expand Down
20 changes: 18 additions & 2 deletions src/db/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ function num(v: FormDataEntryValue | null) {
const s = str(v);
return s === null ? null : Number(s);
}
/**
* 選填外鍵下拉:Radix Select 不接受空字串當值,「不綁定」那一項送的是 "none"
* (比照 employees 的 userId),照 num() 解會變成 NaN。
*/
function optRefNum(v: FormDataEntryValue | null) {
const s = str(v);
return s === null || s === "none" ? null : Number(s);
}

// ---- 外鍵歸屬驗證 ----

Expand Down Expand Up @@ -1795,6 +1803,7 @@ function subscriptionValues(formData: FormData) {
return {
customerPartyName: str(formData.get("customerPartyName")),
projectId: num(formData.get("projectId")),
contractId: optRefNum(formData.get("contractId")),
name: str(formData.get("name")),
amount: str(formData.get("amount")),
currency: str(formData.get("currency")) ?? "TWD",
Expand Down Expand Up @@ -1833,6 +1842,7 @@ function subscriptionColumns(
...required,
customerPartyId,
projectId: v.projectId,
contractId: v.contractId,
currency: v.currency,
intervalMonths: v.intervalMonths,
endDate: v.endDate,
Expand All @@ -1852,7 +1862,10 @@ export async function createSubscription(
try {
const { orgId } = await requireOrg();
const db = getDb();
const refError = await unownedRefError(db, orgId, [[projects, [v.projectId]]]);
const refError = await unownedRefError(db, orgId, [
[projects, [v.projectId]],
[contracts, [v.contractId]],
]);
if (refError) return { ok: false, error: refError };
const customer = await requireCustomer(db, orgId, v.customerPartyName);
if ("error" in customer) return { ok: false, error: customer.error };
Expand Down Expand Up @@ -1882,7 +1895,10 @@ export async function updateSubscription(
try {
const { orgId } = await requireOrg();
const db = getDb();
const refError = await unownedRefError(db, orgId, [[projects, [v.projectId]]]);
const refError = await unownedRefError(db, orgId, [
[projects, [v.projectId]],
[contracts, [v.contractId]],
]);
if (refError) return { ok: false, error: refError };
const customer = await requireCustomer(db, orgId, v.customerPartyName);
if ("error" in customer) return { ok: false, error: customer.error };
Expand Down
Loading
Loading