Approvals Workflow Page

Approval queue for human-in-the-loop decisions. Engineers review proposed changes via inline diffs, add comments, and approve or reject with confirmation dialogs.

Table of contents

  1. src/pages/approvals/index.tsx
  2. src/pages/approvals/components/pending-approvals-list.tsx
  3. src/pages/approvals/components/diff-viewer.tsx
  4. src/pages/approvals/components/approval-dialog.tsx

src/pages/approvals/index.tsx

Main approvals page with filter controls, approval cards, and integrated mutation handling.

import { useState, useMemo } from "react";
import {
  CheckSquare,
  AlertTriangle,
  Filter,
} from "lucide-react";
import {
  useApprovals,
  useApproveSession,
  useRejectSession,
} from "@/hooks/use-approvals";
import type { Approval, ApprovalStatus } from "@/types/approval";
import { PageHeader } from "@/components/layout/page-header";
import { Badge } from "@/components/ui/badge";
import {
  Card,
  CardContent,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
  Select,
  SelectTrigger,
  SelectValue,
  SelectContent,
  SelectItem,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/shared/empty-state";
import { showSuccess, showError } from "@/components/shared/toast-helpers";
import { PendingApprovalsList } from "./components/pending-approvals-list";
import { ApprovalDialog } from "./components/approval-dialog";

// ---------------------------------------------------------------------------
// Filter type
// ---------------------------------------------------------------------------

type ApprovalFilter = "all" | ApprovalStatus;

// ---------------------------------------------------------------------------
// Page component
// ---------------------------------------------------------------------------

export default function ApprovalsPage() {
  const {
    data: approvals,
    isLoading,
    isError,
    error,
  } = useApprovals();
  const approveSession = useApproveSession();
  const rejectSession = useRejectSession();

  const [filter, setFilter] = useState<ApprovalFilter>("all");
  const [expandedId, setExpandedId] = useState<string | null>(null);
  const [dialogState, setDialogState] = useState<{
    approval: Approval;
    action: "approve" | "reject";
  } | null>(null);

  // Filter approvals
  const filteredApprovals = useMemo(() => {
    if (!approvals) return [];
    if (filter === "all") return approvals;
    return approvals.filter((a) => a.status === filter);
  }, [approvals, filter]);

  const pendingCount = useMemo(
    () => approvals?.filter((a) => a.status === "pending").length ?? 0,
    [approvals],
  );

  // Approve handler
  async function handleApprove(approval: Approval, comment?: string) {
    try {
      await approveSession.mutateAsync({
        sessionId: approval.sessionId,
        comment,
      });
      showSuccess(`Approved: ${approval.title}`);
      setDialogState(null);
    } catch (err) {
      showError(
        err instanceof Error ? err.message : "Failed to approve session",
      );
    }
  }

  // Reject handler
  async function handleReject(approval: Approval, reason: string) {
    try {
      await rejectSession.mutateAsync({
        sessionId: approval.sessionId,
        reason,
      });
      showSuccess(`Rejected: ${approval.title}`);
      setDialogState(null);
    } catch (err) {
      showError(
        err instanceof Error ? err.message : "Failed to reject session",
      );
    }
  }

  return (
    <div className="space-y-6">
      {/* ---- Page header ---- */}
      <PageHeader
        title="Approvals"
        description="Review and approve or reject proposed changes from agent sessions"
      >
        <div className="flex items-center gap-3">
          <Badge
            variant={pendingCount > 0 ? "destructive" : "secondary"}
            className="text-sm"
          >
            {pendingCount} Pending
          </Badge>

          {/* Filter select */}
          <Select
            value={filter}
            onValueChange={(v) => setFilter(v as ApprovalFilter)}
          >
            <SelectTrigger className="w-[140px] h-8 text-xs gap-1">
              <Filter className="h-3 w-3" />
              <SelectValue placeholder="Filter" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="all">All</SelectItem>
              <SelectItem value="pending">Pending</SelectItem>
              <SelectItem value="approved">Approved</SelectItem>
              <SelectItem value="rejected">Rejected</SelectItem>
            </SelectContent>
          </Select>
        </div>
      </PageHeader>

      {/* ---- Error state ---- */}
      {isError && (
        <Card className="border-destructive">
          <CardContent className="py-8 text-center">
            <AlertTriangle className="mx-auto h-10 w-10 text-destructive mb-3" />
            <p className="text-sm text-muted-foreground">
              Failed to load approvals.{" "}
              {error instanceof Error ? error.message : "Unknown error."}
            </p>
          </CardContent>
        </Card>
      )}

      {/* ---- Loading state ---- */}
      {isLoading && (
        <div className="space-y-4">
          {Array.from({ length: 3 }).map((_, i) => (
            <Skeleton key={i} className="h-40 rounded-xl" />
          ))}
        </div>
      )}

      {/* ---- Empty state ---- */}
      {!isLoading && filteredApprovals.length === 0 && (
        <EmptyState
          icon={CheckSquare}
          title={
            filter === "pending"
              ? "No pending approvals"
              : "No approvals found"
          }
          description={
            filter === "pending"
              ? "All agent-proposed changes have been reviewed. Great work!"
              : `No approvals match the "${filter}" filter.`
          }
          action={
            filter !== "all" ? (
              <Button
                variant="outline"
                size="sm"
                onClick={() => setFilter("all")}
              >
                Show All
              </Button>
            ) : undefined
          }
        />
      )}

      {/* ---- Approvals list ---- */}
      {!isLoading && filteredApprovals.length > 0 && (
        <PendingApprovalsList
          approvals={filteredApprovals}
          expandedId={expandedId}
          onToggleExpand={(id) =>
            setExpandedId(expandedId === id ? null : id)
          }
          onApprove={(approval) =>
            setDialogState({ approval, action: "approve" })
          }
          onReject={(approval) =>
            setDialogState({ approval, action: "reject" })
          }
        />
      )}

      {/* ---- Approval/Reject dialog ---- */}
      {dialogState && (
        <ApprovalDialog
          approval={dialogState.approval}
          action={dialogState.action}
          isLoading={
            approveSession.isPending || rejectSession.isPending
          }
          onConfirm={(commentOrReason) => {
            if (dialogState.action === "approve") {
              handleApprove(dialogState.approval, commentOrReason);
            } else {
              handleReject(dialogState.approval, commentOrReason ?? "");
            }
          }}
          onCancel={() => setDialogState(null)}
        />
      )}
    </div>
  );
}

src/pages/approvals/components/pending-approvals-list.tsx

Approval card list with expandable diffs, status badges, and action buttons.

import {
  CheckCircle2,
  XCircle,
  Clock,
  ChevronDown,
  ChevronRight,
  FileText,
  FilePlus,
  FileX,
  FilePen,
  ExternalLink,
} from "lucide-react";
import type { Approval } from "@/types/approval";
import {
  Card,
  CardHeader,
  CardTitle,
  CardContent,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/format";
import { AGENT_DISPLAY_NAMES } from "@/lib/constants";
import { DiffViewer } from "./diff-viewer";

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------

interface PendingApprovalsListProps {
  approvals: Approval[];
  expandedId: string | null;
  onToggleExpand: (id: string) => void;
  onApprove: (approval: Approval) => void;
  onReject: (approval: Approval) => void;
}

// ---------------------------------------------------------------------------
// Status configuration
// ---------------------------------------------------------------------------

const STATUS_CONFIG: Record<
  Approval["status"],
  {
    icon: typeof Clock;
    color: string;
    bg: string;
    badgeVariant: "default" | "secondary" | "destructive" | "outline";
    label: string;
  }
> = {
  pending: {
    icon: Clock,
    color: "text-amber-600",
    bg: "bg-amber-100 dark:bg-amber-900/30",
    badgeVariant: "outline",
    label: "Pending",
  },
  approved: {
    icon: CheckCircle2,
    color: "text-emerald-600",
    bg: "bg-emerald-100 dark:bg-emerald-900/30",
    badgeVariant: "default",
    label: "Approved",
  },
  rejected: {
    icon: XCircle,
    color: "text-red-600",
    bg: "bg-red-100 dark:bg-red-900/30",
    badgeVariant: "destructive",
    label: "Rejected",
  },
};

// ---------------------------------------------------------------------------
// Change summary helpers
// ---------------------------------------------------------------------------

function changeTypeCounts(changes: Approval["changes"]) {
  let added = 0;
  let modified = 0;
  let deleted = 0;
  for (const c of changes) {
    if (c.type === "add") added++;
    else if (c.type === "modify") modified++;
    else if (c.type === "delete") deleted++;
  }
  return { added, modified, deleted };
}

function ChangeTypeIcon({ type }: { type: "add" | "modify" | "delete" }) {
  switch (type) {
    case "add":
      return <FilePlus className="h-3 w-3 text-emerald-500" />;
    case "modify":
      return <FilePen className="h-3 w-3 text-amber-500" />;
    case "delete":
      return <FileX className="h-3 w-3 text-red-500" />;
  }
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export function PendingApprovalsList({
  approvals,
  expandedId,
  onToggleExpand,
  onApprove,
  onReject,
}: PendingApprovalsListProps) {
  return (
    <div className="space-y-4" role="list" aria-label="Approval requests">
      {approvals.map((approval) => {
        const config = STATUS_CONFIG[approval.status];
        const StatusIcon = config.icon;
        const isExpanded = expandedId === approval.id;
        const counts = changeTypeCounts(approval.changes);
        const agentName =
          AGENT_DISPLAY_NAMES[approval.requestedBy] ??
          approval.requestedBy;

        return (
          <Card
            key={approval.id}
            className={cn(
              "transition-shadow",
              isExpanded && "shadow-md",
              approval.status === "pending" && "border-amber-200 dark:border-amber-900/50",
            )}
            role="listitem"
          >
            <CardHeader className="pb-3">
              <div className="flex items-start justify-between gap-3">
                {/* Left: title + metadata */}
                <div className="flex-1 min-w-0 space-y-1">
                  <div className="flex items-center gap-2">
                    <div
                      className={cn(
                        "flex-shrink-0 rounded-full p-1",
                        config.bg,
                      )}
                    >
                      <StatusIcon className={cn("h-3.5 w-3.5", config.color)} />
                    </div>
                    <CardTitle className="text-sm font-semibold truncate">
                      {approval.title}
                    </CardTitle>
                    <Badge
                      variant={config.badgeVariant}
                      className="text-[10px] flex-shrink-0"
                    >
                      {config.label}
                    </Badge>
                  </div>

                  <div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
                    <span>
                      Type:{" "}
                      <Badge variant="secondary" className="text-[10px]">
                        {approval.type}
                      </Badge>
                    </span>
                    <span>
                      By:{" "}
                      <Badge variant="outline" className="text-[10px] font-mono">
                        {agentName}
                      </Badge>
                    </span>
                    <span>{formatRelativeTime(approval.requestedAt)}</span>
                    <span>
                      Session:{" "}
                      <code className="text-[10px]">
                        {approval.sessionId.slice(0, 8)}...
                      </code>
                    </span>
                  </div>

                  {/* Changes summary */}
                  <div className="flex items-center gap-3 text-xs text-muted-foreground">
                    {counts.added > 0 && (
                      <span className="flex items-center gap-1 text-emerald-600">
                        <FilePlus className="h-3 w-3" />
                        {counts.added} added
                      </span>
                    )}
                    {counts.modified > 0 && (
                      <span className="flex items-center gap-1 text-amber-600">
                        <FilePen className="h-3 w-3" />
                        {counts.modified} modified
                      </span>
                    )}
                    {counts.deleted > 0 && (
                      <span className="flex items-center gap-1 text-red-600">
                        <FileX className="h-3 w-3" />
                        {counts.deleted} deleted
                      </span>
                    )}
                  </div>
                </div>

                {/* Right: actions */}
                <div className="flex items-center gap-2 flex-shrink-0">
                  {approval.status === "pending" && (
                    <>
                      <Button
                        variant="default"
                        size="sm"
                        className="gap-1.5 bg-emerald-600 hover:bg-emerald-700 text-white"
                        onClick={() => onApprove(approval)}
                      >
                        <CheckCircle2 className="h-3.5 w-3.5" />
                        Approve
                      </Button>
                      <Button
                        variant="destructive"
                        size="sm"
                        className="gap-1.5"
                        onClick={() => onReject(approval)}
                      >
                        <XCircle className="h-3.5 w-3.5" />
                        Reject
                      </Button>
                    </>
                  )}

                  {/* Resolved info */}
                  {approval.status !== "pending" && approval.resolvedAt && (
                    <span className="text-xs text-muted-foreground">
                      {formatRelativeTime(approval.resolvedAt)}
                      {approval.resolvedBy && (
                        <> by {approval.resolvedBy}</>
                      )}
                    </span>
                  )}

                  <Button
                    variant="ghost"
                    size="icon"
                    className="h-8 w-8"
                    onClick={() => onToggleExpand(approval.id)}
                    aria-label={isExpanded ? "Collapse diff" : "Expand diff"}
                  >
                    {isExpanded ? (
                      <ChevronDown className="h-4 w-4" />
                    ) : (
                      <ChevronRight className="h-4 w-4" />
                    )}
                  </Button>
                </div>
              </div>
            </CardHeader>

            {/* Expanded diff viewer */}
            {isExpanded && (
              <CardContent className="pt-0">
                <Separator className="mb-4" />

                {/* Description */}
                {approval.description && (
                  <div className="mb-4">
                    <p className="text-sm text-muted-foreground">
                      {approval.description}
                    </p>
                  </div>
                )}

                {/* Diff viewer */}
                {approval.changes.length > 0 ? (
                  <DiffViewer changes={approval.changes} />
                ) : (
                  <div className="flex items-center justify-center py-8 text-muted-foreground">
                    <FileText className="h-8 w-8 mr-3 opacity-50" />
                    <p className="text-sm">No file changes in this approval.</p>
                  </div>
                )}

                {/* Chat panel — replaces static ApprovalComment[] comments */}
                {/* See: Agent Chat Channel (chat-channel.md) */}
                <ApprovalChatPanel approval={approval} />
              </CardContent>
            )}
          </Card>
        );
      })}
    </div>
  );
}

src/pages/approvals/components/diff-viewer.tsx

File diff viewer with syntax-highlighted additions, removals, and context lines.

import { useState } from "react";
import {
  ChevronDown,
  ChevronRight,
  FilePlus,
  FilePen,
  FileX,
} from "lucide-react";
import type { ApprovalChange } from "@/types/approval";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------

interface DiffViewerProps {
  changes: ApprovalChange[];
}

// ---------------------------------------------------------------------------
// Change type config
// ---------------------------------------------------------------------------

const CHANGE_TYPE_CONFIG: Record<
  ApprovalChange["type"],
  {
    icon: typeof FilePlus;
    color: string;
    bg: string;
    label: string;
  }
> = {
  add: {
    icon: FilePlus,
    color: "text-emerald-600",
    bg: "bg-emerald-100 dark:bg-emerald-900/30",
    label: "Added",
  },
  modify: {
    icon: FilePen,
    color: "text-amber-600",
    bg: "bg-amber-100 dark:bg-amber-900/30",
    label: "Modified",
  },
  delete: {
    icon: FileX,
    color: "text-red-600",
    bg: "bg-red-100 dark:bg-red-900/30",
    label: "Deleted",
  },
};

// ---------------------------------------------------------------------------
// Diff line parser
// ---------------------------------------------------------------------------

interface DiffLine {
  type: "add" | "remove" | "context" | "header";
  content: string;
  lineNumber?: number;
}

function parseDiffLines(diff: string | undefined): DiffLine[] {
  if (!diff) return [];

  const lines = diff.split("\n");
  const result: DiffLine[] = [];
  let addLineNo = 0;
  let removeLineNo = 0;

  for (const line of lines) {
    if (line.startsWith("@@")) {
      // Parse hunk header for line numbers
      const match = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/);
      if (match) {
        removeLineNo = parseInt(match[1], 10);
        addLineNo = parseInt(match[2], 10);
      }
      result.push({ type: "header", content: line });
    } else if (line.startsWith("+")) {
      result.push({
        type: "add",
        content: line.slice(1),
        lineNumber: addLineNo++,
      });
    } else if (line.startsWith("-")) {
      result.push({
        type: "remove",
        content: line.slice(1),
        lineNumber: removeLineNo++,
      });
    } else {
      result.push({
        type: "context",
        content: line.startsWith(" ") ? line.slice(1) : line,
        lineNumber: addLineNo++,
      });
      removeLineNo++;
    }
  }

  return result;
}

// ---------------------------------------------------------------------------
// Diff line styles
// ---------------------------------------------------------------------------

function diffLineClasses(type: DiffLine["type"]): string {
  switch (type) {
    case "add":
      return "bg-emerald-50 dark:bg-emerald-950/30 text-emerald-800 dark:text-emerald-300";
    case "remove":
      return "bg-red-50 dark:bg-red-950/30 text-red-800 dark:text-red-300";
    case "header":
      return "bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-300 font-semibold";
    case "context":
    default:
      return "text-muted-foreground";
  }
}

function diffLinePrefix(type: DiffLine["type"]): string {
  switch (type) {
    case "add":
      return "+";
    case "remove":
      return "-";
    case "header":
      return "";
    case "context":
    default:
      return " ";
  }
}

// ---------------------------------------------------------------------------
// Single file diff section
// ---------------------------------------------------------------------------

function FileDiffSection({ change }: { change: ApprovalChange }) {
  const [collapsed, setCollapsed] = useState(false);
  const config = CHANGE_TYPE_CONFIG[change.type];
  const ChangeIcon = config.icon;
  const diffLines = parseDiffLines(change.diff);

  return (
    <div className="rounded-lg border overflow-hidden">
      {/* File header */}
      <button
        type="button"
        onClick={() => setCollapsed(!collapsed)}
        className={cn(
          "flex items-center gap-2 w-full px-3 py-2 text-left",
          "bg-muted/50 hover:bg-muted/80 transition-colors",
          "border-b",
        )}
      >
        {collapsed ? (
          <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
        ) : (
          <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
        )}
        <ChangeIcon className={cn("h-3.5 w-3.5", config.color)} />
        <code className="text-xs font-mono flex-1 truncate">{change.file}</code>
        <Badge
          variant="outline"
          className={cn("text-[10px] flex-shrink-0", config.color)}
        >
          {config.label}
        </Badge>
      </button>

      {/* Diff content */}
      {!collapsed && (
        <div className="overflow-x-auto">
          {diffLines.length > 0 ? (
            <table className="w-full text-xs font-mono">
              <tbody>
                {diffLines.map((line, index) => (
                  <tr
                    key={index}
                    className={cn(
                      "border-b last:border-b-0",
                      diffLineClasses(line.type),
                    )}
                  >
                    {/* Line number */}
                    <td className="w-12 px-2 py-0.5 text-right text-muted-foreground/50 select-none border-r">
                      {line.lineNumber ?? ""}
                    </td>
                    {/* Prefix */}
                    <td className="w-5 px-1 py-0.5 text-center select-none">
                      {diffLinePrefix(line.type)}
                    </td>
                    {/* Content */}
                    <td className="px-2 py-0.5 whitespace-pre">
                      {line.content}
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          ) : (
            <div className="px-4 py-6 text-center text-xs text-muted-foreground">
              {change.type === "add"
                ? "New file (no diff available)"
                : change.type === "delete"
                  ? "File deleted"
                  : "Binary file or no diff content"}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export function DiffViewer({ changes }: DiffViewerProps) {
  if (changes.length === 0) {
    return (
      <p className="text-sm text-muted-foreground text-center py-4">
        No file changes to display.
      </p>
    );
  }

  return (
    <div className="space-y-3">
      {changes.map((change) => (
        <FileDiffSection key={change.file} change={change} />
      ))}
    </div>
  );
}

src/pages/approvals/components/approval-dialog.tsx

Confirmation dialog for approving or rejecting an approval request with optional comments.

import { useState } from "react";
import { CheckCircle2, XCircle, Loader2 } from "lucide-react";
import type { Approval } from "@/types/approval";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";

// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------

interface ApprovalDialogProps {
  approval: Approval;
  action: "approve" | "reject";
  isLoading: boolean;
  onConfirm: (commentOrReason?: string) => void;
  onCancel: () => void;
}

// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------

export function ApprovalDialog({
  approval,
  action,
  isLoading,
  onConfirm,
  onCancel,
}: ApprovalDialogProps) {
  const [text, setText] = useState("");
  const isApprove = action === "approve";
  const isReject = action === "reject";

  // Reject requires a reason of at least 10 characters
  const isValid = isApprove || text.trim().length >= 10;

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!isValid) return;
    onConfirm(text.trim() || undefined);
  }

  return (
    <Dialog open onOpenChange={(open) => !open && onCancel()}>
      <DialogContent className="sm:max-w-[480px]">
        <form onSubmit={handleSubmit}>
          <DialogHeader>
            <DialogTitle className="flex items-center gap-2">
              {isApprove ? (
                <CheckCircle2 className="h-5 w-5 text-emerald-600" />
              ) : (
                <XCircle className="h-5 w-5 text-red-600" />
              )}
              {isApprove ? "Approve Changes" : "Reject Changes"}
            </DialogTitle>
            <DialogDescription>
              {isApprove
                ? "Confirm that you want to approve the following proposed changes."
                : "Provide a reason for rejecting the proposed changes."}
            </DialogDescription>
          </DialogHeader>

          <div className="space-y-4 py-4">
            {/* Approval summary */}
            <div className="rounded-lg border bg-muted/30 p-3 space-y-2">
              <p className="text-sm font-medium">{approval.title}</p>
              <p className="text-xs text-muted-foreground">
                {approval.description}
              </p>
              <div className="flex items-center gap-2 text-xs">
                <Badge variant="secondary" className="text-[10px]">
                  {approval.type}
                </Badge>
                <span className="text-muted-foreground">
                  {approval.changes.length}{" "}
                  {approval.changes.length === 1 ? "file" : "files"} changed
                </span>
              </div>
            </div>

            <Separator />

            {/* Comment / Reason textarea */}
            <div className="space-y-2">
              <Label htmlFor="approval-text" className="text-sm">
                {isApprove ? "Comment (optional)" : "Reason (required)"}
              </Label>
              <textarea
                id="approval-text"
                value={text}
                onChange={(e) => setText(e.target.value)}
                placeholder={
                  isApprove
                    ? "Add an optional comment..."
                    : "Explain why this change is being rejected (minimum 10 characters)..."
                }
                className={cn(
                  "flex min-h-[100px] w-full rounded-md border border-input",
                  "bg-background px-3 py-2 text-sm ring-offset-background",
                  "placeholder:text-muted-foreground",
                  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
                  "disabled:cursor-not-allowed disabled:opacity-50",
                  "resize-y",
                )}
                disabled={isLoading}
              />
              {isReject && text.length > 0 && text.trim().length < 10 && (
                <p className="text-xs text-red-500">
                  Reason must be at least 10 characters ({text.trim().length}/10).
                </p>
              )}
            </div>
          </div>

          <DialogFooter className="gap-2">
            <Button
              type="button"
              variant="outline"
              onClick={onCancel}
              disabled={isLoading}
            >
              Cancel
            </Button>
            <Button
              type="submit"
              disabled={!isValid || isLoading}
              className={cn(
                isApprove
                  ? "bg-emerald-600 hover:bg-emerald-700 text-white"
                  : "bg-red-600 hover:bg-red-700 text-white",
              )}
            >
              {isLoading ? (
                <>
                  <Loader2 className="mr-2 h-4 w-4 animate-spin" />
                  {isApprove ? "Approving..." : "Rejecting..."}
                </>
              ) : (
                <>
                  {isApprove ? (
                    <CheckCircle2 className="mr-2 h-4 w-4" />
                  ) : (
                    <XCircle className="mr-2 h-4 w-4" />
                  )}
                  {isApprove ? "Approve" : "Reject"}
                </>
              )}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  );
}