From 34e66da1d355dd730448a3a92dfc4c429828b76e Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Tue, 8 Sep 2026 00:52:23 +0800 Subject: [PATCH] =?UTF-8?q?[feature]=20=E5=88=97=E8=A1=A8=E6=94=AF?= =?UTF-8?q?=E6=8F=B4=20=3Fopen=3D=20=E6=B7=B1=E9=80=A3=E7=B5=90?= =?UTF-8?q?=EF=BC=8C=E8=A8=82=E9=96=B1=E8=88=87=E5=90=88=E7=B4=84=E5=8F=AF?= =?UTF-8?q?=E4=BA=92=E7=9B=B8=E8=B7=B3=E8=BD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/dashboard/billing/page.tsx | 34 +++++++- .../contracts/contract-subscriptions.tsx | 7 +- src/app/dashboard/contracts/page.tsx | 1 + src/app/dashboard/invoices/page.tsx | 1 + src/app/dashboard/parties/page.tsx | 1 + src/app/dashboard/projects/page.tsx | 1 + src/app/dashboard/subscriptions/page.tsx | 27 ++++++- src/app/dashboard/transactions/page.tsx | 1 + src/components/row-dialog.tsx | 78 +++++++++++++++++++ src/i18n/messages/subscriptions.ts | 1 + 10 files changed, 146 insertions(+), 6 deletions(-) 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} diff --git a/src/i18n/messages/subscriptions.ts b/src/i18n/messages/subscriptions.ts index 6d8a0b9..cdf92e0 100644 --- a/src/i18n/messages/subscriptions.ts +++ b/src/i18n/messages/subscriptions.ts @@ -23,6 +23,7 @@ const subscriptions = { description: { "zh-TW": "客戶定期收費的方案,點列可編輯", en: "Recurring billing plans for clients — click a row to edit" }, newButton: { "zh-TW": "新增訂閱", en: "Add subscription" }, rowDescription: { "zh-TW": "訂閱 / 月費", en: "Subscription" }, + contractLinkLabel: { "zh-TW": "合約:", en: "Contract: " }, columns: { plan: { "zh-TW": "方案", en: "Plan" }, customer: { "zh-TW": "客戶", en: "Client" },