Skip to content
Open
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: 5 additions & 0 deletions .infisical.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"workspaceId": "3dbddeb7-9e1b-444c-be5a-1dedc702351c",
"defaultEnvironment": "dev",
"gitBranchToEnvironmentMapping": null
}
Binary file added deployment-architectures-screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions sonar-project.properties
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
sonar.projectKey=pathorsAI_internal
# 排除產生 / 第三方腳手架的檔案(shadcn UI、drizzle introspect schema、wrangler 型別)
sonar.exclusions=src/components/ui/**,src/db/schema.ts,cloudflare-env.d.ts,.open-next/**,.next/**,.next-prod/**
# 排除產生 / 第三方腳手架的檔案(shadcn UI、drizzle introspect schema、wrangler 型別、
# 已套用且不可改的 forward-only SQL migrations)
sonar.exclusions=src/components/ui/**,src/db/schema.ts,cloudflare-env.d.ts,.open-next/**,.next/**,.next-prod/**,migrations/**
2 changes: 1 addition & 1 deletion src/app/(dashboard)/activity/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export default async function ActivityPage() {
</TableCell>
<TableCell className="whitespace-nowrap">
{entityLabel[r.entityType] ?? r.entityType}
{r.entityId != null ? ` #${r.entityId}` : ""}
{r.entityId == null ? "" : ` #${r.entityId}`}
</TableCell>
<TableCell className="max-w-[28ch] truncate text-muted-foreground" title={r.summary ?? ""}>
{r.summary ?? "—"}
Expand Down
27 changes: 10 additions & 17 deletions src/app/(dashboard)/contracts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,14 @@ function CollectionCell({
// 進度條填色依收款狀態:溢收→支出色,已收齊→收入色,進行中→主色;無金額時留一條空軌維持等高。
const overCollected = total != null && received > total;
const fullyCollected = total != null && received >= total;
let barColor = "bg-primary";
if (overCollected) barColor = "bg-expense";
else if (fullyCollected) barColor = "bg-income";
let remainingLabel: string;
if (remaining == null) remainingLabel = "—";
else if (remaining > 0) remainingLabel = `未收 ${formatCurrency(remaining, currency)}`;
else if (remaining < 0) remainingLabel = `溢收 ${formatCurrency(-remaining, currency)}`;
else remainingLabel = "已收齊";
return (
<div className="min-w-[170px] space-y-1">
<div className="flex items-baseline justify-between gap-2 text-sm tabular-nums">
Expand All @@ -65,28 +73,13 @@ function CollectionCell({
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
{pct != null && (
<div
className={cn(
"h-full rounded-full",
overCollected
? "bg-expense"
: fullyCollected
? "bg-income"
: "bg-primary",
)}
className={cn("h-full rounded-full", barColor)}
style={{ width: `${pct}%` }}
/>
)}
</div>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground tabular-nums">
<span>
{remaining == null
? "—"
: remaining > 0
? `未收 ${formatCurrency(remaining, currency)}`
: remaining < 0
? `溢收 ${formatCurrency(-remaining, currency)}`
: "已收齊"}
</span>
<span>{remainingLabel}</span>
{cost > 0 && <span>成本 {formatCurrency(cost, currency)}</span>}
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import {

export default function DashboardLayout({
children,
}: {
}: Readonly<{
children: React.ReactNode;
}) {
}>) {
return (
<SidebarProvider>
<AppSidebar />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ const initial: ActionState = { ok: false };

export function NewReconciliationDialog({
accounts,
}: {
}: Readonly<{
accounts: { id: number; name: string; currency: string }[];
}) {
}>) {
const [open, setOpen] = useState(false);
const [state, action, pending] = useActionState(createReconciliation, initial);
const today = new Date().toISOString().slice(0, 10);
Expand Down
5 changes: 2 additions & 3 deletions src/app/(dashboard)/subscriptions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const periodStatusVariant: Record<string, "default" | "secondary" | "destructive
upcoming: "outline",
};

function PeriodScheduleTable({ schedule }: { schedule: SubscriptionSchedule | null }) {
function PeriodScheduleTable({ schedule }: Readonly<{ schedule: SubscriptionSchedule | null }>) {
if (!schedule || schedule.periods.length === 0) {
return <p className="mt-6 text-sm text-muted-foreground">尚無收款期別</p>;
}
Expand Down Expand Up @@ -91,8 +91,7 @@ function PeriodScheduleTable({ schedule }: { schedule: SubscriptionSchedule | nu
</Table>
</div>
<p className="mt-2 text-right text-sm text-muted-foreground">
未收合計:
<span className="font-medium text-foreground tabular-nums">
未收合計:<span className="font-medium text-foreground tabular-nums">
{formatCurrency(schedule.totalOutstanding, schedule.currency)}
</span>
</p>
Expand Down
4 changes: 2 additions & 2 deletions src/app/(dashboard)/transactions/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,15 +183,15 @@ const PAGE_SIZE = 50;

export default async function TransactionsPage({
searchParams,
}: {
}: Readonly<{
searchParams: Promise<{
book?: string;
category?: string;
account?: string;
period?: string;
page?: string;
}>;
}) {
}>) {
const { orgId } = await requireOrg();
const { book, category, account, period, page: pageParam } = await searchParams;
const active = (["internal", "external", "both"].includes(book ?? "") ? book : undefined) as
Expand Down
5 changes: 3 additions & 2 deletions src/app/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function slugify(name: string) {
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
.replace(/^-|-$/g, "");
}

type Invite = {
Expand Down Expand Up @@ -82,7 +82,8 @@ export default function OnboardingPage() {
async function onCreate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = new FormData(e.currentTarget);
const name = String(form.get("name")).trim();
const nameField = form.get("name");
const name = (typeof nameField === "string" ? nameField : "").trim();
const slug = slugify(name) || `org-${Date.now()}`;
setCreating(true);
const { data: org, error } = await authClient.organization.create({ name, slug });
Expand Down
2 changes: 1 addition & 1 deletion src/components/book-badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const map: Record<string, { label: string; variant: "default" | "secondary" | "o
both: { label: "內外", variant: "outline" },
};

export function BookBadge({ book }: { book: string }) {
export function BookBadge({ book }: Readonly<{ book: string }>) {
const cfg = map[book] ?? { label: book, variant: "outline" as const };
return <Badge variant={cfg.variant}>{cfg.label}</Badge>;
}
1 change: 1 addition & 0 deletions src/components/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export function Combobox({
<div
key={item}
role="option"
tabIndex={-1}
aria-selected={idx === activeIndex}
className={cn(
"cursor-default rounded-sm px-2 py-1.5 outline-none",
Expand Down
4 changes: 2 additions & 2 deletions src/components/logo.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cn } from "@/lib/utils";

/** Pathors brand mark — a minimal blue waypoint route. */
export function LogoMark({ className }: { className?: string }) {
export function LogoMark({ className }: Readonly<{ className?: string }>) {
return (
<svg
viewBox="0 0 32 32"
Expand All @@ -23,7 +23,7 @@ export function LogoMark({ className }: { className?: string }) {
}

/** Brand mark + app name, used in the sidebar header. */
export function Logo({ className }: { className?: string }) {
export function Logo({ className }: Readonly<{ className?: string }>) {
return (
<div className={cn("flex items-center gap-2", className)}>
<LogoMark className="size-6" />
Expand Down
4 changes: 2 additions & 2 deletions src/components/page-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ export function PageHeader({
title,
description,
children,
}: {
}: Readonly<{
title: React.ReactNode;
description?: string;
children?: React.ReactNode;
}) {
}>) {
return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-1">
Expand Down
10 changes: 5 additions & 5 deletions src/components/row-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
} from "@/components/ui/sheet";

const CloseCtx = React.createContext<() => void>(() => {});

// 點到列裡的按鈕 / 連結 / 輸入等互動元素 → 交給它們自己處理,不開啟編輯
function hitInteractive(target: EventTarget | null) {
return Boolean((target as HTMLElement | null)?.closest("button, a, input, label"));
}
/** 在 RowDialog 內的表單可呼叫此 hook,存檔成功後關閉對話框 */
export function useRowDialogClose() {
return React.useContext(CloseCtx);
Expand All @@ -42,12 +47,7 @@
const [open, setOpen] = React.useState(false);
const close = React.useCallback(() => setOpen(false), []);

// 點到列裡的按鈕 / 連結 / 輸入等互動元素 → 交給它們自己處理,不開啟編輯
function hitInteractive(target: EventTarget | null) {
return Boolean((target as HTMLElement | null)?.closest("button, a, input, label"));
}

function shouldOpen(target: EventTarget | null) {

Check warning on line 50 in src/components/row-dialog.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Move function 'shouldOpen' to the outer scope.

See more on https://sonarcloud.io/project/issues?id=pathorsAI_internal&issues=AZ9Cywp0bvnph_-cnngX&open=AZ9Cywp0bvnph_-cnngX&pullRequest=27
// 有對話框正開著 / 正在關閉時(例如點灰色背景關閉發放薪資 dialog,
// 那個點擊會「穿透」到下面的列)→ 不要開啟這一列的編輯
if (
Expand Down
10 changes: 6 additions & 4 deletions src/components/table-skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,19 @@ export function TableSkeleton({
rows = 8,
cols = 5,
}: Readonly<{ rows?: number; cols?: number }>) {
const rowKeys = Array.from({ length: rows }, (_, i) => `row-${i}`);
const colKeys = Array.from({ length: cols }, (_, i) => `col-${i}`);
return (
<div className="overflow-hidden rounded-xl border">
<div className="border-b px-4 py-2.5">
<Skeleton className="h-4 w-24" />
</div>
<div className="divide-y">
{Array.from({ length: rows }).map((_, r) => (
<div key={r} className="flex items-center gap-4 px-4 py-3.5">
{Array.from({ length: cols }).map((_, c) => (
{rowKeys.map((rowKey) => (
<div key={rowKey} className="flex items-center gap-4 px-4 py-3.5">
{colKeys.map((colKey, c) => (
<Skeleton
key={c}
key={colKey}
className={cn(
"h-4",
c === 0 && "w-24",
Expand Down
2 changes: 1 addition & 1 deletion src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -938,7 +938,7 @@ export function computeSubscriptionSchedule(
// 有應收覆寫的期別也一律納入。
for (const p of explicitPeriods) starts.add(p.periodStart);

const sorted = [...starts].sort();
const sorted = [...starts].sort((a, b) => a.localeCompare(b));
let totalOutstanding = 0;
const periods: SubscriptionPeriod[] = sorted.map((periodStart) => {
const override = overrideByStart.get(periodStart);
Expand Down
6 changes: 3 additions & 3 deletions src/hooks/use-mobile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)

React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const mql = globalThis.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
setIsMobile(globalThis.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
setIsMobile(globalThis.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])

Expand Down
81 changes: 44 additions & 37 deletions src/lib/mcp/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,48 @@ function categoryTag(a: ToolAnnotations): string {
return "[write]";
}

async function handleToolCall(
id: JsonRpcId,
params: Record<string, unknown> | undefined,
ctx: ToolContext,
): Promise<object> {
const name = params?.name as string | undefined;
const args = (params?.arguments as Record<string, unknown>) ?? {};
const tool = name ? tools[name] : undefined;
if (!tool) {
return ok(id, {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true,
});
}
try {
const out = await tool.execute(args, ctx);
// Best-effort audit log for MCP writes (never affects the response).
const audit = name ? deriveMcpAudit(name, out) : null;
if (audit) {
try {
const orgId = await resolveOrg(args, ctx);
await logMcp(orgId, ctx.userId, audit.action, audit.entityType, audit.entityId, name);
} catch {
// ignore audit failures
}
}
return ok(id, {
content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
});
} catch (err) {
return ok(id, {
content: [
{
type: "text",
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
});
}
}

async function handleMessage(
msg: JsonRpcMessage,
ctx: ToolContext,
Expand Down Expand Up @@ -118,43 +160,8 @@ async function handleMessage(
}),
});

case "tools/call": {
const name = msg.params?.name as string | undefined;
const args = (msg.params?.arguments as Record<string, unknown>) ?? {};
const tool = name ? tools[name] : undefined;
if (!tool) {
return ok(id, {
content: [{ type: "text", text: `Unknown tool: ${name}` }],
isError: true,
});
}
try {
const out = await tool.execute(args, ctx);
// Best-effort audit log for MCP writes (never affects the response).
const audit = name ? deriveMcpAudit(name, out) : null;
if (audit) {
try {
const orgId = await resolveOrg(args, ctx);
await logMcp(orgId, ctx.userId, audit.action, audit.entityType, audit.entityId, name);
} catch {
// ignore audit failures
}
}
return ok(id, {
content: [{ type: "text", text: JSON.stringify(out, null, 2) }],
});
} catch (err) {
return ok(id, {
content: [
{
type: "text",
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
},
],
isError: true,
});
}
}
case "tools/call":
return handleToolCall(id, msg.params, ctx);

default:
if (isNotification) return null;
Expand Down
Loading