feat(ancpi): complete ePay UI + dedup protection
UI Components (Phase 4): - epay-connect.tsx: connection widget with credit badge, auto-connect - epay-order-button.tsx: per-parcel "Extras CF" button with status - epay-tab.tsx: full "Extrase CF" tab with orders table, filters, download/refresh actions, new order form - Minimal changes to parcel-sync-module.tsx: 5th tab + button on search results + ePay connect widget Dedup Protection: - epay-queue.ts: batch-level dedup (60s window, canonical key from sorted cadastral numbers) - order/route.ts: request nonce idempotency (60s cache) - test/route.ts: refresh protection (30s cache) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,676 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
FileText,
|
||||
Download,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
CreditCard,
|
||||
Plus,
|
||||
Trash2,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Input } from "@/shared/components/ui/input";
|
||||
import { Badge } from "@/shared/components/ui/badge";
|
||||
import { Card, CardContent } from "@/shared/components/ui/card";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import type { EpaySessionStatus } from "./epay-connect";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type CfExtractRecord = {
|
||||
id: string;
|
||||
orderId: string | null;
|
||||
nrCadastral: string;
|
||||
nrCF: string | null;
|
||||
siruta: string | null;
|
||||
judetName: string;
|
||||
uatName: string;
|
||||
status: string;
|
||||
epayStatus: string | null;
|
||||
documentName: string | null;
|
||||
documentDate: string | null;
|
||||
minioPath: string | null;
|
||||
expiresAt: string | null;
|
||||
errorMessage: string | null;
|
||||
version: number;
|
||||
creditsUsed: number;
|
||||
createdAt: string;
|
||||
completedAt: string | null;
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function formatDate(iso?: string | null) {
|
||||
if (!iso) return "\u2014";
|
||||
return new Date(iso).toLocaleDateString("ro-RO", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function formatShortDate(iso?: string | null) {
|
||||
if (!iso) return "\u2014";
|
||||
return new Date(iso).toLocaleDateString("ro-RO", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function isExpired(expiresAt: string | null): boolean {
|
||||
if (!expiresAt) return false;
|
||||
return new Date(expiresAt) < new Date();
|
||||
}
|
||||
|
||||
function isActiveStatus(status: string): boolean {
|
||||
return ["pending", "queued", "cart", "searching", "ordering", "polling", "downloading"].includes(
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
type StatusStyle = { label: string; className: string; pulse?: boolean };
|
||||
|
||||
function statusBadge(status: string, expiresAt: string | null): StatusStyle {
|
||||
if (status === "completed" && isExpired(expiresAt)) {
|
||||
return {
|
||||
label: "Expirat",
|
||||
className:
|
||||
"bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950/40 dark:text-orange-400 dark:border-orange-800",
|
||||
};
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case "pending":
|
||||
case "queued":
|
||||
return {
|
||||
label: "In coada",
|
||||
className:
|
||||
"bg-muted text-muted-foreground border-muted-foreground/20",
|
||||
};
|
||||
case "cart":
|
||||
case "searching":
|
||||
case "ordering":
|
||||
case "polling":
|
||||
case "downloading":
|
||||
return {
|
||||
label: "Se proceseaza",
|
||||
className:
|
||||
"bg-yellow-100 text-yellow-700 border-yellow-200 dark:bg-yellow-950/40 dark:text-yellow-400 dark:border-yellow-800",
|
||||
pulse: true,
|
||||
};
|
||||
case "completed":
|
||||
return {
|
||||
label: "Finalizat",
|
||||
className:
|
||||
"bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-400 dark:border-emerald-800",
|
||||
};
|
||||
case "failed":
|
||||
return {
|
||||
label: "Eroare",
|
||||
className:
|
||||
"bg-rose-100 text-rose-700 border-rose-200 dark:bg-rose-950/40 dark:text-rose-400 dark:border-rose-800",
|
||||
};
|
||||
case "cancelled":
|
||||
return {
|
||||
label: "Anulat",
|
||||
className:
|
||||
"bg-muted text-muted-foreground border-muted-foreground/20",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: status,
|
||||
className:
|
||||
"bg-muted text-muted-foreground border-muted-foreground/20",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function EpayTab() {
|
||||
/* ── ePay session ──────────────────────────────────────────────── */
|
||||
const [epayStatus, setEpayStatus] = useState<EpaySessionStatus>({
|
||||
connected: false,
|
||||
});
|
||||
|
||||
/* ── Orders list ───────────────────────────────────────────────── */
|
||||
const [orders, setOrders] = useState<CfExtractRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
/* ── Manual order input ────────────────────────────────────────── */
|
||||
const [manualInput, setManualInput] = useState("");
|
||||
const [manualSiruta, setManualSiruta] = useState("");
|
||||
const [manualCounty, setManualCounty] = useState("");
|
||||
const [manualUat, setManualUat] = useState("");
|
||||
const [orderSubmitting, setOrderSubmitting] = useState(false);
|
||||
const [orderError, setOrderError] = useState("");
|
||||
const [orderSuccess, setOrderSuccess] = useState("");
|
||||
|
||||
/* ── Filter ────────────────────────────────────────────────────── */
|
||||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||||
|
||||
/* ── Polling ───────────────────────────────────────────────────── */
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const hasActive = orders.some((o) => isActiveStatus(o.status));
|
||||
|
||||
/* ── Fetch session status ──────────────────────────────────────── */
|
||||
const fetchEpayStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/ancpi/session");
|
||||
const data = (await res.json()) as EpaySessionStatus;
|
||||
setEpayStatus(data);
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* ── Fetch orders ──────────────────────────────────────────────── */
|
||||
const fetchOrders = useCallback(
|
||||
async (showRefreshing = false) => {
|
||||
if (showRefreshing) setRefreshing(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ limit: "100" });
|
||||
if (filterStatus !== "all") params.set("status", filterStatus);
|
||||
|
||||
const res = await fetch(`/api/ancpi/orders?${params.toString()}`);
|
||||
const data = (await res.json()) as {
|
||||
orders: CfExtractRecord[];
|
||||
total: number;
|
||||
};
|
||||
setOrders(data.orders);
|
||||
setTotal(data.total);
|
||||
} catch {
|
||||
/* silent */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
},
|
||||
[filterStatus],
|
||||
);
|
||||
|
||||
/* ── Initial load ──────────────────────────────────────────────── */
|
||||
useEffect(() => {
|
||||
void fetchEpayStatus();
|
||||
void fetchOrders();
|
||||
}, [fetchEpayStatus, fetchOrders]);
|
||||
|
||||
/* ── Auto-refresh when active orders exist ─────────────────────── */
|
||||
useEffect(() => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
|
||||
if (hasActive) {
|
||||
pollRef.current = setInterval(() => {
|
||||
void fetchOrders();
|
||||
void fetchEpayStatus();
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
};
|
||||
}, [hasActive, fetchOrders, fetchEpayStatus]);
|
||||
|
||||
/* ── Submit manual order ───────────────────────────────────────── */
|
||||
const submitManualOrder = async () => {
|
||||
setOrderSubmitting(true);
|
||||
setOrderError("");
|
||||
setOrderSuccess("");
|
||||
|
||||
const cadNumbers = manualInput
|
||||
.split(/[,\n;]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (cadNumbers.length === 0) {
|
||||
setOrderError("Introdu cel putin un numar cadastral.");
|
||||
setOrderSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parcels = cadNumbers.map((nr) => ({
|
||||
nrCadastral: nr,
|
||||
siruta: manualSiruta || undefined,
|
||||
judetIndex: 0,
|
||||
judetName: manualCounty || "N/A",
|
||||
uatId: 0,
|
||||
uatName: manualUat || "N/A",
|
||||
}));
|
||||
|
||||
const res = await fetch("/api/ancpi/order", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ parcels }),
|
||||
});
|
||||
|
||||
const data = (await res.json()) as { orders?: unknown[]; error?: string };
|
||||
if (!res.ok || data.error) {
|
||||
setOrderError(data.error ?? "Eroare la trimiterea comenzii.");
|
||||
} else {
|
||||
const count = data.orders?.length ?? cadNumbers.length;
|
||||
setOrderSuccess(
|
||||
`${count} extras${count > 1 ? "e" : ""} CF trimis${count > 1 ? "e" : ""} la procesare.`,
|
||||
);
|
||||
setManualInput("");
|
||||
void fetchOrders(true);
|
||||
void fetchEpayStatus();
|
||||
}
|
||||
} catch {
|
||||
setOrderError("Eroare retea.");
|
||||
} finally {
|
||||
setOrderSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Re-order (for expired extracts) ───────────────────────────── */
|
||||
const handleReorder = async (order: CfExtractRecord) => {
|
||||
try {
|
||||
const res = await fetch("/api/ancpi/order", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
parcels: [
|
||||
{
|
||||
nrCadastral: order.nrCadastral,
|
||||
nrCF: order.nrCF,
|
||||
siruta: order.siruta,
|
||||
judetIndex: 0,
|
||||
judetName: order.judetName,
|
||||
uatId: 0,
|
||||
uatName: order.uatName,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const data = (await res.json()) as { error?: string };
|
||||
if (!res.ok || data.error) {
|
||||
/* show inline later */
|
||||
} else {
|
||||
void fetchOrders(true);
|
||||
void fetchEpayStatus();
|
||||
}
|
||||
} catch {
|
||||
/* silent */
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Render ────────────────────────────────────────────────────── */
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "all", label: "Toate" },
|
||||
{ value: "queued", label: "In coada" },
|
||||
{ value: "polling", label: "In procesare" },
|
||||
{ value: "completed", label: "Finalizate" },
|
||||
{ value: "failed", label: "Erori" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ── Header: ePay status + credits ───────────────────────── */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold">Extrase CF</h3>
|
||||
{epayStatus.connected ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] border-emerald-200 text-emerald-700 dark:border-emerald-800 dark:text-emerald-400"
|
||||
>
|
||||
<CreditCard className="mr-0.5 h-2.5 w-2.5" />
|
||||
{epayStatus.credits ?? "?"} credite
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-[10px] text-muted-foreground">
|
||||
ePay neconectat
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{total} extras{total !== 1 ? "e" : ""}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={refreshing}
|
||||
onClick={() => void fetchOrders(true)}
|
||||
>
|
||||
{refreshing ? (
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Reincarca
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Manual order section ────────────────────────────────── */}
|
||||
{epayStatus.connected && (
|
||||
<Card>
|
||||
<CardContent className="pt-4 space-y-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Comanda noua</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Numere cadastrale (separate prin virgula sau rand nou)
|
||||
</label>
|
||||
<Input
|
||||
placeholder="ex: 50001, 50002, 50003"
|
||||
value={manualInput}
|
||||
onChange={(e) => {
|
||||
setManualInput(e.target.value);
|
||||
setOrderError("");
|
||||
setOrderSuccess("");
|
||||
}}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
SIRUTA (optional)
|
||||
</label>
|
||||
<Input
|
||||
placeholder="57582"
|
||||
value={manualSiruta}
|
||||
onChange={(e) => setManualSiruta(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Judet
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Cluj"
|
||||
value={manualCounty}
|
||||
onChange={(e) => setManualCounty(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
UAT
|
||||
</label>
|
||||
<Input
|
||||
placeholder="Feleacu"
|
||||
value={manualUat}
|
||||
onChange={(e) => setManualUat(e.target.value)}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={orderSubmitting || !manualInput.trim()}
|
||||
onClick={() => void submitManualOrder()}
|
||||
>
|
||||
{orderSubmitting ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<FileText className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Comanda extrase
|
||||
</Button>
|
||||
{orderError && (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{orderError}
|
||||
</span>
|
||||
)}
|
||||
{orderSuccess && (
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
{orderSuccess}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Filter bar ──────────────────────────────────────────── */}
|
||||
<div className="flex gap-1 p-0.5 bg-muted rounded-md w-fit">
|
||||
{statusOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-2.5 py-1 text-xs rounded-sm transition-colors",
|
||||
filterStatus === opt.value
|
||||
? "bg-background text-foreground shadow-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setFilterStatus(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Orders table ────────────────────────────────────────── */}
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 mx-auto mb-3 animate-spin opacity-40" />
|
||||
<p>Se incarca extrasele...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : orders.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-muted-foreground">
|
||||
<FileText className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="font-medium">Niciun extras CF</p>
|
||||
<p className="text-xs mt-1">
|
||||
{epayStatus.connected
|
||||
? "Foloseste sectiunea de mai sus sau butonul de pe fiecare parcela."
|
||||
: "Conecteaza-te la ePay ANCPI pentru a comanda extrase CF."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/40">
|
||||
<th className="px-3 py-2 text-left font-medium">
|
||||
Nr. Cadastral
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium">UAT</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Status</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Data</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Expira</th>
|
||||
<th className="px-3 py-2 text-right font-medium">
|
||||
Actiuni
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{orders.map((order) => {
|
||||
const badge = statusBadge(order.status, order.expiresAt);
|
||||
const expired =
|
||||
order.status === "completed" && isExpired(order.expiresAt);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={order.id}
|
||||
className={cn(
|
||||
"border-b last:border-0 transition-colors hover:bg-muted/30",
|
||||
expired && "opacity-70",
|
||||
)}
|
||||
>
|
||||
{/* Nr. Cadastral */}
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium tabular-nums">
|
||||
{order.nrCadastral}
|
||||
</span>
|
||||
{order.nrCF &&
|
||||
order.nrCF !== order.nrCadastral && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
CF: {order.nrCF}
|
||||
</span>
|
||||
)}
|
||||
{order.version > 1 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
v{order.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* UAT */}
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-col">
|
||||
<span>{order.uatName}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
jud. {order.judetName}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="px-3 py-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium",
|
||||
badge.className,
|
||||
badge.pulse && "animate-pulse",
|
||||
)}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
{order.errorMessage && (
|
||||
<p
|
||||
className="text-[10px] text-destructive mt-0.5 max-w-48 truncate"
|
||||
title={order.errorMessage}
|
||||
>
|
||||
{order.errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Data */}
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground tabular-nums">
|
||||
{formatDate(order.completedAt ?? order.createdAt)}
|
||||
</td>
|
||||
|
||||
{/* Expira */}
|
||||
<td className="px-3 py-2 text-xs tabular-nums">
|
||||
{order.expiresAt ? (
|
||||
<span
|
||||
className={cn(
|
||||
expired
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{expired && (
|
||||
<Clock className="inline h-3 w-3 mr-0.5 -mt-0.5" />
|
||||
)}
|
||||
{formatShortDate(order.expiresAt)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{"\u2014"}</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Actiuni */}
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{order.status === "completed" &&
|
||||
order.minioPath &&
|
||||
!expired && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={`/api/ancpi/download?id=${order.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
Descarca
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{expired && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={!epayStatus.connected}
|
||||
onClick={() => void handleReorder(order)}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Actualizeaza
|
||||
</Button>
|
||||
)}
|
||||
{order.status === "completed" &&
|
||||
order.minioPath &&
|
||||
expired && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-muted-foreground"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={`/api/ancpi/download?id=${order.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
PDF
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Active orders indicator ─────────────────────────────── */}
|
||||
{hasActive && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<span>
|
||||
Se actualizeaza automat la fiecare 10 secunde...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user