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
34 changes: 31 additions & 3 deletions src/app/dashboard/billing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Link
href={`/dashboard/subscriptions?open=${row.subscriptionId}`}
className="text-primary hover:underline"
>
{label}
</Link>
);
}
if (row.contractId != null && row.contractTitle) {
return (
<Link
href={`/dashboard/contracts?open=${row.contractId}`}
className="text-primary hover:underline"
>
{row.contractTitle}
</Link>
);
}
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;
Expand Down Expand Up @@ -235,9 +265,7 @@ export default async function BillingPage({
{row.title}
</div>
<div className="truncate text-xs text-muted-foreground">
{row.source === "subscription"
? t("table.subscriptionSource")
: (row.contractTitle ?? row.projectName ?? t("table.oneTimeSource"))}
<SourceLine row={row} t={t} />
</div>
</TableCell>
<TableCell
Expand Down
7 changes: 6 additions & 1 deletion src/app/dashboard/contracts/contract-subscriptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ export async function ContractSubscriptions({
{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>
<Link
href={`/dashboard/subscriptions?open=${s.id}`}
className="block truncate font-medium text-primary hover:underline"
>
{s.name}
</Link>
<div className="text-xs text-muted-foreground tabular-nums">
{formatCurrency(s.amount, s.currency)} · {intervalLabel(t, s.intervalMonths)}
</div>
Expand Down
1 change: 1 addition & 0 deletions src/app/dashboard/contracts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export default async function ContractsPage() {
rows.map((c) => (
<RowDialog
key={c.id}
rowId={c.id}
title={c.title}
description={t("list.rowDescription")}
cells={
Expand Down
1 change: 1 addition & 0 deletions src/app/dashboard/invoices/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export default async function InvoicesPage({
rows.map((inv) => (
<RowDialog
key={inv.id}
rowId={inv.id}
title={inv.invoiceNumber ?? t("defaultTitle")}
description={inv.displayName ?? undefined}
cells={
Expand Down
1 change: 1 addition & 0 deletions src/app/dashboard/parties/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export default async function PartiesPage() {
rows.map((s) => (
<RowDialog
key={s.id}
rowId={s.id}
title={s.name}
description={t("rowDialogDescription")}
cells={
Expand Down
1 change: 1 addition & 0 deletions src/app/dashboard/projects/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export default async function ProjectsPage() {
rows.map((p) => (
<RowDialog
key={p.id}
rowId={p.id}
title={p.name}
description={t("list.rowDescription")}
cells={
Expand Down
27 changes: 25 additions & 2 deletions src/app/dashboard/subscriptions/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { RowDialog } from "@/components/row-dialog";
import { DeleteButton } from "@/components/delete-button";
Expand Down Expand Up @@ -176,15 +177,26 @@ export default async function SubscriptionsPage() {
rows.map((s) => (
<RowDialog
key={s.id}
rowId={s.id}
title={s.name}
description={t("list.rowDescription")}
cells={
<>
<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 className="max-w-[22ch] text-muted-foreground">
{s.contractId != null && s.contractTitle ? (
<Link
href={`/dashboard/contracts?open=${s.contractId}`}
title={s.contractTitle}
className="block truncate text-primary hover:underline"
>
{s.contractTitle}
</Link>
) : (
<span className="block truncate">{s.contractTitle ?? "—"}</span>
)}
</TableCell>
<TableCell className="text-right font-medium tabular-nums">
{formatCurrency(s.amount, s.currency)}
Expand All @@ -200,6 +212,17 @@ export default async function SubscriptionsPage() {
</>
}
>
{s.contractId != null && s.contractTitle ? (
<p className="mb-3 text-sm">
<span className="text-muted-foreground">{t("list.contractLinkLabel")}</span>
<Link
href={`/dashboard/contracts?open=${s.contractId}`}
className="text-primary hover:underline"
>
{s.contractTitle} →
</Link>
</p>
) : null}
<EditSubscriptionForm
subscription={{
id: s.id,
Expand Down
1 change: 1 addition & 0 deletions src/app/dashboard/transactions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ function TransactionRow({
return (
<RowDialog
variant="sheet"
rowId={t.id}
title={editDialogTitle}
description={editDialogDescription}
cells={
Expand Down
78 changes: 78 additions & 0 deletions src/components/row-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import * as React from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { TableRow } from "@/components/ui/table";
import {
Dialog,
Expand Down Expand Up @@ -42,12 +43,68 @@ export function useRowDialogClose() {
return React.useContext(CloseCtx);
}

/** 把 ?open=<id> 從網址上拿掉,其餘 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=<rowId>` 時自動打開這一列的編輯視窗。
* 本身不畫任何東西,只負責讀網址、開視窗、關掉後把參數清掉。
*
* `useSearchParams` 必須包在 Suspense 裡(見下方呼叫端),所以獨立成一個小元件。
*/
function RowDeepLink({
rowId,
openParam,
open,
setOpen,
rowRef,
}: Readonly<{
rowId: number | string;
openParam: string;
open: boolean;
setOpen: (open: boolean) => void;
rowRef: React.RefObject<HTMLTableRowElement | null>;
}>) {
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<{
/** <TableCell> 們 */
cells: React.ReactNode;
Expand All @@ -57,9 +114,14 @@ export function RowDialog({
children: React.ReactNode;
/** "dialog" 置中彈窗(預設);"sheet" 從右側滑出 */
variant?: "dialog" | "sheet";
/** 給了就支援 `?open=<rowId>` 深連結 */
rowId?: number | string;
/** 深連結用的 query 參數名,預設 `open` */
openParam?: string;
}>) {
const [open, setOpen] = React.useState(false);
const close = React.useCallback(() => setOpen(false), []);
const rowRef = React.useRef<HTMLTableRowElement | null>(null);

function onRowClick(e: React.MouseEvent<HTMLTableRowElement>) {
if (shouldOpen(e.target)) setOpen(true);
Expand All @@ -75,6 +137,7 @@ export function RowDialog({

const row = (
<TableRow
ref={rowRef}
role="button"
tabIndex={0}
aria-haspopup="dialog"
Expand All @@ -86,10 +149,24 @@ export function RowDialog({
</TableRow>
);

const deepLink =
rowId == null ? null : (
<React.Suspense fallback={null}>
<RowDeepLink
rowId={rowId}
openParam={openParam}
open={open}
setOpen={setOpen}
rowRef={rowRef}
/>
</React.Suspense>
);

if (variant === "sheet") {
return (
<>
{row}
{deepLink}
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent className="data-[side=right]:sm:max-w-xl">
<SheetHeader className="shrink-0">
Expand All @@ -109,6 +186,7 @@ export function RowDialog({
return (
<>
{row}
{deepLink}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
<DialogHeader className="shrink-0 px-4 pt-4 pb-3">
Expand Down
1 change: 1 addition & 0 deletions src/i18n/messages/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down