diff --git a/src/app/dashboard/billing/page.tsx b/src/app/dashboard/billing/page.tsx
index 1600cd2..c96cd05 100644
--- a/src/app/dashboard/billing/page.tsx
+++ b/src/app/dashboard/billing/page.tsx
@@ -110,6 +110,36 @@ function InvoiceCell({ row, t }: Readonly<{ row: BillingRow; t: T }>) {
* 開發票走預填草稿,只有 billing_items 有實體列可綁;訂閱期別沒有 id 可綁,
* 退回單純的日期標記,發票本身到發票頁建立。
*/
+/**
+ * 項目底下那行來源說明:訂閱期別連回訂閱、掛了合約的一次性項目連回合約,
+ * 其餘(只有專案或什麼都沒綁)維持純文字。
+ */
+function SourceLine({ row, t }: Readonly<{ row: BillingRow; t: T }>) {
+ if (row.source === "subscription") {
+ const label = t("table.subscriptionSource");
+ if (row.subscriptionId == null) return <>{label}>;
+ return (
+
+ {label}
+
+ );
+ }
+ if (row.contractId != null && row.contractTitle) {
+ return (
+
+ {row.contractTitle}
+
+ );
+ }
+ return <>{row.projectName ?? t("table.oneTimeSource")}>;
+}
+
function RowActions({ row }: Readonly<{ row: BillingRow }>) {
const itemId = row.source === "billing_item" ? row.billingItemId : null;
const outstanding = row.expected - row.paid;
@@ -235,9 +265,7 @@ export default async function BillingPage({
{row.title}
- {row.source === "subscription"
- ? t("table.subscriptionSource")
- : (row.contractTitle ?? row.projectName ?? t("table.oneTimeSource"))}
+
(
-
{s.name}
+
+ {s.name}
+
{formatCurrency(s.amount, s.currency)} · {intervalLabel(t, s.intervalMonths)}
diff --git a/src/app/dashboard/contracts/page.tsx b/src/app/dashboard/contracts/page.tsx
index d5f975d..0936e50 100644
--- a/src/app/dashboard/contracts/page.tsx
+++ b/src/app/dashboard/contracts/page.tsx
@@ -156,6 +156,7 @@ export default async function ContractsPage() {
rows.map((c) => (
(
(
(
(
{s.name}
{s.customerName ?? "—"}
{s.projectName ?? "—"}
-
- {s.contractTitle ?? "—"}
+
+ {s.contractId != null && s.contractTitle ? (
+
+ {s.contractTitle}
+
+ ) : (
+ {s.contractTitle ?? "—"}
+ )}
{formatCurrency(s.amount, s.currency)}
@@ -200,6 +212,17 @@ export default async function SubscriptionsPage() {
>
}
>
+ {s.contractId != null && s.contractTitle ? (
+
+ {t("list.contractLinkLabel")}
+
+ {s.contractTitle} →
+
+
+ ) : null}
從網址上拿掉,其餘 query 原樣保留 */
+function urlWithout(params: URLSearchParams, pathname: string, key: string) {
+ const next = new URLSearchParams(params);
+ next.delete(key);
+ const query = next.toString();
+ return query ? `${pathname}?${query}` : pathname;
+}
+
+/**
+ * 深連結:網址帶 `?open=` 時自動打開這一列的編輯視窗。
+ * 本身不畫任何東西,只負責讀網址、開視窗、關掉後把參數清掉。
+ *
+ * `useSearchParams` 必須包在 Suspense 裡(見下方呼叫端),所以獨立成一個小元件。
+ */
+function RowDeepLink({
+ rowId,
+ openParam,
+ open,
+ setOpen,
+ rowRef,
+}: Readonly<{
+ rowId: number | string;
+ openParam: string;
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ rowRef: React.RefObject;
+}>) {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const pathname = usePathname();
+ const matched = searchParams.get(openParam) === String(rowId);
+ // 只有「被打開過又關掉」才需要清網址;避免掛載當下就把參數清掉。
+ const wasOpen = React.useRef(false);
+
+ // 用 effect 而不是 initial state,server / client 首次 render 才會一致。
+ React.useEffect(() => {
+ if (matched) setOpen(true);
+ }, [matched, setOpen]);
+
+ React.useEffect(() => {
+ if (open) {
+ wasOpen.current = true;
+ // 關掉視窗後看得到自己剛剛點的是哪一列
+ if (matched) rowRef.current?.scrollIntoView({ block: "center" });
+ return;
+ }
+ if (!wasOpen.current || !matched) return;
+ wasOpen.current = false;
+ router.replace(urlWithout(searchParams, pathname, openParam), { scroll: false });
+ }, [open, matched, openParam, pathname, router, searchParams, rowRef]);
+
+ return null;
+}
+
export function RowDialog({
cells,
title,
description,
children,
variant = "dialog",
+ rowId,
+ openParam = "open",
}: Readonly<{
/** 們 */
cells: React.ReactNode;
@@ -57,9 +114,14 @@ export function RowDialog({
children: React.ReactNode;
/** "dialog" 置中彈窗(預設);"sheet" 從右側滑出 */
variant?: "dialog" | "sheet";
+ /** 給了就支援 `?open=` 深連結 */
+ rowId?: number | string;
+ /** 深連結用的 query 參數名,預設 `open` */
+ openParam?: string;
}>) {
const [open, setOpen] = React.useState(false);
const close = React.useCallback(() => setOpen(false), []);
+ const rowRef = React.useRef(null);
function onRowClick(e: React.MouseEvent) {
if (shouldOpen(e.target)) setOpen(true);
@@ -75,6 +137,7 @@ export function RowDialog({
const row = (
);
+ const deepLink =
+ rowId == null ? null : (
+
+
+
+ );
+
if (variant === "sheet") {
return (
<>
{row}
+ {deepLink}
@@ -109,6 +186,7 @@ export function RowDialog({
return (
<>
{row}
+ {deepLink}