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": "操作",