diff --git a/__tests__/components/ApprovalTargetCard.test.tsx b/__tests__/components/ApprovalTargetCard.test.tsx
new file mode 100644
index 0000000..ce73158
--- /dev/null
+++ b/__tests__/components/ApprovalTargetCard.test.tsx
@@ -0,0 +1,49 @@
+/**
+ * Tests for ApprovalTargetCard — renders the target record's title plus its
+ * populated business fields (skipping system/empty fields).
+ */
+import React from "react";
+import { render } from "@testing-library/react-native";
+
+jest.mock("react-i18next", () => ({
+ initReactI18next: { type: "3rdParty", init: () => {} },
+ useTranslation: () => ({ t: (k: string) => k }),
+}));
+
+import { ApprovalTargetCard } from "~/components/approvals/ApprovalTargetCard";
+import type { FieldDefinition } from "~/components/renderers";
+import type { ObjectMeta } from "~/hooks/useObjectMeta";
+
+const meta = { name: "server_item", label: "Server Item" } as ObjectMeta;
+const fields: FieldDefinition[] = [
+ { name: "name", label: "Name", type: "text" } as FieldDefinition,
+ { name: "description", label: "Description", type: "textarea" } as FieldDefinition,
+ { name: "created_at", label: "Created At", type: "datetime" } as FieldDefinition,
+];
+
+describe("ApprovalTargetCard", () => {
+ it("renders the object label, record title, and populated fields", () => {
+ const record = {
+ id: "1",
+ name: "Wayne Enterprise License",
+ description: "Needs VP sign-off.",
+ created_at: "2026-06-02T00:00:00Z",
+ };
+ const { getByText, getAllByText, queryByText } = render(
+ ,
+ );
+ expect(getByText("Server Item")).toBeTruthy();
+ // Title resolves to the record's name (appears as title + field value).
+ expect(getAllByText("Wayne Enterprise License").length).toBeGreaterThan(0);
+ expect(getByText("Needs VP sign-off.")).toBeTruthy();
+ // System field is not previewed.
+ expect(queryByText("Created At")).toBeNull();
+ });
+
+ it("shows a placeholder when the record is missing", () => {
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText("—")).toBeTruthy();
+ });
+});
diff --git a/__tests__/hooks/useApprovals.test.tsx b/__tests__/hooks/useApprovals.test.tsx
index 82572c5..8d592fc 100644
--- a/__tests__/hooks/useApprovals.test.tsx
+++ b/__tests__/hooks/useApprovals.test.tsx
@@ -8,11 +8,18 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const mockFind = jest.fn();
const mockUpdate = jest.fn();
+const mockGet = jest.fn();
jest.mock("@objectstack/client-react", () => ({
- useClient: () => ({ data: { find: mockFind, update: mockUpdate } }),
+ useClient: () => ({ data: { find: mockFind, update: mockUpdate, get: mockGet } }),
}));
-import { useApprovals, useDecideApproval, type ApprovalRequest } from "~/hooks/useApprovals";
+import {
+ useApprovals,
+ useApproval,
+ useApprovalTarget,
+ useDecideApproval,
+ type ApprovalRequest,
+} from "~/hooks/useApprovals";
function wrapper({ children }: { children: React.ReactNode }) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -31,6 +38,7 @@ const REQ: ApprovalRequest = {
beforeEach(() => {
mockFind.mockReset();
mockUpdate.mockReset().mockResolvedValue({});
+ mockGet.mockReset();
});
describe("useApprovals", () => {
@@ -54,6 +62,35 @@ describe("useApprovals", () => {
});
});
+describe("useApproval", () => {
+ it("fetches a single request by id", async () => {
+ mockGet.mockResolvedValue({ record: REQ });
+ const { result } = renderHook(() => useApproval("ar1"), { wrapper });
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(mockGet).toHaveBeenCalledWith("sys_approval_request", "ar1");
+ expect(result.current.data).toEqual(REQ);
+ });
+});
+
+describe("useApprovalTarget", () => {
+ it("fetches the business record named by the request", async () => {
+ mockGet.mockResolvedValue({ record: { id: "opp1", name: "Acme Deal" } });
+ const { result } = renderHook(() => useApprovalTarget(REQ), { wrapper });
+ await waitFor(() => expect(result.current.record).toBeTruthy());
+ expect(mockGet).toHaveBeenCalledWith("crm_opportunity", "opp1");
+ expect(result.current.record).toEqual({ id: "opp1", name: "Acme Deal" });
+ });
+
+ it("does not fetch when the request has no target", async () => {
+ const { result } = renderHook(() => useApprovalTarget({ id: "x", status: "pending" }), {
+ wrapper,
+ });
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
+ expect(mockGet).not.toHaveBeenCalled();
+ expect(result.current.record).toBeNull();
+ });
+});
+
describe("useDecideApproval", () => {
it("approve sets status=approved on the request row", async () => {
const { result } = renderHook(() => useDecideApproval(), { wrapper });
diff --git a/app/approvals/[id].tsx b/app/approvals/[id].tsx
new file mode 100644
index 0000000..42686b4
--- /dev/null
+++ b/app/approvals/[id].tsx
@@ -0,0 +1,109 @@
+import { View, Text, ScrollView } from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { useLocalSearchParams, useRouter } from "expo-router";
+import { useTranslation } from "react-i18next";
+import { Inbox } from "lucide-react-native";
+import { ScreenHeader } from "~/components/common/ScreenHeader";
+import { Badge } from "~/components/ui/Badge";
+import { EmptyState } from "~/components/ui/EmptyState";
+import { ListSkeleton } from "~/components/ui/ListSkeleton";
+import { ApprovalActions } from "~/components/approvals/ApprovalActions";
+import { ApprovalTargetCard } from "~/components/approvals/ApprovalTargetCard";
+import { useObjectMeta } from "~/hooks/useObjectMeta";
+import { useApproval, useApprovalTarget } from "~/hooks/useApprovals";
+import { formatDateTime } from "~/lib/formatting";
+
+function humanize(token: string): string {
+ return token.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+/**
+ * Approval detail — review the record under approval (title + key fields) and
+ * the request context before approving or rejecting.
+ */
+export default function ApprovalDetailScreen() {
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const approvalId = Array.isArray(id) ? id[0] : id;
+ const router = useRouter();
+ const { t } = useTranslation();
+
+ const { data: req, isLoading, error } = useApproval(approvalId);
+ const { meta, fields } = useObjectMeta(req?.object_name);
+ const { record: target, isLoading: targetLoading } = useApprovalTarget(req);
+
+ if (isLoading) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (error || !req) {
+ return (
+
+
+
+
+ );
+ }
+
+ const objectLabel = meta?.label ?? (req.object_name ? humanize(req.object_name) : "Record");
+ const isPending = (req.status ?? "pending") === "pending";
+
+ return (
+
+
+
+
+ {/* Request context */}
+
+
+ {req.current_step ? {req.current_step} : null}
+
+ {req.status ? humanize(req.status) : humanize("pending")}
+
+
+ {req.submitter_comment ? (
+ {req.submitter_comment}
+ ) : null}
+ {req.created_at ? (
+
+ {formatDateTime(req.created_at)}
+
+ ) : null}
+
+
+ {/* Record under review */}
+ {targetLoading ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Decision bar */}
+ {isPending ? (
+
+
+ router.canGoBack() ? router.back() : router.replace("/approvals")
+ }
+ />
+
+ ) : null}
+
+ );
+}
diff --git a/app/approvals/index.tsx b/app/approvals/index.tsx
index 3195619..1fdfd10 100644
--- a/app/approvals/index.tsx
+++ b/app/approvals/index.tsx
@@ -1,127 +1,72 @@
-import { useState } from "react";
-import { View, Text, ScrollView, Pressable, ActivityIndicator } from "react-native";
+import { View, Text, ScrollView } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
+import { useRouter } from "expo-router";
import { useTranslation } from "react-i18next";
-import { Inbox, Check, X } from "lucide-react-native";
+import { Inbox, ChevronRight } from "lucide-react-native";
import { ScreenHeader } from "~/components/common/ScreenHeader";
import { Badge } from "~/components/ui/Badge";
import { EmptyState } from "~/components/ui/EmptyState";
import { ListSkeleton } from "~/components/ui/ListSkeleton";
-import { useToast } from "~/components/ui/Toast";
-import { useConfirm } from "~/components/ui/ConfirmDialog";
-import { RejectReasonDialog } from "~/components/approvals/RejectReasonDialog";
+import { PressableCard } from "~/components/ui/PressableCard";
+import { ApprovalActions } from "~/components/approvals/ApprovalActions";
import { formatDateTime } from "~/lib/formatting";
-import {
- useApprovals,
- useDecideApproval,
- type ApprovalRequest,
-} from "~/hooks/useApprovals";
+import { useApprovals, type ApprovalRequest } from "~/hooks/useApprovals";
-function ApprovalCard({
- req,
- busy,
- onApprove,
- onReject,
-}: {
- req: ApprovalRequest;
- busy: boolean;
- onApprove: () => void;
- onReject: () => void;
-}) {
+function humanize(token: string): string {
+ return token.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+function ApprovalCard({ req, onOpen }: { req: ApprovalRequest; onOpen: () => void }) {
const { t } = useTranslation();
return (
-
-
- {req.process_name ?? t("approvals.title")}
-
- {req.current_step ? {req.current_step} : null}
-
- {req.object_name && req.record_id ? (
-
- {req.object_name} · {req.record_id}
-
- ) : null}
- {req.submitter_comment ? (
- {req.submitter_comment}
- ) : null}
- {req.created_at ? (
- {formatDateTime(req.created_at)}
- ) : null}
-
-
-
- {busy ? (
-
- ) : (
-
- )}
-
- {t("approvals.approve")}
+
+
+
+ {req.process_name ?? t("approvals.title")}
-
-
-
- {t("approvals.reject")}
-
+ {req.object_name ? (
+ {humanize(req.object_name)}
+ ) : null}
+ {req.submitter_comment ? (
+
+ {req.submitter_comment}
+
+ ) : null}
+ {req.created_at ? (
+
+ {formatDateTime(req.created_at)}
+
+ ) : null}
+
+
+ {req.current_step ? {req.current_step} : null}
+
+
+
+
+
+
);
}
/**
- * Approvals inbox — lists the current user's pending approval requests and lets
- * them approve (with an optional comment) or reject (with a required reason).
- * Surfaces the previously-unused workflow approve/reject API.
+ * Approvals inbox — the current user's pending requests. Each row links to a
+ * review screen (the record under approval) and offers quick approve / reject.
*/
export default function ApprovalsScreen() {
const { t } = useTranslation();
- const { toastSuccess, toastError } = useToast();
- const confirm = useConfirm();
+ const router = useRouter();
const { data: requests, isLoading, error, refetch, isRefetching } = useApprovals();
- const { approve, reject, pendingId } = useDecideApproval();
-
- const [rejectTarget, setRejectTarget] = useState(null);
const count = requests?.length ?? 0;
- const handleApprove = async (req: ApprovalRequest) => {
- const ok = await confirm({
- title: t("approvals.approve"),
- message: t("approvals.approveConfirm", { name: req.process_name ?? req.id }),
- confirmLabel: t("approvals.approve"),
- });
- if (!ok) return;
- const res = await approve(req);
- if (res.ok) toastSuccess(t("approvals.approved"));
- else toastError(res.error ?? t("approvals.decisionFailed"));
- };
-
- const handleReject = async (reason: string) => {
- const req = rejectTarget;
- setRejectTarget(null);
- if (!req) return;
- const res = await reject(req, reason);
- if (res.ok) toastSuccess(t("approvals.rejected"));
- else toastError(res.error ?? t("approvals.decisionFailed"));
- };
-
return (
void handleApprove(req)}
- onReject={() => setRejectTarget(req)}
+ onOpen={() => router.push(`/approvals/${encodeURIComponent(req.id)}`)}
/>
))}
)}
-
- setRejectTarget(null)}
- onReject={(reason) => void handleReject(reason)}
- />
);
}
diff --git a/components/approvals/ApprovalActions.tsx b/components/approvals/ApprovalActions.tsx
new file mode 100644
index 0000000..6f209f9
--- /dev/null
+++ b/components/approvals/ApprovalActions.tsx
@@ -0,0 +1,100 @@
+import React from "react";
+import { View, Text, Pressable, ActivityIndicator } from "react-native";
+import { useTranslation } from "react-i18next";
+import { Check, X } from "lucide-react-native";
+import { useToast } from "~/components/ui/Toast";
+import { useConfirm } from "~/components/ui/ConfirmDialog";
+import { RejectReasonDialog } from "./RejectReasonDialog";
+import { useDecideApproval, type ApprovalRequest } from "~/hooks/useApprovals";
+
+export interface ApprovalActionsProps {
+ req: ApprovalRequest;
+ /** Called after a successful approve/reject (e.g. to navigate back). */
+ onDecided?: () => void;
+}
+
+/**
+ * Approve / reject buttons for one request, with the confirm + reject-reason
+ * dialogs and toasts. Shared by the inbox card and the approval detail screen so
+ * the decision flow is identical everywhere.
+ */
+export function ApprovalActions({ req, onDecided }: ApprovalActionsProps) {
+ const { t } = useTranslation();
+ const { toastSuccess, toastError } = useToast();
+ const confirm = useConfirm();
+ const { approve, reject, pendingId } = useDecideApproval();
+ const [rejecting, setRejecting] = React.useState(false);
+
+ const busy = pendingId === req.id;
+
+ const handleApprove = async () => {
+ const ok = await confirm({
+ title: t("approvals.approve"),
+ message: t("approvals.approveConfirm", { name: req.process_name ?? req.id }),
+ confirmLabel: t("approvals.approve"),
+ });
+ if (!ok) return;
+ const res = await approve(req);
+ if (res.ok) {
+ toastSuccess(t("approvals.approved"));
+ onDecided?.();
+ } else {
+ toastError(res.error ?? t("approvals.decisionFailed"));
+ }
+ };
+
+ const handleReject = async (reason: string) => {
+ setRejecting(false);
+ const res = await reject(req, reason);
+ if (res.ok) {
+ toastSuccess(t("approvals.rejected"));
+ onDecided?.();
+ } else {
+ toastError(res.error ?? t("approvals.decisionFailed"));
+ }
+ };
+
+ return (
+ <>
+
+
+ {busy ? (
+
+ ) : (
+
+ )}
+
+ {t("approvals.approve")}
+
+
+ setRejecting(true)}
+ disabled={busy}
+ accessibilityRole="button"
+ accessibilityLabel={`${t("approvals.reject")} ${req.process_name ?? req.id}`}
+ className={`flex-1 flex-row items-center justify-center gap-1.5 rounded-lg border border-destructive px-3 py-2.5 ${
+ busy ? "opacity-50" : "active:bg-destructive/10"
+ }`}
+ >
+
+ {t("approvals.reject")}
+
+
+
+ setRejecting(false)}
+ onReject={(reason) => void handleReject(reason)}
+ />
+ >
+ );
+}
diff --git a/components/approvals/ApprovalTargetCard.tsx b/components/approvals/ApprovalTargetCard.tsx
new file mode 100644
index 0000000..d3ed166
--- /dev/null
+++ b/components/approvals/ApprovalTargetCard.tsx
@@ -0,0 +1,82 @@
+import React from "react";
+import { View, Text } from "react-native";
+import { FieldRenderer } from "~/components/renderers/fields/FieldRenderer";
+import { renderRecordTitle } from "~/lib/record-title";
+import type { FieldDefinition } from "~/components/renderers";
+import type { ObjectMeta } from "~/hooks/useObjectMeta";
+
+const SYSTEM_FIELDS = new Set([
+ "created_at",
+ "updated_at",
+ "modified_at",
+ "last_modified_at",
+ "created_by",
+ "updated_by",
+ "modified_by",
+ "last_modified_by",
+ "organization_id",
+]);
+
+/** How many populated business fields to preview under the title. */
+const MAX_FIELDS = 8;
+
+export interface ApprovalTargetCardProps {
+ objectLabel: string;
+ meta: ObjectMeta | null;
+ fields: FieldDefinition[];
+ record: Record | null;
+}
+
+/**
+ * A read-only summary of the business record an approval is about: its title
+ * plus the first few populated fields — so the approver can review before
+ * deciding.
+ */
+export function ApprovalTargetCard({
+ objectLabel,
+ meta,
+ fields,
+ record,
+}: ApprovalTargetCardProps) {
+ const title = renderRecordTitle(meta, record, objectLabel);
+
+ const previewFields = React.useMemo(() => {
+ if (!record) return [];
+ return fields
+ .filter((f) => {
+ if (f.name.startsWith("_") || f.name === "id") return false;
+ if (SYSTEM_FIELDS.has(f.name)) return false;
+ const v = record[f.name];
+ return v != null && v !== "";
+ })
+ .slice(0, MAX_FIELDS);
+ }, [fields, record]);
+
+ return (
+
+
+ {objectLabel}
+ {title}
+
+
+ {previewFields.length === 0 ? (
+ —
+ ) : (
+ previewFields.map((f) => (
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/hooks/useApprovals.ts b/hooks/useApprovals.ts
index d8af29b..1726031 100644
--- a/hooks/useApprovals.ts
+++ b/hooks/useApprovals.ts
@@ -51,6 +51,41 @@ export function useApprovals(): UseQueryResult {
});
}
+/** Fetch a single approval request by id. */
+export function useApproval(
+ id: string | undefined,
+): UseQueryResult {
+ const client = useClient();
+ return useQuery({
+ queryKey: ["approval", id],
+ enabled: !!id,
+ queryFn: async () => {
+ const res = await client.data.get("sys_approval_request", id!);
+ return (res?.record ?? null) as ApprovalRequest | null;
+ },
+ });
+}
+
+/** The business record an approval request is about, plus its object metadata. */
+export function useApprovalTarget(req: ApprovalRequest | null | undefined): {
+ record: Record | null;
+ isLoading: boolean;
+ error: Error | null;
+} {
+ const client = useClient();
+ const object = req?.object_name;
+ const recordId = req?.record_id;
+ const { data, isLoading, error } = useQuery({
+ queryKey: ["approval-target", object, recordId],
+ enabled: !!object && !!recordId,
+ queryFn: async () => {
+ const res = await client.data.get>(object!, recordId!);
+ return (res?.record ?? res ?? null) as Record | null;
+ },
+ });
+ return { record: data ?? null, isLoading, error: error ?? null };
+}
+
/* ------------------------------------------------------------------ */
/* Decide */
/* ------------------------------------------------------------------ */
@@ -95,6 +130,7 @@ export function useDecideApproval(): {
} finally {
setPendingId(null);
void queryClient.invalidateQueries({ queryKey: PENDING_KEY });
+ void queryClient.invalidateQueries({ queryKey: ["approval"] });
}
},
[client, queryClient],
diff --git a/locales/ar.json b/locales/ar.json
index eb526b1..3f542d2 100644
--- a/locales/ar.json
+++ b/locales/ar.json
@@ -96,7 +96,8 @@
"rejected": "تم الرفض.",
"decisionFailed": "فشلت العملية.",
"rejectReason": "يرجى تقديم سبب الرفض.",
- "rejectReasonPlaceholder": "السبب…"
+ "rejectReasonPlaceholder": "السبب…",
+ "review": "مراجعة"
},
"actions": {
"title": "إجراءات",
diff --git a/locales/en.json b/locales/en.json
index 1940815..c0e6825 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -92,7 +92,8 @@
"rejected": "Rejected.",
"decisionFailed": "Decision failed.",
"rejectReason": "Provide a reason for rejection.",
- "rejectReasonPlaceholder": "Reason…"
+ "rejectReasonPlaceholder": "Reason…",
+ "review": "Review"
},
"actions": {
"title": "Actions",
diff --git a/locales/zh.json b/locales/zh.json
index 389192e..d43730b 100644
--- a/locales/zh.json
+++ b/locales/zh.json
@@ -91,7 +91,8 @@
"rejected": "已驳回。",
"decisionFailed": "操作失败。",
"rejectReason": "请填写驳回理由。",
- "rejectReasonPlaceholder": "理由…"
+ "rejectReasonPlaceholder": "理由…",
+ "review": "查看"
},
"actions": {
"title": "操作",